Skip to content
DocsconceptsDocumentation

Caching

Edge caching with Cloudflare Workers Cache — declarative headers on HTTP routes and programmatic cache purging from actions.

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 on the surfaces that have a cacheable URL: httpRoute HTTP endpoints, httpAction handlers, and a query published over REST with .expose({ rest: true, cache }).

The httpRoute methods below write whatever value you pass — they are the lower-level escape hatch. The REST cache policy is the guarded surface: it re-derives public vs private from the live request, never stores a credentialed exchange in the shared cache, and folds the varying headers into the cache key rather than trusting Vary. Prefer it when the endpoint is a procedure.

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

MethodHeaderPurpose
.cacheControl(value)Cache-ControlTTL, public/private, stale-while-revalidate, etc.
.cacheTag(value)Cache-TagLogical tag for bulk purging via ctx.cache.purge.
.vary(value)VaryStore 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

ctx.cache is built by the Worker, so it reaches HTTP action handlers only — the ones mounted on httpRouter(). Purge by tag, or purge everything:

// lunora/http.ts
import { httpAction, httpRouter } from "lunorash/server";

const app = httpRouter();

app.post(
    "/admin/refresh-products",
    httpAction(async (ctx) => {
        if (!ctx.cache) {
            return new Response("Workers Cache is not enabled in wrangler.jsonc", { status: 501 });
        }

        await ctx.cache.purge({ tags: ["products"] });

        return Response.json({ ok: true });
    }),
);

export default app;

ctx.cache.purge accepts:

  • tags?: string[] purges every cached response whose Cache-Tag matches any listed tag.
  • purgeEverything?: boolean wipes the entire cache for this worker.

Why only HTTP actions?

Queries, mutations, and RPC actions all run inside the Durable Object, where the Cloudflare cache binding does not exist — ctx.cache is undefined for every one of them, which is why the example above branches on it. Only an HTTP action's context is built in the Worker itself, alongside the fetch handler, so only it can reach ExecutionContext.cache.

So a mutation cannot purge, and there is no ctx hop that gets it one: MutationCtx has no runAction, and an action reached through ctx.runMutation/ctx.runAction runs on the caller's DO context, without a cache. Drive the write from an HTTP route instead — call the mutation with ctx.runMutation, then purge in the same handler once it returns.

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, use defineActionCache

For a genuinely computed result (an embedding, a derived summary), the framework already ships the memo: defineActionCache is a schema-extension component that adds one table and derives the key for you, so there is no hand-rolled hash and no bespoke read/write pair.

// lunora/cache.ts
import { defineActionCache } from "@lunora/server";

export const cache = defineActionCache({ ttlMs: 60 * 60 * 1000 });
// lunora/schema.ts — merges in as `actionCache_entries`
export const schema = defineSchema({ ... }).extend(cache.extension);

// Re-export so codegen registers it; schedule it from a cron for bulk cleanup.
export const { purgeExpired } = cache.functions;
// lunora/embeddings.ts
import { action, v } from "@/lunora/_generated/server";

import { cache } from "./cache";

export const embed = action
    .input({ text: v.string() })
    .action(async ({ args, ctx }) => cache.wrap(ctx, "embed", args, async () => callEmbeddingModel(args.text)));

wrap(ctx, name, args, compute) returns the stored value on a hit and runs compute on a miss. The key is a SHA-256 over name plus the args encoded through the wire codec with sorted keys, so { a, b } and { b, a } are one entry and bigint / Date / bytes arguments key distinctly.

invalidate(ctx, name, args) drops one entry; invalidateAll(ctx, name) drops every entry under a name and returns { complete, deleted } — call it again while complete is false. Expiry is lazy (a read past the TTL is a miss and the row is overwritten by that same call), each miss reaps a few expired rows, and purgeExpired reclaims entries whose names went quiet. A result over maxValueBytes (512 KB default) is returned but not stored.

See @lunora/server for the full surface.

No single-flight. Two callers that miss at the same instant both run compute; the unique index on the key means one result is stored rather than two rows appearing, but the work was done twice. Preventing that needs a lock held across an arbitrarily long external call, whose own failure mode — a crashed holder wedging the key — is worse than a cold start being paid twice. If you genuinely need the stampede guard, take the lease yourself in a mutation: a mutation runs to completion inside one Durable Object without interleaving, so exactly one caller can observe "no live lease" and take it, and the compute stays in the action.

wrap reads and writes through the app's own ctx.db, so on an .rls("required") schema the merged actionCache_entries table needs a policy like any other. Declare one that denies client access outright — entries are keyed by a digest and carry no ownership column, so there is nothing for a row policy to scope to.

Scaffolding cache-aware routes

Use the built-in generator to scaffold a route with cache examples commented in:

vis generate lunora-http-route --name=listProducts

This 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

  1. 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.
  2. Use stale-while-revalidate for reads. public, max-age=300, stale-while-revalidate=3600 means Cloudflare serves from cache for 5 minutes, then revalidates in the background for up to an hour. Your backend sees reduced load.
  3. Vary on content negotiation. If your endpoint returns different formats based on Accept or compresses differently per Accept-Encoding, add .vary("Accept-Encoding") so Cloudflare stores separate variants.
  4. 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.
  5. 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 with curl -I or the Cloudflare dashboard.