LSN 07/10 ▸KV·R2·D1 12 MIN TIER FREE
Storage: KV, R2, D1, and Durable Objects
Picking the right storage primitive for an edge app: key-value, object storage, relational, or single-tenant stateful.
Before this: 06 Workers: code at the edge
A Worker by itself is stateless. Anything you want to persist between requests goes into one of Cloudflare’s storage products. Each is tuned for a different shape of data; picking the wrong one is the most common architectural mistake on this stack.
Workers KV
A globally distributed key-value store. Reads from the nearest edge in single-digit milliseconds. Writes propagate eventually, with a typical upper bound of about 60 seconds.
await env.CACHE.put("user:42", JSON.stringify({ name: "Anya" }));
const raw = await env.CACHE.get("user:42");
const user = raw ? JSON.parse(raw) : null;
await env.CACHE.delete("user:42");
Values can be strings, ArrayBuffers, or streams, up to 25 MiB each. Keys up to 512 bytes. You can attach metadata (a small JSON object) and a TTL.
The mental model: a content-distribution network for arbitrary key-value data. Reads are cheap and ridiculously fast. Writes are slow and you should never depend on a write being visible immediately on the other side of the world.
Good fits for KV:
- Site configuration and feature flags read on every request.
- User session lookups (with a fallback to a more authoritative store on miss).
- Cached lookup results (geo data, currency rates, anything ~daily).
- A/B test bucketing tables.
Bad fits for KV: anything where two near-simultaneous writes from different edges could race. The last write wins, but “last” is fuzzy.
Pricing: 100,000 reads per day free, 1,000 writes per day free, 1 GB stored free. Paid Workers raises these substantially.
R2
S3-compatible object storage with no egress fees. This is the headline feature: AWS charges around $0.09 per GB of egress; R2 charges nothing. For any workload that serves a lot of bytes, this is a real cost difference.
Compatibility means existing S3 SDKs, CLI tools, and clients work against R2 if you point them at the R2 endpoint and use R2 credentials. The Worker binding is more direct:
await env.ASSETS.put("uploads/avatar-42.jpg", request.body, {
httpMetadata: { contentType: "image/jpeg" },
});
const object = await env.ASSETS.get("uploads/avatar-42.jpg");
if (object) {
return new Response(object.body, { headers: { "content-type": "image/jpeg" } });
}
Good fits for R2:
- Static assets you do not want to bundle into a Pages deploy.
- User uploads (images, video, documents).
- Backups, archives, log dumps.
- Datasets and ML training data.
- Anything you would normally put in S3.
Bad fits: small frequently-updated key-value data (use KV) or relational queries (use D1).
Pricing: 10 GB stored free, 1 million Class A (write) and 10 million Class B (read) operations per month free. Storage beyond that is $0.015 per GB-month. Egress is always free.
D1
SQLite at the edge. You get a real relational database with SQL, transactions, indices, foreign keys, the lot. Writes go to a primary region; reads can be served from replicas that lag the primary by seconds.
const { results } = await env.DB.prepare(
"SELECT id, name FROM users WHERE org_id = ? LIMIT 50"
).bind(orgId).all();
await env.DB.prepare(
"INSERT INTO events (user_id, kind, at) VALUES (?, ?, ?)"
).bind(userId, "click", Date.now()).run();
Schema lives in migration files; wrangler d1 migrations apply runs
them. Bindings reference the database by ID in wrangler.toml.
Good fits for D1:
- App data with relationships: users, orgs, posts, comments.
- Anything you would put in Postgres at a small to medium scale.
- Edge-served read-heavy workloads where the data fits in one DB.
Limits to know:
- 10 GB per database, 50,000 databases per account. You can shard across databases manually if you outgrow one.
- Writes are not as cheap as KV. Bulk operations should use transactions.
- D1 is read-replicated but not multi-master. Hot-write workloads bottleneck on the primary region.
Pricing: 5 GB storage free across all D1 DBs, 5 million row reads and 100,000 row writes per day free.
Durable Objects
The odd one. A Durable Object is a single-tenant stateful actor: one instance, identified by a name or ID, that handles all requests for that name with strong consistency. Inside the object you can use a small transactional storage API.
export class Counter {
state: DurableObjectState;
count = 0;
constructor(state: DurableObjectState) {
this.state = state;
state.blockConcurrencyWhile(async () => {
this.count = (await state.storage.get<number>("count")) ?? 0;
});
}
async fetch(req: Request): Promise<Response> {
this.count += 1;
await this.state.storage.put("count", this.count);
return new Response(String(this.count));
}
}
From a Worker:
const id = env.COUNTER.idFromName("global");
const stub = env.COUNTER.get(id);
const res = await stub.fetch(new Request("https://internal/"));
Good fits for Durable Objects:
- Multiplayer game rooms, chat rooms, collaborative editing sessions.
- Per-user rate limiters that need exact counting.
- Leaderboards or counters that need to be authoritative.
- Coordination between many Workers (job queues, lock services).
Bad fits: massive uniform workloads. The single-instance constraint means one hot object cannot scale. Shard by some key.
A decision tree
When choosing where to put data, work through:
- Is it a file, blob, or large binary? R2.
- Is it relational, with joins or transactions across rows? D1.
- Is it a config or read-heavy lookup that tolerates ~60s staleness? KV.
- Is it state for a specific entity (room, session, user) that needs strong consistency and coordination? Durable Object.
- Is it really just memory inside one request? Plain Worker variables are fine, but they vanish at the end of the request.
You can mix and match. A typical app might keep configs in KV, user data in D1, uploads in R2, and live game state in Durable Objects, all from the same Worker.
The next two lessons cover the parts of Cloudflare that are about who can do what: Access for human authentication, and API tokens for machine-to-machine.