hono-workers-cache
v0.4.0
Published
Cloudflare Workers Cache middleware & purge helpers for Hono (works with HonoX)
Maintainers
Readme
hono-workers-cache
Declarative Cloudflare Workers Cache middleware and purge helpers for Hono (works with HonoX).
Next.js Cache Components semantics for Hono: declare a lifetime with the same stale / revalidate / expire vocabulary and the same built-in profiles ('seconds' … 'max') as Next.js' cacheLife(), tag what a route renders with cacheTag(), and call revalidateTag() when the data changes. The difference: the cache is Cloudflare's edge, in front of your Worker — a cache hit costs zero CPU and your code never even runs.
import { Hono } from 'hono'
import { cacheTag, revalidateTag, workersCache } from 'hono-workers-cache'
const app = new Hono()
// Cached read — same profile names and values as Next.js' cacheLife()
app.get('/posts/:id', workersCache('hours'), async (c) => {
const post = await getPostById(c.req.param('id'))
cacheTag(c, `post-${post.id}`) // tag so we can purge after an update
return c.json(post)
})
// Mutation — persist changes, then purge the tag so the next GET regenerates
app.post('/posts/:id', async (c) => {
const id = c.req.param('id')
const { body } = await c.req.json<{ body: string }>()
await updatePost(id, body)
await revalidateTag(`post-${id}`, c)
return c.json({ ok: true })
})What
stale/revalidate/expireand the profile names mean — and how they map to edge cache headers — is explained in The cache model.
What is Workers Cache?
Workers Cache (2026) is an edge cache that runs in front of your Worker. Enable it with "cache": { "enabled": true } in wrangler.jsonc (Wrangler >= 4.69.0) and Cloudflare checks the cache before invoking your Worker — on a HIT, your code (including Hono) never executes.
That shapes this package's entire design: it never reads or writes the cache. It only does two things:
- Declare policy —
workersCache()/cacheLife()stampCache-Control/CDN-Cache-Control/Cache-Tagon responses - Invalidate —
revalidateTag()/revalidatePath()/revalidateEverything()wrapcache.purge()in a Next.js-revalidateTag-style API
Not the same as hono/cache
hono/cache is built on the older Cache API (caches.default). They are independent systems:
| | Workers Cache (this package) | Cache API (hono/cache) |
| --- | --- | --- |
| Where it runs | In front of the Worker | Inside the Worker |
| Worker invoked on HIT | No (zero CPU) | Yes, every request |
| Read-through | Automatic | Manual put() / match() |
| Request collapsing | Automatic | No |
| Tiered cache | Automatic | No |
| Invalidation | ctx.cache.purge() (tags / path prefixes / everything) | cache.delete() (single data center only) |
| Purge scope | Per Worker entrypoint | Per URL |
For new Workers, Cloudflare recommends Workers Cache. Keep using hono/cache when you need fine-grained programmatic control from inside the Worker.
Install
npm i hono-workers-cache
# pnpm add hono-workers-cache / bun add hono-workers-cacheSetup
Enable Workers Cache in your Wrangler configuration (Wrangler >= 4.69.0). This is the only piece that cannot be configured from runtime code:
// wrangler.jsonc
{
"cache": { "enabled": true }
}Usage
Plain Hono
import { Hono } from 'hono'
import { cacheLife, noCache, revalidatePath, workersCache } from 'hono-workers-cache'
const app = new Hono()
// Zero config: the 'default' profile (stale 5 min / revalidate 15 min / expire never)
app.get('/about', workersCache(), (c) => c.html('<h1>About</h1>'))
// A profile name, exactly like Next.js' cacheLife('hours')
app.get('/posts/:id', workersCache('hours'), (c) => c.json({ id: c.req.param('id') }))
// Custom lifetime — the same object Next.js accepts
app.get('/feed', workersCache({ stale: 60, revalidate: 3600, expire: 86400 }), handler)
// Tighten the lifetime from inside a handler (shortest wins, like Next.js)
app.get('/pages/:slug', workersCache('days'), async (c) => {
const page = await getPage(c.req.param('slug'))
if (page.frequentlyUpdated) cacheLife(c, 'minutes')
return c.html(page.html)
})
// Never cached (also strips cache headers stamped upstream)
app.get('/admin', noCache(), (c) => c.text('admin'))
// Invalidate after a mutation
app.post('/posts/:id', async (c) => {
const id = c.req.param('id')
await update(id)
await revalidatePath(`/posts/${id}`, c) // exact path
return c.json({ ok: true })
})HonoX: per route
This is a plain Hono middleware — HonoX simply provides _middleware.ts / createRoute() as the places to apply it.
// app/routes/blog/[id].tsx
import { createRoute } from 'honox/factory'
import { cacheTag, workersCache } from 'hono-workers-cache'
export default createRoute(
workersCache({
profile: 'hours',
stale: 0, // purges reach users instantly
tags: (c) => [`post-${c.req.param('id')}`, 'posts'],
}),
async (c) => {
const post = await getPost(c.req.param('id'))
cacheTag(c, `author-${post.authorId}`) // append tags from inside the handler
return c.render(<Post post={post} />)
},
)HonoX: per directory
// app/routes/blog/_middleware.ts
import { createRoute } from 'honox/factory'
import { workersCache } from 'hono-workers-cache'
export default createRoute(workersCache({ profile: 'minutes', tags: ['blog'] }))// app/routes/admin/_middleware.ts
import { createRoute } from 'honox/factory'
import { noCache } from 'hono-workers-cache'
export default createRoute(noCache())Invalidation
import { revalidateTag } from 'hono-workers-cache'
export const POST = createRoute(async (c) => {
const id = c.req.param('id')
await updatePost(id, await c.req.parseBody())
await revalidateTag([`post-${id}`, 'posts'], c) // c is optional
return c.redirect(`/blog/${id}`)
})revalidatePath() and revalidateEverything() work the same way. Outside the Workers runtime (Node during dev, tests) the helpers become a no-op resolving to { ok: false, reason: 'cache-unavailable' } — they never throw and never break dev.
await revalidatePath('/blog/post-1') // exact path (path: tag)
await revalidatePath('/blog/:id', 'route') // every URL the route produced (route: tag)
await revalidatePath('/blog/', 'prefix') // everything under /blog/ (Cloudflare pathPrefixes)
await revalidateEverything() // the whole entrypoint cacheThe cache model
Every cached response has one lifetime, described in Next.js' three-value vocabulary and projected onto Cloudflare's two cache tiers (browser and edge):
| Field | Meaning (same as Next.js) | Becomes |
| --- | --- | --- |
| stale | How long clients reuse a copy without asking the server | Cache-Control: public, max-age=<stale> (browser) |
| revalidate | How long the server serves a copy before regenerating in the background | CDN-Cache-Control: public, max-age=<revalidate> (edge) |
| expire | Max total lifetime before switching to a blocking fresh fetch | stale-while-revalidate=<expire − revalidate> (edge); 'never' → one year |
So workersCache('hours') (stale 5 min / revalidate 1 hour / expire 1 day) emits:
Cache-Control: public, max-age=300 ← browsers
CDN-Cache-Control: public, max-age=3600, stale-while-revalidate=82800 ← edge
Cache-Tag: route:/posts/:id,path:/posts/123Within revalidate the edge serves instantly; past it, the edge keeps serving stale while regenerating in the background (exactly Next.js' behavior, implemented by the CDN's SWR); past expire, the request blocks and fetches fresh. Browsers hold a copy for at most stale seconds before checking back with the edge.
stale and instant purges
stale is the one knob that trades Next.js-likeness against purge latency: a purge (revalidateTag()) empties the edge immediately, but browsers that hold a fresh copy won't ask again for up to stale seconds. Set stale: 0 and browsers revalidate with the edge on every request (max-age=0, must-revalidate — conditional 304s keep it cheap), so purges reach every user instantly:
// Daily content, but updates must be visible the moment you purge
app.get('/news/:id', workersCache({ profile: 'days', stale: 0 }), handler)The edge still absorbs all the traffic — you give up nothing but the browser-local cache.
Built-in profiles
Same names and values as Next.js (cacheLifeProfiles export):
| Profile | stale | revalidate | expire |
| --- | --- | --- | --- |
| default | 5 minutes | 15 minutes | never (1 year at the edge) |
| seconds | 30 seconds | 1 second | 1 minute |
| minutes | 5 minutes | 1 minute | 1 hour |
| hours | 5 minutes | 1 hour | 1 day |
| days | 5 minutes | 1 day | 1 week |
| weeks | 5 minutes | 1 week | 30 days |
| max | 5 minutes | 30 days | 1 year |
API
workersCache(profile | options): MiddlewareHandler
Accepts a profile name (workersCache('hours')) or an options object. With no arguments, the default profile applies.
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| profile | 'default' \| 'seconds' \| 'minutes' \| 'hours' \| 'days' \| 'weeks' \| 'max' | 'default' | Base profile — same names and values as Next.js |
| stale | number | from profile | Browser freshness in seconds. 0 = revalidate with the edge every request (instant purges) |
| revalidate | number | from profile | Edge freshness in seconds; past it, the edge serves stale while regenerating in the background |
| expire | number \| 'never' | from profile | Max total lifetime; past it, the edge blocks and fetches fresh. 'never' = one year |
| staleIfError | number | — | Serve stale for this many seconds when the origin returns 5xx |
| tags | string[] \| (c: Context) => string[] | — | Cache-Tag values; a function is evaluated per request |
| routeTag | boolean | true | Auto-add a route:/blog/:id tag from the matched route pattern, enabling revalidatePath(path, 'route') |
| pathTag | boolean | true | Auto-add a path:/blog/123 tag with the concrete request path (query excluded, trailing slash normalized), enabling exact-path revalidatePath(path) |
| cacheControl | string | — | Escape hatch: emitted verbatim as the single Cache-Control (no CDN-Cache-Control). Mutually exclusive with the lifetime options (profile / stale / revalidate / expire / staleIfError) — the type forbids combining them |
Explicit fields override the profile, e.g. { profile: 'days', stale: 0 } = daily content with instant purges. tags / routeTag / pathTag combine with either variant. The route: and path: tag prefixes are reserved by the automatic tags — avoid them in your own tags / cacheTag() values.
The middleware leaves the response untouched when any of these hold:
- The request method is not GET/HEAD (Workers Cache only stores GET/HEAD; they share one entry)
- The response carries
Set-Cookie(the edge auto-BYPASSes it; aconsole.warnfires in dev) - The status is not in the cacheable whitelist (200/203/204/301/302/304/308/404/410)
- The handler already set
Cache-Controlitself (your explicit header wins)
cacheLife(c, profile | { stale?, revalidate?, expire? }): void
The counterpart of Next.js' cacheLife() — declare a lifetime from a handler or deeply nested code (requires a Hono Context instead of a use cache scope). Fields declared here override the middleware's defaults; across multiple calls for one response, the shortest value wins per field, same as nested cacheLife() calls in Next.js.
cacheTag(c, ...tags): void
Append tags to the response from a handler or deeply nested code. Merged with the middleware's tags and automatic route: / path: tags. Same name as Next.js' cacheTag, but requires a Hono Context (no async local store).
noCache(): MiddlewareHandler
Sets Cache-Control: no-store and deletes any upstream-stamped CDN-Cache-Control, Cloudflare-CDN-Cache-Control, and Cache-Tag.
Purge helpers
Named after Next.js (revalidateTag / revalidatePath), with Workers Cache semantics: arrays are accepted for batch purge, c is optional, and results are Promise<PurgeResult> (not void).
revalidateTag(tags: string | string[], c?: Context): Promise<PurgeResult>
revalidatePath(paths: string | string[], type?: 'route' | 'prefix', c?: Context): Promise<PurgeResult>
revalidateEverything(c?: Context): Promise<PurgeResult>
interface PurgeResult {
ok: boolean
reason?: 'cache-unavailable' | 'purge-failed'
error?: unknown
}revalidatePath has three modes, mirroring Next.js' revalidatePath(path, type?) in Hono terms:
| Call | Next.js counterpart | Purges |
| --- | --- | --- |
| revalidatePath('/blog/post-1') | revalidatePath('/blog/post-1') | The path: tag — exactly that path, all query-string variants together. Requires pathTag (default on) |
| revalidatePath('/blog/:id', 'route') | revalidatePath('/blog/[slug]', 'page') | The route: tag — every URL that Hono route produced. Requires routeTag (default on); with routeTag: false the purge silently matches nothing |
| revalidatePath('/blog/', 'prefix') | revalidatePath('/blog', 'layout') | Cloudflare pathPrefixes — every cached path starting with the prefix. A pure string prefix: /blog also matches /blogger, so end directory prefixes with / |
Like Next.js' revalidatePath, all of this reaches only the server-side (edge) cache — browsers keep their copy until stale runs out (see stale and instant purges). Tag purges also only affect entries cached after the middleware started emitting that tag — mind the transition right after enabling pathTag / routeTag or upgrading.
Runtime resolution order: duck-typed c.executionCtx.cache → dynamic import('cloudflare:workers') → no-op { ok: false, reason: 'cache-unavailable' }. Passing an empty array resolves { ok: true } immediately without touching the cache. purge() failures — both rejections and resolved { success: false } results (e.g. rate limiting) — map to { ok: false, reason: 'purge-failed', error }.
Migrating from 0.3.x
| 0.3.x | Now |
| --- | --- |
| revalidatePath('/blog/') (path-prefix purge) | revalidatePath('/blog/', 'prefix') — the default is now an exact-path tag purge |
| purgeEverything() | revalidateEverything() |
The middleware also emits one extra automatic tag per response (path:<request path>); disable with pathTag: false.
Migrating from 0.2.x
The maxAge / staleWhileRevalidate / strategy options were replaced by the Next.js vocabulary:
| 0.2.x | Now |
| --- | --- |
| workersCache({ maxAge: 3600, staleWhileRevalidate: 300 }) | workersCache({ stale: 0, revalidate: 3600, expire: 3900 }) |
| staleWhileRevalidate: 'unbounded' | expire: 'never' |
| strategy: 'cdn-split' (default) | stale: 0 — the split is now always on; stale controls the browser tier |
| strategy: 'shared' | a nonzero stale (browser-local caching), or cacheControl for one verbatim header |
| cacheControl (edge policy, browser revalidated) | cacheControl (single verbatim Cache-Control, both tiers) |
Note the default policy changed with the model: workersCache() now applies the Next.js default profile, whose stale: 300 lets browsers hold a copy for up to 5 minutes. Pass stale: 0 where purges must be instant.
Caveats
- Billing — enabling Workers Cache makes every request billable as a standard request, including static asset requests that are normally free. (No CPU billing on HITs.)
- The cache goes cold on every deploy by default: the Worker version is part of the cache key. If you deploy frequently, consider
cache.cross_version_cache: truecombined with tag-based purging. - The hostname is not part of the cache key (neither is the HTTP method). If one Worker serves multiple domains, partition with
ctx.props— otherwise the domains share entries. vite devdoes not reproduce edge caching. Verify on a Preview URL (it has its own cache, independent of production). On a HIT your Worker never runs — loggers won't see cached requests; observe via theCf-Cache-Statusheader and the Workers dashboard.- Bundling with Vite: the purge helpers dynamically import
cloudflare:workers, which must stay external.@cloudflare/vite-pluginexternalizes it automatically; with other setups add it tobuild.rollupOptions.external(see examples/honox).
Design Notes
- Next.js vocabulary, CDN mechanics.
stale/revalidate/expireand the built-in profiles are copied verbatim from Next.js (defaultCacheLifeProfiles), so knowledge and mental models transfer 1:1. The mechanics differ: Next.js implements them with an in-process cache; here they compile to standardCache-Control/CDN-Cache-Controldirectives that Cloudflare's edge executes —revalidatebecomes edgemax-age,expire − revalidatebecomes the SWR window,stalebecomes browsermax-age. - The split is always on (from vinext's CDN adapter): browsers and the edge always get separate policies. The old
strategyoption dissolved intostale—stale: 0reproducescdn-split(no stale copy survives a purge), a nonzerostaleapproximatessharedwithout giving up the edge-side SWR. expire: 'never'= one year. Cloudflare has no infinite window, and follows RFC 5861 in treating a value-lessstale-while-revalidateas zero-width — so the directive always spells out the seconds.'never'uses the full year rather thanyear − revalidate; it declares "serve stale as long as possible", not arithmetic.cacheLife(c, …)merges like Next.js: explicit handler declarations beat the middleware's defaults, and across multiple calls the shortest value wins per field — the same rule Next.js applies to nesteduse cachescopes (explicitRevalidateis only ever lowered).cacheControlis verbatim — nos-maxage→max-agenormalization. vinext normalizes because it processes strings generated by Next.js; here the string is written by you. It is emitted as the singleCache-Control, so the standards-maxage+max-agetechnique works unmodified.- Hands-off guards (non-GET/HEAD,
Set-Cookie, non-cacheable status, pre-setCache-Control) exist because stamping headers in those cases is either pointless (the edge bypasses anyway) or would override explicit user intent. Cache-Tagsize guard: Cloudflare's limits are 16KB per header / 1024 bytes per tag, and violations are silently dropped. We cap conservatively at 8KB, skip tags containing commas or exceeding 1024 bytes, deduplicate, and measure withTextEncoder(multibyte-correct).- Automatic route tag
route:/blog/:idfrom the matched route template (via theroutePath()route helper) enables purging per route template; skipped for the bare/*pattern. - Purge runtime resolution never throws outside Workers — duck typing on
ctx.cache, then dynamic import, then acache-unavailableno-op, so Node-based dev and tests keep working. - Purge result inspection (extension over the initial design): per the official docs,
purge()resolves to{ success, errors }rather than rejecting on failures such as rate limiting. The helpers inspect an explicitsuccess: falseand map it to{ ok: false, reason: 'purge-failed', error: errors }in addition to catching rejections. noCache()deletes upstream cache headers rather than just settingno-store, mirroring vinext's defensive non-cached branch — a strayCDN-Cache-Controlfrom an upstream middleware would otherwise still cache the response at the edge.
Examples
examples/hono-minimal— plain Hono +wrangler.jsonc, deployable withwrangler deployexamples/honox— HonoX with_middleware.ts/createRoute()patterns and Vite bundling
Development
pnpm install
pnpm test # unit tests (Node)
pnpm test:workerd # integration test on workerd via @cloudflare/vitest-pool-workers
pnpm typecheck
pnpm lint
pnpm buildReleases are published manually from the Publish workflow (workflow_dispatch on main). Bump version in package.json before triggering the workflow. Publishing runs with npm provenance.
License
MIT
