Add a KV cache in front of your database
Check KV first; only touch the database on a miss, then cache the answer with a TTL.
What are we building?
We will put a cache in front of a database. The Worker checks a fast key-value store (Workers KV) first; only when the value is missing does it query the slower database (D1), and it saves that answer back into KV for next time. This pattern is called cache-aside.
For read-heavy data - the same product page, the same config, the same profile fetched thousands of times - this is a huge win: most requests never reach the database, so they are faster and cheaper.
Think of it like…
A barista keeps a jug of brewed coffee on the counter (KV). If the jug has coffee, you get a cup instantly. Only when it runs dry do they brew a fresh pot from the machine (D1) - then refill the jug so the next ten customers are served instantly again.
Who does what?
Three pieces work together. The Worker is the brain that decides; KV is the fast cache; D1 is the source of truth that always holds the correct, latest data.
Worker - the orchestrator
Receives the request, checks KV, decides whether to hit D1, and writes the result back to the cache.
KV - the fast cache
Holds a copy of recent answers near every user. Reads are near-instant; entries auto-expire by TTL.
D1 - the source of truth
The authoritative SQL database. Slower and pricier per read, but always correct - only queried on a miss.
The cache key
A stable name like product:42 that maps one DB row to one KV entry. Same input, same key.
Hit vs miss, side by side
The first request for a key is a MISS: the Worker has to walk all the way to D1 and back, then fill the cache. Every following request (until the TTL expires) is a HIT: it returns from KV without ever touching D1.
Why this saves so much
If 95% of requests are hits, you cut database reads by roughly 20x. Fewer D1 reads means lower bills and lower latency for almost everyone.
Build it: KV + D1 + Worker
Create the two stores, bind them, seed a row, then write the cache-aside logic. The front end just calls your API - it never knows a cache exists.
Create the KV namespace and D1 database
Each command prints an id - paste them into wrangler.jsonc in the next step.
npx wrangler kv namespace create CACHE npx wrangler d1 create shopBind both to your Worker
These bindings make the stores available as env.CACHE (KV) and env.DB (D1) inside your code.
{ "name": "cache-aside-api", "main": "src/index.js", "compatibility_date": "2025-01-01", "kv_namespaces": [ { "binding": "CACHE", "id": "<your-kv-namespace-id>" } ], "d1_databases": [ { "binding": "DB", "database_name": "shop", "database_id": "<your-d1-database-id>" } ] }Create the table and seed a row
D1 is the source of truth. Run this SQL once to give the cache something to load.
CREATE TABLE IF NOT EXISTS products ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, price REAL NOT NULL ); INSERT INTO products (id, name, price) VALUES (42, 'Mechanical Keyboard', 89.00);Write the cache-aside read
Ask KV first with get(key, "json"). On a hit, return immediately. On a miss, query D1, then put the result back into KV with a 300-second TTL.
export default { async fetch(request, env) { const url = new URL(request.url); const id = url.pathname.split("/").pop(); const key = `product:${id}`; // 1) Cache-aside: ask KV first let product = await env.CACHE.get(key, "json"); if (product) { // HIT: return straight away, no database needed return Response.json({ source: "cache", product }); } // 2) MISS: fall back to D1 (the source of truth) const { results } = await env.DB .prepare("SELECT * FROM products WHERE id = ?") .bind(id) .all(); product = results[0] ?? null; if (!product) { return new Response("Not found", { status: 404 }); } // 3) Re-fill the cache with a 5-minute TTL, then return await env.CACHE.put(key, JSON.stringify(product), { expirationTtl: 300, }); return Response.json({ source: "database", product }); }, };Invalidate on write
When data changes, update D1, then delete(key). The cached copy is now gone, so the next read misses and repopulates with fresh data.
// On any write, update D1 first, then invalidate the cached copy. async function updateProduct(env, id, data) { // 1) Write to the source of truth await env.DB .prepare("UPDATE products SET name = ?, price = ? WHERE id = ?") .bind(data.name, data.price, id) .run(); // 2) Invalidate: delete the stale key so the next read repopulates it await env.CACHE.delete(`product:${id}`); }Call it from the front end
The browser just fetches your API. Caching is entirely a server-side concern.
// Plain browser fetch - it never talks to KV or D1 directly. async function loadProduct(id) { const res = await fetch(`/api/product/${id}`); const data = await res.json(); // data.source tells you whether it came from "cache" or "database" console.log("served from:", data.source); return data.product; }
Key concepts
Cache-aside (lazy loading)
The app code, not the cache, manages loading: read cache, on miss read the DB, then fill the cache. The cache stays out of the way until asked.
TTL (time to live)
expirationTtl: 300 means the entry auto-deletes after 300 seconds. A short TTL keeps data fresher; a long TTL saves more DB reads. It is the dial you tune.
Hit vs miss
A hit = the value was in KV (fast, no DB). A miss = it was not, so you pay the full DB round trip once and then cache it. Hit rate is the metric to watch.
Eventual consistency
KV writes take up to ~60s to spread worldwide, and a cached value can be stale until its TTL expires. Readers see the new value eventually, not instantly.
Invalidation
Deleting (or overwriting) the key on every write keeps the cache honest. The classic hard problem is remembering every place a piece of data is cached.
Source of truth
D1 is authoritative; KV is a disposable copy. If KV and D1 disagree, D1 wins - so it is always safe to delete a KV entry and rebuild it.
Pitfalls & tips
Stale reads are the price of caching
Between a write and a cache delete (or before the TTL expires) some users may see old data. For anything that must be exactly correct everywhere - prices at checkout, stock counts, balances - use a short TTL, invalidate on write, or read D1 directly.
- Pick TTL by how stale you can tolerate: seconds for prices, minutes for product pages, hours for rarely-changing config.
- Always invalidate on write (delete the key), or readers can stay stale for the full TTL.
- Only cache what is actually read often - caching one-off lookups just wastes writes.
- Never cache per-user secrets under a shared key; include the user id in the key, e.g. cart:{userId}.
- Watch your hit rate. A low hit rate means the TTL is too short or the key is too specific to be reused.
- A 'miss storm' (many misses at once on a cold key) can briefly hammer D1 - keep DB queries cheap and indexed.
KV is built for this
Workers KV is read-optimized and globally replicated, which is exactly what a cache wants. Its weakness - slow-to-propagate writes - barely matters here, because a cache is allowed to be slightly behind.
Related products
menu_bookOfficial docsopen_in_new