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:

DeploymentpageCacheRecommendation
Development or one Bun processOmit itUse Furin's existing process-local render caches
Several Bun processes, containers, or hostsredisPageCache(...)Required for coherent origin invalidation and regeneration
Vercel Build OutputOmit itLet the Vercel adapter use native prerenders and Runtime Cache
Pure static exportNot usedThere 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

ResponseCache-ControlETag
SSRno-store, no-cache, must-revalidateNone
SSGpublic, max-age=0, must-revalidate, s-maxage=31536000Build ID plus cache timestamp
Fresh ISRpublic, max-age=0, s-maxage=N, stale-while-revalidate=NBuild ID plus generation timestamp
Stale ISRSame, with s-maxage=0Existing ISR ETag
Cached route with requestLoader()private, no-storeNone
/_client/* assetspublic, max-age=31536000, immutableNot 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:

src/pages/blog/index.tsx
export const route = defineRoute("/blog")
  .config({
    mode: "isr",
    revalidate: 60,
    tags: ["posts"],
  })
  .loader(async () => ({ posts: await db.posts.findMany() }))
  .page(({ posts }) => <PostList posts={posts} />);
  1. A miss renders and caches only a healthy 200 result.
  2. A fresh hit returns the stored HTML.
  3. The first stale hit returns the old HTML and starts background regeneration.
  4. Other stale hits reuse the same in-flight regeneration, including hits handled by another replica when the page cache is shared.
  5. 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:

ts
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:

ts
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:

ts
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

EventShared behavior
First ISR/SSG/PPR missOne replica acquires the render lease; other replicas wait briefly for its artifact
Stale ISR hitThe 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 deploymentArtifacts remain build-specific; invalidation state remains shared
Render finishes after invalidationIts conditional commit is rejected, so stale HTML cannot be republished
Redis read, write, or lease fails during renderingFurin 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:

src/pages/blog/index.tsx
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

ts
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:

tsx
.config({
  mode: "isr",
  revalidate: 300,
  tags: ["posts", "homepage"],
})

Then invalidate the concrete cached paths registered under those tags:

ts
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:

ts
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:

text
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:

ts
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():

ts
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.

Comments