State and background work
Defer small work with waitUntil
Keep a request response fast while allowing a short non-critical promise to continue through the execution context.
Some work doesn’t belong in the response time. After we save a link in D1, we want to delete the recent-links cache key. The user doesn’t need to wait for that. They need their 201.
Normally, once your handler returns a Response, the runtime is free to shut the isolate down. Any promise still pending may never finish. ctx.waitUntil() fixes that for one promise: it tells the runtime to keep the event alive until the promise settles.
Use it after the authoritative write
Here is the pattern in a Hono route. Save first, respond fast, invalidate in the background:
app.post('/api/links', async c => {
const link = await createLink(c.env.DB, await c.req.json())
c.executionCtx.waitUntil(c.env.CACHE.delete('recent-links'))
return c.json(link, 201)
})
In Hono, c.executionCtx is the same ctx a plain fetch handler receives. The await on createLink stays, because the response depends on it. The cache delete does not, so it goes to waitUntil.
Pass the promise, not a function call
waitUntil tracks the promise you hand it. This is a bug:
invalidateCache(env) // starts, but nobody tracks it
ctx.waitUntil(Promise.resolve())
The async function starts, the runtime sees an already-resolved promise, and the real work may get cut off. Pass the actual promise: ctx.waitUntil(invalidateCache(env)).
Handle rejections yourself
The client already got a 201. If the background promise rejects, there is nobody to tell. Catch it and log it with the request ID, or the failure disappears:
ctx.waitUntil(
env.CACHE.delete('recent-links').catch(err =>
console.error(JSON.stringify({ requestId, error: err.message }))
)
)
It’s not a job queue
This is the part people get wrong. waitUntil extends a request by a bit. It doesn’t retry, it doesn’t persist, and it doesn’t survive the isolate being evicted. It is best effort.
So here is the test I apply. If losing this work would make accepted data wrong, create a user-visible inconsistency, or need a retry, it does not go in waitUntil. It goes to a Queue, which we cover next. Optional analytics and cache invalidation pass the test. Sending a confirmation email or writing the export file does not.
And design for the loss. Our cache has a 60-second TTL. If the delete never runs, readers see a stale list for at most a minute, then it heals itself. That’s what “harmless when it fails” looks like.
Now add this to your own POST /api/links. Create a link, then immediately GET /api/links twice. The first read should log a cache miss, because the key was deleted, and the second a hit.
Lesson completed