BuildBot

Compute at the Edge

State at the edge

Lesson 4 of 5

What you'll learn

  • Query D1 — SQLite at the edge — with prepared statements via a binding
  • Understand Durable Objects: one live instance per name, in-memory state plus storage
  • Choose D1 for relational data and Durable Objects for coordination

KV's eventual consistency rules it out the moment two writers can race. The platform's answer is two very different tools.

D1: SQLite, as a binding

D1 is a real SQL database — SQLite — exposed on env like any other binding. The API is built around prepared statements: prepare the SQL with ? placeholders, bind the values (never string-interpolate them), then first(), all(), or run():

export default {
  async fetch(request, env) {
    const id = new URL(request.url).searchParams.get("id");
    const note = await env.DB.prepare("SELECT title, body FROM notes WHERE id = ?")
      .bind(id)
      .first();
    return note ? Response.json(note) : new Response("Not found", { status: 404 });
  },
};

Schema lives in migration files you apply with wrangler d1 migrations apply, and env.DB.batch([...]) runs several statements as one round trip. D1 is the fit for classic relational shapes — users, notes, orders — read from everywhere, written at a sane rate.

Durable Objects: a single point of coordination

Some state isn't relational — it's contended: a counter, a game room, the highest bid. A Durable Object is a class; for any given name, the platform guarantees exactly one live instance in the world, and delivers requests to it one at a time. That single-threaded, single-instance guarantee is the whole feature: no races, because there is nothing to race against. Each instance keeps fast in-memory state for its lifetime, backed by transactional ctx.storage that survives eviction:

import { DurableObject } from "cloudflare:workers";

export class Counter extends DurableObject {
  async increment() {
    let value = (await this.ctx.storage.get("value")) ?? 0;
    value += 1;
    await this.ctx.storage.put("value", value);
    return value;
  }
}

export default {
  async fetch(request, env) {
    const id = env.COUNTER.idFromName("global"); // same name -> same instance, worldwide
    const stub = env.COUNTER.get(id);
    return Response.json({ value: await stub.increment() }); // RPC to the one instance
  },
};

Choosing in one sentence each

D1 when the question is "what does the data look like?" — tables, joins, many readers. Durable Objects when the question is "who is allowed to touch this right now?" — counters, presence, queues, WebSocket rooms. They compose: a DO coordinating hot writes that periodically flushes to D1 is a common shape.

Model a Durable Object: one instance per name

Run it. idFromName models the platform's guarantee — the same name always resolves to the same single instance, so its in-memory count is safely shared by every request that names it. Note room:games has its own instance and its own count.

Loading editor…
Knowledge check

A live auction needs a current-highest-bid that many users update concurrently. Which fits best?

Next: shipping — deploy, routes and custom domains, logs, limits, and rolling out gradually.

Saved on this device. Sign in to sync your progress everywhere.