Readiness: Implemented
Build an app
Caching
Route, function, and fetch caches, off by default and opt-in at each call site. Time-based revalidation with an optional stale-while-revalidate window, and on-demand invalidation by tag or by path. In the memory of one process unless you name somewhere else to keep it. A prerendered page that states a lifetime is served from the build and regenerated through the same cache, which is incremental static regeneration. What uf does not have is named here too, because a cache you cannot describe is a cache you cannot trust.
What you will be able to do: cache rendered routes, public function results and fetch responses, set revalidation windows, invalidate by tag or by path, and choose where the cache lives.
What you need first: routes that render on a server — Rendering modes. Fetch directly inside Server Components; loaders are optional.
Turning it on
uf.config.js// @flow
export default {
app: {
rendering: {
cache: {
route: true,
fetch: true,
data: true,
},
},
},
};
All three default to false. There is no actions cache: the key was removed in
uf 0.10, and actions: true is still refused by name.
Turning route on does not start caching anything. It makes a stated lifetime
take effect, and a page states one from inside its own render:
// @flow
import { cacheLife, cacheTag } from "@uniflowed/server/cache";
export default async function Posts() {
cacheLife({ revalidate: 60, expire: 600 });
cacheTag("posts");
const posts = await loadPosts();
return <PostList posts={posts} />;
}
A page that says nothing is rendered for every request, exactly as it is with the cache off. There is no default lifetime and there will not be one: an entry with no stated end is an answer served until somebody notices.
Cache one function across routes
Use cacheFunction for an expensive, public upstream read shared by several
Server Components. Flow remains the BFF; the upstream service owns its database
and migrations.
import { cacheFunction } from "@uniflowed/server/cache";
export const product = cacheFunction(
"catalog.product.v1",
async (id: string) => {
const response = await fetch(`https://catalog.example/products/${encodeURIComponent(id)}`);
if (!response.ok) throw new Error("Catalog request failed");
return response.json();
},
{ lifetime: { revalidate: 60 }, tags: ["catalog"] },
);
export default async function ProductPage({ params }) {
const item = await product(params.id);
return <h1>{item.name}</h1>;
}
Choose an application-unique name and change it when the function's contract changes. Arguments are part of the key: plain data objects have sorted keys, array order is preserved, and unsupported values, accessors, cycles and oversized arguments are refused. Inputs must describe the whole public result; do not capture request-specific values in the callback. Cached results should be treated as immutable.
The wrapper uses the request's configured store, including custom providers and
build-scoped durable keys. An explicit store option selects a different store
or allows use outside an HTTP request. When data is disabled, it executes the
function without storing its result. cacheLife, cacheTag, and noStore inside
the callback apply to its own cache entry.
revalidateTag("catalog") expires matching entries. After a mutation, use
await updateTag("catalog") to also finish durable invalidation before reading
again. Fills that started before invalidation cannot repopulate the store, and a
new reader does not join them. A separately cached parent route still needs its
own tags or revalidatePath; invalidation does not infer application dependencies.
A cached function cannot read cookies(), headers(), draftMode(), nonce()
or requestId(). The build names direct reads, aliases, namespace calls and local
helpers in the callback. Imported helpers and dynamic accesses are covered by the
runtime scope, which throws before returning request data. This is a bounded
static check, not a general purity proof. Read and authorize private data outside
the shared cache.
What the two numbers mean
revalidate is when the entry becomes stale — it may be old. expire is
when it may not be served at all. expire defaults to revalidate, so
stale-while-revalidate is something you ask for rather than something you get:
{ revalidate: 60 }— fresh for a minute; the request after that renders and waits.{ revalidate: 60, expire: 600 }— fresh for a minute, then for nine more minutes a reader is handed the old document immediately and a refresh runs behind them. After ten minutes a reader waits again.
Called more than once in a render, the shortest lifetime wins. A page made of a thing that changes hourly and a thing that changes by the minute is a page that changes by the minute.
Invalidating on demand
// @flow
import { revalidatePath, revalidateTag } from "@uniflowed/server/cache";
export async function POST(request: Request): Promise<Response> {
await createPost(await request.json());
revalidateTag("posts");
return new Response(null, { status: 204 });
}
revalidateTag reaches every entry filled under that tag; revalidatePath
reaches every entry filled for that URL. Both answer with the number of entries
they expired, so a handler can log what it did rather than what it meant to.
An invalidated entry is expired, not marked stale. A stale-while-revalidate window does not apply to it: a tag is invalidated because somebody changed the thing it names, so the entry is known wrong rather than possibly old, and there is no window in which serving it is acceptable.
What is never cached
A rendered document is stored only if all of these hold, and the failing one is
reported in the response's x-uf-cache header as BYPASS:
- the render stated a lifetime;
- the render did not call
cookies(),headers()ordraftMode(); - the render answered
200; - the render set no cookie.
The header is written before the document ends, because the document streams, so it says what was known when the shell was ready. For a page that does not suspend that is the whole answer. For one that does, a boundary that resolves later can still state a lifetime or read the request; the store decides on the whole document either way, and the header is what the shell said.
The second is the one that matters most and the one that is easiest to get
wrong. Those three functions are how a document comes to be about one person,
and a document about one person in a cache shared by every person is the worst
bug a framework can have. uf counts those calls across the whole document
rather than up to the shell — a component inside a <Suspense> boundary renders
long after the shell resolved, and a cache that decided early would cache a
document whose tail was about somebody.
A read from a middleware does not count. A guard that reads a session cookie and lets the request through has not made the page vary, and treating it as though it had would mean no application with authentication could cache anything.
uf build refuses such a route before it writes anything. When a route's
render reaches cookies(), headers() or draftMode(), and the route either
states a lifetime with route on or is
prerendered, the build fails under
rsc/request-state-in-static-route. The error names three things:
- the route, and whether it is cached or prerendered;
- the function it reads;
- the chain of imports from the page or layout down to the module that imports that function, since the import to cut is somewhere on that path.
A read reached through a layout is one error naming every route under that layout.
The build reads imports, not calls, so a module that imports cookies counts as
reading cookies. It does not follow a "use client" component, or a
"use server" module, whose functions run when an action is called rather than
while the page renders. A namespace import of @uniflowed/server and a dynamic
import() are not counted. The BYPASS above stays behind all of them, when
the page is rendered.
The fetch cache
@uniflowed/fetch stays what it says it is — a failed response that is a failed
promise, a timeout, and a retry policy — so caching wraps it rather than growing
inside it:
// @flow
import { createFetch } from "@uniflowed/fetch";
import { createCachedFetch } from "@uniflowed/server/cache";
const api = createCachedFetch({ client: createFetch({ baseURL }), name: "api" });
const users = await api.request("/users", {
cache: { lifetime: { revalidate: 60 }, tags: ["users"] },
});
Opt-in per call. A request with no cache option behaves exactly as the
underlying client's does, so wrapping a client changes nothing until somebody
states a lifetime for one request. There is no "cache every GET" mode: every GET
is not cacheable, and a framework guessing which ones are is how a cache serves
one person's account page to another.
name is required because two clients with different base URLs both request
/users, and a key built from the path alone would file one client's answer
under the other's name.
Where it lives
In memory, in one process, until you say otherwise. That is still the
default and it is still a real limit: four server processes behind a load
balancer hold four caches and they disagree, a restart empties one, and
revalidateTag in one of them does not reach the other three.
Naming a store is what fixes it:
uf.config.js// @flow
export default {
app: {
rendering: {
cache: {
route: true,
store: "filesystem",
// Optional. Defaults to `.uf/cache/route` under the project.
storeDir: "/var/cache/uf",
},
},
},
};
Then a restart finds the entries where it left them, every process of one
deployment reads one store, and revalidateTag takes an entry out of the store
all of them fill from. A URL is rendered once, served from the store until it
goes stale, refreshed behind a reader inside the window, and dropped the moment
a mutation says it is wrong. Started from the build instead of from a render,
the same store is regeneration.
Persistence is a second opt-in on top of the first, and nothing about the first
changes. A route still has to state a lifetime; a render that read the request
is still never stored; actions: true is still refused.
Entries are keyed by the build
A durable entry outlives the build that produced it, which an in-memory one cannot, so the identity of the build is the first part of every durable key. A deploy therefore reads a cold cache rather than answering the new build's URLs with the previous build's documents — a class of bug that has no symptom except a page that is subtly wrong.
uf build mints that identity and writes it beside the server bundle. Set
UF_BUILD_ID when two artefacts have to be one build — a blue/green pair, or
a rebuild of a tagged commit — which is the same variable, read for the same
reason, as the one server action ids are derived from.
A store asked to persist with no build identity to key it by refuses to
start, and says so. It does not fall back to memory: a project that asked for
a cache surviving restarts and got one that does not would see nothing but a
MISS on every cold request, which is what a cold cache looks like anyway.
Somewhere else than a disk
"filesystem" is uf's built-in and it is a convenience, not an architecture.
Behind the store's one method — resolve — anything that can answer six calls
can hold the entries, so a Redis, a KV namespace or an S3 bucket is a module you
write and name:
cache/redis.js// @flow
import type { CacheProvider, DurableCacheEntry } from "@uniflowed/server/cache";
export function createCacheProvider(): CacheProvider {
return {
name: "redis",
async read(key) {
const found = await client.get(key);
return found == null ? null : JSON.parse(found);
},
async write(key, entry: DurableCacheEntry) {
await client.set(key, JSON.stringify(entry));
for (const tag of entry.tags) await client.sAdd(`tag:${tag}`, key);
},
async remove(key) {
await client.del(key);
},
async invalidateTag(tag) {
const keys = await client.sMembers(`tag:${tag}`);
await client.del([...keys, `tag:${tag}`]);
return keys.length;
},
async invalidatePath(path) {
return dropAll(await client.sMembers(`path:${path}`));
},
async clear() {
await client.flushDb();
},
};
}
cache: { route: true, store: "./cache/redis.js" }
A provider stores entries and nothing else. It does not decide what is stale, it
does not run a fill, and it never sees a value uf has not already encoded — so
the five answers above hold identically whichever store is behind them, and a
provider cannot get them subtly different. The value is encoded by uf because a
rendered document is a Uint8Array and plain JSON turns one into an object of
six thousand numeric keys with no error anywhere.
A deployment adapter can hand a store its provider directly rather than through the config, which is what a target with a binding rather than a package does:
createCacheStore({ provider: myProvider, build: BUILD_ID });
--adapter edge refuses "filesystem" at the build rather than linking
node:fs into a Worker that would fail on its first request. A Worker's durable
store is a module you name, and uf ships one: "@uniflowed/server/cache/kv"
keeps entries in Workers KV, reading the namespace bound as UF_CACHE. It is
also what --adapter edge links for a build that
regenerates pages and names no store, and
the wrangler.json it writes then binds UF_CACHE.
What a shared store does not reach
An entry another process is already holding in memory when you invalidate a
tag. It is gone from the shared store, so nothing fills from it again and no
process without a copy can serve it; a process that has a copy serves it until
its own revalidate — the number the page itself named as how stale it may be.
Entries are bounded by count on both sides: the least recently used goes from memory when the store is full, and the filesystem provider sweeps its directory coldest-first, expired entries first. Nothing sweeps in the background, because a cache with a timer in it is a process that will not exit.
Regenerating prerendered pages
A page uf build prerenders that states a lifetime is the route cache started
from the build. When app.rendering.modes allows isr and route is on, the
build writes the page's document under dist/__uf/regenerate/ instead of at its
URL, and the server answers the page:
- The first request in a process gets the build's document, dated when the build
rendered it, so
x-uf-cacheisHITwhile that is inside the lifetime andSTALEonce it is not. A durable store is read first, because a copy another process regenerated is newer than the build's. - A regenerated page is never unservable on a clock. Past its lifetime a
reader gets the document there is and one refresh starts behind it; when the
refresh succeeds, the new document replaces the old one in memory and in the
durable store in one step. Only an
expirethe page stated makes a reader wait. revalidateTagandrevalidatePathexpire it like any other entry, and they also write down when they ran, in the store every process reads. So a process that comes to the page afterwards — another one, or one that restarted — renders it rather than serving the build's older copy. A build that rendered its copy after the invalidation is still served from; one built before it and deployed after it is not. The record is one more entry through the provider'swrite, with no tags, no path and no end, and it counts towards a provider's bound like any entry.- A query string does not make a second entry, because the page was prerendered for its path.
A tag alone does not make a page regenerate. Nothing here is stored without a
lifetime, and that holds with more force for a regenerated page: one with no end
is one another process could go on serving from its memory after revalidateTag
took it out of the store you share.
The rendering guide has the store each target keeps
regenerated pages in. Where a target can keep them durably it does so by default
— .uf/cache/route on disk, Workers KV on the edge — and a Lambda has to name a
provider module, because an instance's memory and its /tmp go with the
instance.
Why Workers KV and not the Cache API
The provider seam has to find every entry filled under a tag or for a path, to invalidate them. The Cache API is a map from request to response that cannot list what it holds, so an invalidation could only remove entries it already knew by name — and a page regenerated in another isolate is exactly the one it would not know. It is also per data center, so a page regenerated in one location would be regenerated again in every other. KV lists by prefix and is one namespace for every location.
The cost is KV's own. A write takes up to a minute to be visible in other locations, and a listing is eventually consistent too, so a page regenerated in one place may be regenerated once more elsewhere inside that minute, and an invalidation that follows a write inside it may miss that write and leave that copy until the page's lifetime passes. Both windows are Cloudflare's number rather than uf's.
What it is worth
uf run bench:route-cacheOne page whose loader takes 50 ms, served twice, twenty times, on Node 24 and an M-series laptop:
| first request | second request | renders per pair | renders for 10 at once | |
|---|---|---|---|---|
| no cache | 52.35 ms | 52.41 ms | 2 | 10 |
| route cache | 52.45 ms | 0.16 ms | 1 | 1 |
The cold request is fractionally slower: it renders the document and keeps a
copy. It still streams — the request that fills the entry is answered as the
document is produced, and a copy is drained into the store behind it, so a
$loading.js fallback reaches the browser before the page it stands in for
whether or not the page is ever kept. A request that arrives while the entry is
being filled joins the fill and is answered with the whole document.
The last column is the half a median cannot show. Ten simultaneous requests for a page nobody has asked for yet are one render, because a request that arrives while an entry is being filled joins the fill rather than starting a second one.
Those numbers are the memory store. A durable one adds a read of the provider on a key this process has not seen — and a read is what it replaces a render with, which is the trade the whole feature is: 0.16 ms becomes a file read or a network hop instead of 52 ms, on every process rather than on the one that rendered it.
In the browser
Everything above is on the server. The client router has one cache of its own:
app.rendering.staleTime, how many seconds a navigation shows a page it already
fetched without asking again. It is off by default, and router.refresh() and
every server action clear it.
Keeping what a navigation fetched
has what is kept and when.
In development, window.__UF_NAVIGATION_CACHE__() shows keys and expiration
times without exposing loader values. A component inside a Link can use
useLinkStatus() to display that link's pending navigation.
What is not here
- The action cache.
rendering.cache.actionsis gone, andtrueis refused rather than ignored: writes are not cached implicitly.rendering.cache.datais the function cache above. uf build --compile. A compiled binary answers from bytes it carries rather than from a directory, so it has its own copy of the resolution order and not its own copy of the cache — and a regenerating page, whose document is not at its URL, is rendered for every request.uf preview,uf startand every--adaptertarget do have the cache.
Where to go next
Signing in is next: the OAuth flow uf ships, and the session store you can replace.
Edit this pagedocs/app/guide/cache/$page.mdx