Last updated:
Lunora supports Cloudflare Workers Cache for HTTP routes. RPC queries and mutations are POST /_lunora/rpc by design and not cacheable at the edge, so caching is exposed only on httpRoute HTTP endpoints and httpAction handlers.
Enabling Workers Cache
Add the cache block to wrangler.jsonc:
{
"name": "my-app",
"cache": { "enabled": true },
// ...
}When cache.enabled is true, the dev server (lunora dev or the Vite plugin) and the CLI (lunora prepare / lunora deploy) automatically bump compatibility_date to at least 2026-05-01 if it is lower. You do not need to know or set the date manually. Lunora reconciles it for you and preserves the comments and formatting in wrangler.jsonc.
You can also enable cache per entrypoint in exports:
{
"exports": {
"default": {
"type": "webpack",
"cache": { "enabled": true },
},
},
}Declarative cache headers on httpRoute
The httpRoute builder carries three chainable methods for cache headers. They attach automatically to the response, both JSON and streaming (SSE).
| Method | Header | Purpose |
|---|---|---|
.cacheControl(value) | Cache-Control | TTL, public/private, stale-while-revalidate, etc. |
.cacheTag(value) | Cache-Tag | Logical tag for bulk purging via ctx.cache.purge. |
.vary(value) | Vary | Store separate cached variants per request header. |
import { httpRoute, v } from "lunorash/server";
export const getProduct = httpRoute
.get("/api/products/:id")
.params({ id: v.string() })
.cacheControl("public, max-age=300, stale-while-revalidate=3600")
.cacheTag("products")
.vary("Accept-Encoding")
.handler(async ({ ctx, params }) => {
const product = await ctx.runQuery(api.products.get, params);
return product ?? new Response("Not Found", { status: 404 });
});The headers are sent on both 200 OK and 204 No Content responses, as well as streaming SSE responses.
Programmatic cache purging
Actions run in the Worker (not inside the Durable Object), so they receive the ctx.cache binding. Purge by tag, or purge everything:
import { action } from "@/lunora/_generated/server";
export const refreshProducts = action.action(async ({ ctx }) => {
if (!ctx.cache) {
throw new Error("Workers Cache is not enabled in wrangler.jsonc");
}
await ctx.cache.purge({ tags: ["products"] });
return { ok: true };
});ctx.cache.purge accepts:
tags?: string[]purges every cached response whoseCache-Tagmatches any listed tag.purgeEverything?: booleanwipes the entire cache for this worker.
Why only actions?
Queries and mutations run inside the Durable Object, where the Cloudflare cache binding is not available. Actions run in the Worker itself, alongside the fetch handler, so they can reach ExecutionContext.cache. If you need to invalidate cache as a side effect of a mutation, schedule an action from the mutation or call ctx.runAction from the mutation's handler.
Memoizing an expensive action result
Everything above caches an HTTP response. RPC is POST /_lunora/rpc, so it
is not edge-cacheable, and an action result therefore has no cache in front of
it. Two different problems hide under "cache my action", and only one of them
needs code.
If the action is fetching from an upstream API, cache the fetch
Anything you read over HTTP that changes on a slow clock (model metadata, pricing
tables, currency rates) should be cached at the fetch, not at the action.
Cloudflare does this for you:
const response = await fetch("https://api.example.com/models", {
// Cache the upstream response at the edge for an hour, keyed on the URL.
cf: { cacheEverything: true, cacheTtl: 3600 },
});That needs no Lunora code, no table and no TTL bookkeeping, and every colocation shares one cached copy. Reach for the recipe below only when there is no URL to key on.
Otherwise, memoize into a table
For a genuinely computed result (an embedding, a derived summary), store it in a table keyed by a hash of the arguments:
// lunora/schema.ts
actionCache: defineTable({
key: v.string(), // sha-256 of `${name}:${JSON.stringify(args)}`
value: v.string(), // JSON-encoded result
expiresAt: v.number(), // epoch ms
leaseUntil: v.number(), // epoch ms; guards against a stampede on a cold key
})
.index("by_key", ["key"], { unique: true })
// Reap expired rows automatically — see the warning below.
.ttl("expiresAt"),// lunora/lib/actionCache.ts
export const readCache = internalQuery.input({ key: v.string() }).query(async ({ ctx, args }) => {
const hit = await ctx.db
.query("actionCache")
.withIndex("by_key", (q) => q.eq("key", args.key))
.first();
return hit && hit.expiresAt > ctx.now ? (JSON.parse(hit.value) as unknown) : null;
});
export const writeCache = internalMutation.input({ key: v.string(), ttlMs: v.number(), value: v.string() }).mutation(async ({ ctx, args }) => {
const existing = await ctx.db
.query("actionCache")
.withIndex("by_key", (q) => q.eq("key", args.key))
.first();
const row = { expiresAt: ctx.now + args.ttlMs, key: args.key, value: args.value };
await (existing ? ctx.db.replace(existing._id, row) : ctx.db.insert("actionCache", row));
});The action claims the key before computing, so concurrent callers do not all run the expensive work:
// lunora/lib/actionCache.ts — one mutation, two outcomes.
export const claimOrRead = internalMutation.input({ key: v.string(), leaseMs: v.number() }).mutation(async ({ ctx, args }) => {
const hit = await ctx.db
.query("actionCache")
.withIndex("by_key", (q) => q.eq("key", args.key))
.first();
if (hit && hit.expiresAt > ctx.now) {
return { value: hit.value } as const;
}
// A live lease means someone else is already computing this key.
if (hit && hit.leaseUntil > ctx.now) {
return { claimed: false } as const;
}
const row = { expiresAt: hit?.expiresAt ?? 0, key: args.key, leaseUntil: ctx.now + args.leaseMs, value: hit?.value ?? "" };
await (hit ? ctx.db.replace(hit._id, row) : ctx.db.insert("actionCache", row));
return { claimed: true } as const;
});// The action: compute only if we won the claim.
const outcome = await ctx.runMutation(internal.lib_actionCache.claimOrRead, { key, leaseMs: 30_000 });
if ("value" in outcome) {
return JSON.parse(outcome.value) as Result;
}
if (!outcome.claimed) {
// Someone else is computing it. Wait and re-read, or serve stale — your call.
return await waitForOrServeStale(key);
}
const fresh = await expensiveThing();
await ctx.runMutation(internal.lib_actionCache.writeCache, { key, ttlMs: 86_400_000, value: JSON.stringify(fresh) });
return fresh;The Durable Object makes the claim atomic, and that is the part you would otherwise have to build. A cold key with a hundred concurrent callers is the
classic stampede. claimOrRead is a mutation, and a mutation runs to completion inside one Durable Object without interleaving, so exactly one caller can
observe "no live lease" and take it. The other ninety-nine get claimed: false on their first round-trip.
What is not free: the compute itself has to stay outside the mutation. A mutation runs in a transaction that can roll back and must not do slow external I/O, so the winner computes in the action and writes back after. That means the lease is what serialises callers, not the write. A lease needs a duration, so pick one longer than the work and shorter than your patience.
This is why Lunora ships no cache add-on: the hard half is the atomic claim, and a Durable Object gives you that in a single mutation.
Reap expired rows. An entry past expiresAt is ignored on read but still occupies the shard, so a cache without a sweep grows without bound.
.ttl("expiresAt") on the table points the runtime at the expiry column and it reaps them for you; .ttl(field, {after}) instead treats field as a
timestamp and expires field + after.
Scaffolding cache-aware routes
Use the built-in generator to scaffold a route with cache examples commented in:
vis generate lunora-http-route --name=listProductsThis produces:
import { httpRoute, v } from "lunorash/server";
export const listProducts = httpRoute
.get("/api/listProducts")
.searchParams({
// q: v.optional(v.string()),
})
// .cacheControl("public, max-age=300, stale-while-revalidate=3600")
// .cacheTag("listProducts")
// .vary("Accept-Encoding")
.handler(async ({ ctx, searchParams }) => {
return { ok: true, searchParams };
});Uncomment the cache lines and adjust the values for your use case.
Best practices
- Tag by entity, not by endpoint. Use
cacheTag("products")on every route that returns product data, then purge"products"once when anything changes. Don't create one tag per route. - Use
stale-while-revalidatefor reads.public, max-age=300, stale-while-revalidate=3600means Cloudflare serves from cache for 5 minutes, then revalidates in the background for up to an hour. Your backend sees reduced load. - Vary on content negotiation. If your endpoint returns different formats based on
Acceptor compresses differently perAccept-Encoding, add.vary("Accept-Encoding")so Cloudflare stores separate variants. - Invalidate eagerly, cache generously. Cache reads for a long TTL, then purge immediately when a mutation changes the underlying data. A short TTL is a band-aid for missing invalidation.
- Keep cache headers deterministic.
.cacheControl()and.cacheTag()accept plain strings. There's no built-in parsing or validation, so a typo in the header value is sent verbatim. Test your headers withcurl -Ior the Cloudflare dashboard.