sellIntegration
bolt

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.

100KFree KV reads / day
~0msCached edge read
300sExample TTL
2Stores wired (KV + D1)
insights

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.

local_cafe

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.

schemaCache-aside read flow

Yes (hit)

No (miss)

User request

Worker

Cache hit in KV?

Return value to user

Query D1 database

Put result in KV (TTL 300s)

account_tree

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.

psychology

Worker - the orchestrator

Receives the request, checks KV, decides whether to hit D1, and writes the result back to the cache.

bolt

KV - the fast cache

Holds a copy of recent answers near every user. Reads are near-instant; entries auto-expire by TTL.

database

D1 - the source of truth

The authoritative SQL database. Slower and pricier per read, but always correct - only queried on a miss.

key

The cache key

A stable name like product:42 that maps one DB row to one KV entry. Same input, same key.

swap_vert

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.

schemaCache MISS vs cache HIT round trip
D1"KV cache"WorkerUserD1"KV cache"WorkerUserFirst request - cache MISSNext request - cache HITGET /api/product/42get product:42empty, missSELECT product 42rowput product:42 with TTL 300JSON, slow pathGET /api/product/42get product:42cached JSONJSON, fast path
trending_down

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.

construction

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.

  1. Create the KV namespace and D1 database

    Each command prints an id - paste them into wrangler.jsonc in the next step.

    bash
    npx wrangler kv namespace create CACHE
    npx wrangler d1 create shop
  2. Bind both to your Worker

    These bindings make the stores available as env.CACHE (KV) and env.DB (D1) inside your code.

    json
    {
      "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>"
        }
      ]
    }
  3. Create the table and seed a row

    D1 is the source of truth. Run this SQL once to give the cache something to load.

    sql
    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);
  4. 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.

    js
    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 });
      },
    };
  5. 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.

    js
    // 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}`);
    }
  6. Call it from the front end

    The browser just fetches your API. Caching is entirely a server-side concern.

    js
    // 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;
    }
schemaWrite path: update then invalidate

Client write request

Worker

UPDATE row in D1

Delete the KV key

Reply OK to client

Next read misses and refetches fresh data

school

Key concepts

layers

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.

timer

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.

compare_arrows

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.

schedule

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.

delete_sweep

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.

menu_book

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.

tips_and_updates

Pitfalls & tips

warning

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.
info

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.