How toUse waitUntil for Non-Blocking Cache Writes

When building applications on Cloudflare Workers, every millisecond counts. If you're caching data to KV, waiting for the write to complete before sending a response adds unnecessary latency. The user doesn't need to wait for the cache to be updated: they just need their data.

The waitUntil API lets you schedule work to run after the response is sent. This is perfect for cache writes, analytics, batched logging, or any operation that doesn't affect the response. By deferring these writes, you can return responses faster while still ensuring the work completes.

Create a Cache Store with waitUntil

Here's a cache store implementation that uses waitUntil for non-blocking writes:

lib/cache.ts
export namespace Cache { type CacheKey = string | { cacheKey: string } | { cacheKey(): string }; interface StoreWriteOptions { ttl?: number; } abstract class Store { abstract read(key: CacheKey): Promise<string | null>; abstract write(key: CacheKey, value: string, options?: StoreWriteOptions): Promise<void>; abstract delete(key: CacheKey): Promise<void>; abstract exists(key: CacheKey): Promise<boolean>; abstract fetch( key: CacheKey, fn: () => Promise<string>, options?: StoreWriteOptions, ): Promise<string>; protected getKey(key: CacheKey): string { if (typeof key === "string") return key; if (typeof key.cacheKey === "string") return key.cacheKey; return key.cacheKey(); } } interface KVStoreWriteOptions extends StoreWriteOptions { metadata?: KVNamespacePutOptions["metadata"]; } export class KVStore extends Store { constructor( private readonly kv: KVNamespace, private readonly waitUntil: (promise: Promise<unknown>) => void, ) { super(); } async read(key: CacheKey): Promise<string | null> { return this.kv.get(this.getKey(key), "text"); } async write( key: CacheKey, value: string, { ttl, metadata }: KVStoreWriteOptions = {}, ): Promise<void> { this.waitUntil( this.kv.put(this.getKey(key), value, { expirationTtl: ttl, metadata, }), ); } async delete(key: CacheKey): Promise<void> { await this.kv.delete(this.getKey(key)); } async exists(key: CacheKey): Promise<boolean> { let result = await this.read(key); return result !== null; } async fetch( key: CacheKey, fn: () => Promise<string>, options?: KVStoreWriteOptions, ): Promise<string> { let cached = await this.read(key); if (cached !== null) { return cached; } let value = await fn(); await this.write(key, value, options); return value; } async list(prefix?: string, limit = 1000): Promise<string[]> { let list = await this.kv.list({ prefix, limit }); return list.keys.map((key) => key.name); } } }

The key insight is in the write method. Instead of awaiting the KV put operation, it passes the promise to waitUntil. This tells Cloudflare to keep the Worker alive until the write completes, but the response can be sent immediately.

Instantiate the Store with ExecutionContext

To use the store, you need access to the waitUntil function from the ExecutionContext. In a Cloudflare Worker, this is available in the fetch handler:

app/worker.ts
import { Cache } from "~/lib/cache"; export default { async fetch(request: Request, env: Env, ctx: ExecutionContext) { let cache = new Cache.KVStore(env.CACHE_KV, ctx.waitUntil.bind(ctx)); // Use the cache in your request handling let data = await cache.fetch("user:123", async () => { let user = await fetchUserFromDatabase("123"); return JSON.stringify(user); }); return new Response(data, { headers: { "Content-Type": "application/json" }, }); }, };

Notice the ctx.waitUntil.bind(ctx) call. This ensures the waitUntil method retains its context when called inside the store.

Use the Fetch Pattern for Cache-Aside

The fetch method implements the cache-aside pattern: read from cache first, and if the value doesn't exist, compute it and write to cache. The write happens in the background thanks to waitUntil:

app/routes/user.ts
export async function loader({ context }: Route.LoaderArgs) { let cache = context.cache; let userData = await cache.fetch( `user:${userId}`, async () => { // This only runs on cache miss let user = await db.query.users.findFirst({ where: eq(users.id, userId), }); return JSON.stringify(user); }, { ttl: 3600 }, // Cache for 1 hour ); return { user: JSON.parse(userData) }; }

On a cache hit, the response is immediate. On a cache miss, the database query runs, the response is sent with the fresh data, and the cache write happens in the background.

Support Flexible Cache Keys

The store accepts three types of cache keys: plain strings, objects with a cacheKey property, or objects with a cacheKey() method. This flexibility lets you use domain objects directly:

app/models/user.ts
class User { constructor( public id: string, public name: string, ) {} cacheKey(): string { return `user:${this.id}`; } } // Use the object directly as a cache key await cache.write(user, JSON.stringify(user.toJSON()));

The getKey helper in the base Store class handles all three formats, so you can pass whatever makes sense for your use case.

Final Thoughts

Using waitUntil for cache writes is a simple optimization that can shave milliseconds off every response. The pattern works for any fire-and-forget operation: logging, analytics, webhooks, or background cleanup. Just remember that errors in waitUntil promises won't affect the response, so add proper error handling if you need to track failures. For heavier background work, consider using Cloudflare Queues or Workflows instead.