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

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

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 whose Cache-Tag matches any listed tag.
  • purgeEverything?: boolean wipes 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=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.