Caching
Furin caches complete public route results according to the route's explicit rendering mode. By default, route caches are in-process and scoped to each mounted Furin instance. Configure a page-cache adapter to share ISR, runtime SSG, and PPR public artifacts across replicas. CDN caching remains a separate layer controlled by response headers and an optional purge callback.
Choose A Cache Setup
Route files do not change when the deployment topology changes. Choose the page-cache backend at application composition time:
| Deployment | pageCache | Recommendation |
|---|---|---|
| Development or one Bun process | Omit it | Use Furin's existing process-local render caches |
| Several Bun processes, containers, or hosts | redisPageCache(...) | Required for coherent origin invalidation and regeneration |
| Vercel Build Output | Omit it | Let the Vercel adapter use native prerenders and Runtime Cache |
| Pure static export | Not used | There is no request-time page cache |
The page cache stores public render artifacts. It does not replace the runtime data cache, browser prefetch cache, Sync journal, or CDN cache.
Vercel deployments reject an explicit pageCache during application initialization. Native Prerender and Runtime Cache integrations are the single source of truth on that target; combining them with Redis would create two independently invalidated page-cache layers.
Cache Contract By Mode
| Response | Cache-Control | ETag |
|---|---|---|
| SSR | no-store, no-cache, must-revalidate | None |
| SSG | public, max-age=0, must-revalidate, s-maxage=31536000 | Build ID plus cache timestamp |
| Fresh ISR | public, max-age=0, s-maxage=N, stale-while-revalidate=N | Build ID plus generation timestamp |
| Stale ISR | Same, with s-maxage=0 | Existing ISR ETag |
Cached route with requestLoader() | private, no-store | None |
/_client/* assets | public, max-age=31536000, immutable | Not required; filenames are content-hashed |
Browsers revalidate cached HTML, while a shared cache may use s-maxage. When If-None-Match matches a current SSG or ISR entry, Furin returns 304 without the body.
SSG and ISR responses also expose Cache-Tag with the concrete resolved path. This is metadata for an upstream cache; it does not make an arbitrary CDN purge itself.
ISR Lifecycle
For mode: "isr", revalidate is seconds:
export const route = defineRoute("/blog")
.config({
mode: "isr",
revalidate: 60,
tags: ["posts"],
})
.loader(async () => ({ posts: await db.posts.findMany() }))
.page(({ posts }) => <PostList posts={posts} />);
- A miss renders and caches only a healthy
200result. - A fresh hit returns the stored HTML.
- The first stale hit returns the old HTML and starts background regeneration.
- Other stale hits reuse the same in-flight regeneration, including hits handled by another replica when the page cache is shared.
- Success replaces the entry atomically; failure retains the last good entry.
ISR and PPR cache keys include the query as the route's query schema decodes it, serialized like a Link URL: undeclared params are dropped, schema defaults are omitted and params are sorted, so ?page=01, ?page=1&utm_source=x and ?page=1 share one entry. Invalid queries are rejected before rendering and never cached. Routes without a query schema are keyed on the pathname alone. Avoid query-dependent SSG output: SSG entries are keyed by concrete pathname.
Runtime Data Cache
Use the platform-neutral runtime cache for expensive API or database results that are shared outside one route:
import { getCache } from "@teyik0/furin/cache";
const weatherCache = getCache({ namespace: "weather" });
await weatherCache.set("forecast:bayonne", forecast, {
tags: ["weather"],
ttl: 300,
});
const cached = await weatherCache.get("forecast:bayonne");
await weatherCache.expireTag("weather");
The application API stays the same on every target:
- development and Bun deployments use Furin's process-local memory provider;
- the Vercel adapter installs Vercel Runtime Cache automatically before the user server starts;
- future deployment adapters can install their own provider without changing application imports.
Cache handles resolve the active provider when an operation runs, not when the module is imported. This guarantees that top-level handles created by statically imported route modules still use the deployment provider.
The memory provider retains up to 1,000 values across namespaces and evicts the least recently used value when full. Empty namespaces are released; existing handles remain valid after eviction.
The runtime data cache is intentionally separate from the page cache. Page artifacts need conditional writes and distributed regeneration leases so a render that started before an invalidation cannot publish stale HTML afterward.
Shared Page Cache
Use the Redis page-cache adapter when several Bun replicas serve the same application. Create the Redis client and adapter once when the server module starts, then pass the adapter to every replica through the same application code:
import { RedisClient } from "bun";
import { Elysia } from "elysia";
import { furin } from "@teyik0/furin";
import { redisPageCache } from "@teyik0/furin/cache/redis";
const redisUrl = process.env.REDIS_URL;
if (redisUrl === undefined) {
throw new Error("REDIS_URL is required in a multi-replica deployment");
}
const client = new RedisClient(redisUrl);
const pageCache = redisPageCache({
client,
namespace: "storefront:production",
});
new Elysia()
.use(await furin({ pagesDir: "./src/pages", pageCache }))
.listen(3000);
RedisClient connects lazily. The application owns it and should close it during graceful shutdown. Furin does not create a second Redis connection or close the supplied client.
Committed artifacts are retained for seven days by default. Set retentionMs on redisPageCache() to change that window. Furin expires entry keys, prunes expired path/tag index members, and keeps fencing metadata for an additional day so rolling deployments cannot revive stale renders.
Every replica of one environment must use the same namespace. Keep it stable across releases: use a value such as storefront:production, not a deployment ID or build ID. Use a different namespace for staging, preview, and production. Mounted Furin applications remain isolated by their normalized prefix, so one adapter can be reused safely:
new Elysia()
.use(await furin({ pagesDir: "./src/pages", pageCache }))
.use(await furin({ pagesDir: "./src/admin", prefix: "/admin", pageCache }))
.listen(3000);
Build IDs isolate rendered artifacts during rolling deployments, while invalidation versions deliberately span build IDs. An older replica therefore cannot publish a render that started before a mutation. Do not add the build ID to the Redis namespace; doing so would split invalidation state between releases.
Redis coordinates both invalidation fencing and ISR regeneration leases. A cache outage degrades page rendering to a fresh no-store response rather than caching an uncoordinated result.
Furin uses Bun's native Redis client and Redis Lua scripts. Use Redis 7.2 or later. Bun's Redis client currently expects a direct Redis endpoint and does not support Redis Cluster or Sentinel discovery; a managed service can still be used through a compatible direct or proxy endpoint.
What Furin Coordinates
| Event | Shared behavior |
|---|---|
| First ISR/SSG/PPR miss | One replica acquires the render lease; other replicas wait briefly for its artifact |
| Stale ISR hit | The stale artifact is served while one replica regenerates it |
revalidatePath() | All query variants of the page, or all layout descendants, become invalid |
revalidateTag() | Every artifact carrying one of the tags becomes invalid |
| Rolling deployment | Artifacts remain build-specific; invalidation state remains shared |
| Render finishes after invalidation | Its conditional commit is rejected, so stale HTML cannot be republished |
| Redis read, write, or lease fails during rendering | Furin renders fresh and responds with no-store |
The adapter is the source of truth; Redis Pub/Sub is not required. Notifications alone would be insufficient because a disconnected or restarting replica could miss an invalidation.
Route Authoring Stays The Same
Routes continue to declare their mode, revalidation window, and tags:
export const route = defineRoute("/blog")
.config({
mode: "isr",
revalidate: 60,
tags: ["posts"],
})
.loader(async () => ({ posts: await db.posts.findMany() }))
.page(({ posts }) => <PostList posts={posts} />);
No Redis key construction belongs in route code. Furin derives keys from the mounted prefix, build ID, render mode, concrete pathname, and, where the mode supports it, query string.
Invalidate A Path
import { revalidatePath } from "@teyik0/furin";
await revalidatePath("/blog/hello", "page");
await revalidatePath("/blog", "layout");
page evicts the exact pathname and all of its query variants. layout evicts the path and its descendants. The promise resolves after origin invalidation is committed, returns whether an origin entry was affected, queues an X-Furin-Revalidate signal for the current response, and invokes the configured CDN purger asynchronously.
Manual invalidation is asynchronous because a shared adapter must durably advance its invalidation version before the mutation is considered complete. Always await it in application code. The handler invalidate() of furinInvalidate() or Furin Sync already performs this wait for you.
Logical paths are unprefixed. revalidatePath() intentionally checks every mounted Furin instance; physical CDN purge paths include each instance prefix.
Invalidate Tags
Declare data dependencies in route config:
.config({
mode: "isr",
revalidate: 300,
tags: ["posts", "homepage"],
})
Then invalidate the concrete cached paths registered under those tags:
import { revalidateTag } from "@teyik0/furin";
await revalidateTag("posts");
await revalidateTag(["posts", "homepage"]);
Tag invalidation spans mounted instances but evicts only paths registered by each owning instance. Generated route types augment FurinCacheTags, providing autocomplete for tags discovered in route config.
Prefer tags when a mutation changes shared data used by several pages. Prefer paths when the changed URL is already known. This keeps invalidation precise instead of clearing the entire route cache.
For an Elysia mutation, the handler's invalidate() is the shortest path:
import { furinInvalidate } from "@teyik0/furin";
import { Elysia } from "elysia";
export const postsApi = new Elysia({ prefix: "/api/posts" })
.use(furinInvalidate())
.post("/", ({ body, invalidate }) => {
invalidate({ tags: ["posts"] });
return db.posts.create(body);
});
The mutation response is not completed until origin invalidation succeeds. It also carries X-Furin-Revalidate for the initiating browser.
Browser Prefetch Invalidation
Furin mutation helpers add a response header such as:
X-Furin-Revalidate: /blog/hello,/blog:layout
The generated router observes same-page browser fetch() responses, evicts matching prefetch entries, and refreshes the active route when it is affected. applyRevalidateHeader() is exported from @teyik0/furin/link for a custom integration, but application code normally does not need to call it.
For other tabs and devices, configure Sync & Invalidations. Sync distributes invalidation events; it is not a page-cache adapter. Configure pageCache separately when origin replicas must share public artifacts.
CDN Purging
Pass a purge callback to each furin() mount whose pages sit behind a CDN that needs explicit invalidation:
await furin({
pagesDir: "./src/pages",
cachePurger: async (paths) => {
await cdn.purge(paths);
},
});
Each callback receives only the physical (prefixed) paths of its own mount. setCachePurger(callback) from @teyik0/furin registers a process-wide fallback for mounts without their own cachePurger. Furin batches and deduplicates physical paths per callback for an invalidation call. The callback is fire-and-forget; a rejection is written to the structured logger and does not fail the mutation response.
CDNs with surrogate-key purging can also receive the semantic tags passed to revalidateTag():
await furin({
pagesDir: "./src/pages",
cacheTagPurger: async (tags) => {
await cdn.purgeTags(tags);
},
});
revalidateTag() invalidates tags across every mounted instance, so each instance's cacheTagPurger receives the whole tag list. Instances sharing one callback receive a single deduplicated call. Without a cacheTagPurger, a mount uses the tag purger installed by its deployment adapter (Vercel purges its native cache tags), if any. Like cachePurger, the callback is fire-and-forget and a rejection is only logged.
An origin cache and a CDN cache must both be coherent. In a multi-replica Bun deployment, configure the shared page cache in addition to CDN purging. Purging only the CDN can allow another origin replica with a local cache to repopulate it with stale HTML.
Deploy Changes
Production responses include X-Furin-Build-ID; the initial document includes the matching meta value. If an SPA navigation reaches a different build, the client reloads the document instead of mixing old browser modules with new server output.
This build check protects code/data compatibility. It does not replace rolling-deployment coordination for origin caches or sync adapters.