How toBuild a Cache Abstraction for Cloudflare KV

When building applications on Cloudflare Workers, you often need to cache expensive operations like database queries, API calls, or computed results. Cloudflare KV provides a globally distributed key-value store that's perfect for server-side caching, but working directly with the KV API can be repetitive and error-prone.

The challenge is creating a clean abstraction that handles cache key generation, TTL management, and the common cache-aside pattern (check cache first, compute if missing, then store). Let's build a Cache.KVStore class that wraps Cloudflare KV with a type-safe, ergonomic API.

Define the Cache Key Type

lib/cache.ts
export namespace Cache { type CacheKey = string | { cacheKey: string } | { cacheKey(): string }; }

This union type allows three ways to specify cache keys: a plain string, an object with a cacheKey property, or an object with a cacheKey() method. This flexibility lets you pass entities directly as cache keys if they implement the interface.

Create the Abstract Store Class

lib/cache.ts
export namespace Cache { 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(); } } }

The abstract Store class defines the contract for any cache implementation. The getKey method handles all three cache key formats, normalizing them to a string. This abstraction allows you to swap storage backends (KV, R2, or in-memory) without changing your application code. You can generate the Cloudflare environment types to get proper typing for KVNamespace and other bindings.

Implement the KV Store

lib/cache.ts
export namespace Cache { 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; } } }

The KVStore class takes a KVNamespace binding and a waitUntil function. The write method uses waitUntil to perform the KV write operation after the response is sent, avoiding blocking the request. This is a common pattern in Cloudflare Workers to improve response times while ensuring writes complete.

Add the Cache-Aside Pattern with Fetch

lib/cache.ts
export namespace Cache { export class KVStore extends Store { // ... previous methods 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; } } }

The fetch method implements the cache-aside pattern: it first checks if the value exists in cache, returns it if found, otherwise calls the provided function to compute the value, stores it in cache, and returns it. This single method handles the most common caching scenario.

Use the Cache in Your Application

app/services/cache.server.ts
import { Cache } from "~/lib/cache"; export function createCache(kv: KVNamespace, waitUntil: ExecutionContext["waitUntil"]) { return new Cache.KVStore(kv, waitUntil); }

Create a factory function that instantiates the cache with your KV binding and the execution context's waitUntil method.

app/routes/users.$id.tsx
import type { Route } from "./+types/users.$id"; import { createCache } from "~/services/cache.server"; export async function loader({ params, context }: Route.LoaderArgs) { let cache = createCache(context.cloudflare.env.CACHE, context.cloudflare.ctx.waitUntil); let user = await cache.fetch( `user:${params.id}`, async () => { let data = await db.query.users.findFirst({ where: eq(users.id, params.id), }); return JSON.stringify(data); }, { ttl: 3600 }, ); return { user: JSON.parse(user) }; }

In your route loader, create the cache instance and use fetch to get the user data. The first request computes and caches the result for one hour. Subsequent requests return the cached value instantly.

Use Objects as Cache Keys

app/models/user.ts
export class User { constructor( public id: string, public name: string, ) {} get cacheKey(): string { return `user:${this.id}`; } }

By implementing the cacheKey property, you can pass User instances directly to cache methods:

app/routes/profile.tsx
let user = new User("123", "John"); let cached = await cache.read(user); // Uses "user:123" as the key

This pattern keeps cache key logic close to your domain models and ensures consistency across your application.

When to Use This Pattern

This cache abstraction works well for read-heavy workloads where data doesn't change frequently. The waitUntil pattern ensures writes don't block responses, but keep in mind that KV is eventually consistent, so recently written values may not be immediately available in all regions. For strongly consistent caching needs, consider using Durable Objects instead. For a broader view of caching approaches, see HTTP vs Server-Side Cache.