← Back to all articles
// Systems · Edge · Serverless
Cloudflare Workers Durable Objects KV D1 R2

Cloudflare Edge Architecture: Workers, Durable Objects, and Binding Topology

Cloudflare's developer platform runs at 330+ locations worldwide. Every request lands at the nearest point of presence and executes in a V8 isolate with a sub-5ms cold start. Understanding how Workers, Durable Objects, and the binding graph connect is the difference between using the edge correctly and fighting it.

Barnabas Waweru  ·  August 20, 2026  ·  14 min read
ansi · wordmark · cloudflare edge
 ██████╗██╗      ██████╗ ██╗   ██╗██████╗ ███████╗██╗      █████╗ ██████╗ ███████╗
██╔════╝██║     ██╔═══██╗██║   ██║██╔══██╗██╔════╝██║     ██╔══██╗██╔══██╗██╔════╝
██║     ██║     ██║   ██║██║   ██║██║  ██║█████╗  ██║     ███████║██████╔╝█████╗  
██║     ██║     ██║   ██║██║   ██║██║  ██║██╔══╝  ██║     ██╔══██║██╔══██╗██╔══╝  
╚██████╗███████╗╚██████╔╝╚██████╔╝██████╔╝██║     ███████╗██║  ██║██║  ██║███████╗
 ╚═════╝╚══════╝ ╚═════╝  ╚═════╝ ╚═════╝ ╚═╝     ╚══════╝╚═╝  ╚═╝╚═╝  ╚═╝╚══════╝

███████╗██████╗  ██████╗ ███████╗
██╔════╝██╔══██╗██╔════╝ ██╔════╝
█████╗  ██║  ██║██║  ███╗█████╗  
██╔══╝  ██║  ██║██║   ██║██╔══╝  
███████╗██████╔╝╚██████╔╝███████╗
╚══════╝╚═════╝  ╚═════╝ ╚══════╝

Isolation Model

A Worker is not a container, not a VM, and not a Node.js process. It is a V8 isolate: a lightweight JavaScript context that shares a process with thousands of other Workers but is completely memory-isolated from them. This is why cold starts are under 5ms. There is no OS-level boot, no container image pull, no network namespace setup. The runtime is already running.

Client Request
  │
  │  DNS resolves to Cloudflare Anycast IP
  │  Packet routes to nearest PoP (330+ locations worldwide)
  │
  ▼
Cloudflare Edge Node (PoP)
  │
  ├─ HTTP/2 or HTTP/3 termination
  ├─ TLS termination (ECDSA P-256, TLS 1.3)
  ├─ Zone routing: which Worker handles this domain
  │
  ▼
Worker Runtime (V8 isolate per Worker)
  │
  ├─ Isolate cold start: typically 0-5ms (first request in PoP)
  ├─ Isolate warm: under 1ms dispatch on subsequent requests
  ├─ CPU time limit: 10ms (Free) · 30s default (Standard) · 5 min max
  ├─ Memory: 128MB per isolate
  ├─ No shared memory across isolates
  │
  ├─ env object injected at request time (all declared bindings)
  │    env.MY_KV       → KV namespace
  │    env.MY_BUCKET   → R2 bucket
  │    env.MY_DB       → D1 database
  │    env.MY_DO       → Durable Object namespace
  │    env.AI          → Workers AI
  │    env.MY_SECRET   → Secret string
  │
  ▼
fetch handler / scheduled handler / queue handler
  │
  └─ Returns Response (must be serializable)
// Isolates, Not Containers

Docker containers share the host kernel but isolate processes. V8 isolates share the host process but isolate JavaScript heaps. The tradeoff: cold starts measured in microseconds rather than seconds, but no file system access, no arbitrary network sockets, and no long-running background threads that survive past the request. The Workers runtime is Web Standards-first: Fetch API, Web Crypto, URL, ReadableStream. Node.js APIs require nodejs_compat in wrangler config, which adds a small overhead on first use.

The compatibility_date Field

Cloudflare evolves the Workers runtime continuously. Breaking changes are gated behind a compatibility date. Your wrangler.toml pins a date, and Cloudflare only applies changes introduced after that date when you explicitly bump it. A Worker deployed in 2024 still runs on 2024 semantics today.

Bump compatibility_date deliberately before deploying to production. Run wrangler dev first to catch behavior changes. Never leave it as a placeholder; the default is whatever date the CLI used at project creation time.

wrangler.toml (minimal, correct)
name = "my-worker" main = "src/index.ts" compatibility_date = "2026-08-01" # Enable Node.js APIs if a dependency requires them # compatibility_flags = ["nodejs_compat"] [[r2_buckets]] binding = "ASSETS" bucket_name = "my-assets-bucket" [[kv_namespaces]] binding = "CACHE" id = "abc123..." [[d1_databases]] binding = "DB" database_name = "my-db" database_id = "def456..."
CPU Time, Not Wall Time
Workers bill CPU milliseconds. A Worker that awaits a fetch call for 500ms but uses 2ms of CPU costs 2ms. I/O wait is free. This makes Workers cheap for network-heavy tasks and expensive for compute-heavy ones.
No Persistent State
Each invocation starts clean. In-memory variables reset between requests unless using Durable Objects or a binding. Global scope can persist within a single isolate lifetime, but do not rely on it across requests.
Multiple Handlers
A single Worker exports fetch (HTTP), scheduled (cron triggers), queue (consumers), and email handlers. One deployment, multiple entry points, billed separately per handler type.
Subrequests
Workers call external URLs, other Workers via service bindings, or Cloudflare services. Sub-request I/O wait is free. Only the CPU time spent locally counts against the billing unit.

Binding Topology

A binding is a named capability injected into env at request time. It is not a secret string, not an SDK import, and not an HTTP client. It is a direct connection to a Cloudflare service that bypasses the public internet entirely. Bindings give Workers performance advantages and tight permission boundaries that REST API calls cannot match.

How Bindings Work

You declare bindings in wrangler.toml (or wrangler.json). At deploy time, Cloudflare resolves them to the specific resources in your account. At runtime, they arrive as typed objects on env. The Worker never sees an API key or a base URL. The permission is the binding itself.

This matters for security: a Worker with an R2 binding scoped to one bucket can only access that bucket. There is no credential to leak and reuse elsewhere. Rotate, remove, or scope bindings in the dashboard or wrangler config without touching application code.

Worker (V8 isolate)
  │
  │  env object (injected per request)
  │
  ├─ env.KV          → Workers KV namespace
  │    Read: globally replicated, fast from any PoP
  │    Write: eventually consistent (~60s global propagation)
  │    Values up to 25MB; 1000 ops/s per namespace
  │
  ├─ env.DB          → D1 SQL database (SQLite-compatible)
  │    Primary: one region; read replicas at edge PoPs
  │    Queries: env.DB.prepare("SELECT ...").bind().all()
  │    Strong consistency on primary; eventual on replicas
  │
  ├─ env.BUCKET      → R2 object storage
  │    S3-compatible API; zero egress fees
  │    env.BUCKET.get(key) · put(key, body) · delete(key)
  │    Class A ops (writes): $4.50/million
  │    Class B ops (reads): $0.36/million
  │
  ├─ env.DO          → Durable Object namespace
  │    env.DO.get(id) → stub → stub.fetch(request)
  │    Routes to a single globally-unique instance
  │    Single-writer; strongly consistent; SQLite storage (GA)
  │
  ├─ env.AI          → Workers AI (serverless GPU inference)
  │    env.AI.run("@cf/meta/llama-3.1-8b-instruct", { messages })
  │    No GPU provisioning; billed per inference unit
  │
  ├─ env.QUEUE       → Cloudflare Queue (producer side)
  │    env.QUEUE.send({ payload })
  │    env.QUEUE.sendBatch([...])
  │    Guaranteed delivery; no egress charges
  │
  ├─ env.HYPERDRIVE  → TCP tunnel to external Postgres/MySQL
  │    Connection pooling + query caching at edge
  │    Use connectionString with pg or postgres.js
  │
  └─ env.SERVICE     → Service binding to another Worker
       await env.SERVICE.fetch(new Request("https://internal/path"))
       Internal network only; no public internet hop
// Bindings vs Environment Variables vs Secrets

Environment variables (plain strings via vars in wrangler.toml) are for config: feature flags, public API URLs, region names. Secrets (via wrangler secret put) are for sensitive strings: API keys passed to third-party services. Bindings are for first-class Cloudflare resources. These three categories serve different purposes. Do not put an R2 API token in a secret when a binding does the same job with less attack surface and better performance.

Service Bindings: Worker-to-Worker

Service bindings let one Worker call another without going through the public internet or any HTTP overhead. The call travels through Cloudflare's internal network. It counts against the caller's CPU budget, not the callee's request quota. This is how you build a microservice graph at the edge: one entry-point Worker routes to specialized Workers for auth, data transformation, or background processing.

Worker calling another Worker via service binding
// wrangler.toml: [[services]] binding = "AUTH_WORKER" service = "my-auth-worker" export default { async fetch(request: Request, env: Env): Promise<Response> { // Call the auth Worker internally — no public endpoint, no TCP overhead const authResp = await env.AUTH_WORKER.fetch( new Request('https://auth/verify', { method: 'POST', body: JSON.stringify({ token: request.headers.get('Authorization') }), headers: { 'Content-Type': 'application/json' }, }) ) if (!authResp.ok) return new Response('Unauthorized', { status: 401 }) const { userId } = await authResp.json() const result = await env.DB.prepare( 'SELECT * FROM items WHERE user_id = ?1' ).bind(userId).all() return Response.json(result.results) }, }

Durable Objects

Workers are stateless by design. Durable Objects add state. Each Durable Object instance has a globally unique name, a single-threaded execution context, and durable storage that lives alongside it. When two clients need to coordinate (real-time collaboration, rate limiting, game state, chat rooms), they both route to the same named Durable Object instance.

The Uniqueness Guarantee

A Durable Object ID maps to exactly one instance, anywhere in the world. If a user in Nairobi and a user in New York both connect to room:abc123, both connections route to the same Durable Object. That object runs in one specific data center (the one closest to where it was first requested). All state mutations go through a single thread. No locks needed. No distributed consensus. The object is the coordination primitive.

Two clients connecting to the same room

Client A (Nairobi)                    Client B (New York)
    │                                     │
    │ WebSocket connect                   │ WebSocket connect
    │ to Workers edge (Nairobi PoP)       │ to Workers edge (New York PoP)
    │                                     │
    ▼                                     ▼
Entry Worker (Nairobi)            Entry Worker (New York)
    │                                     │
    │ id = env.ROOM.idFromName("abc123") ←│ same name → same DO ID
    │ stub = env.ROOM.get(id)             │
    │ return stub.fetch(upgradeReq)       │
    │                                     │
    └──────────────┬──────────────────────┘
                   │
                   ▼
          Durable Object: room:abc123
          (one instance, one data center, globally accessible)
          ┌──────────────────────────────────────────┐
          │ class Room extends DurableObject         │
          │                                          │
          │ ctx.storage (SQLite, now GA)             │
          │   → messages, user list, room state      │
          │                                          │
          │ this.sessions: Map<string, WebSocket>    │
          │   → in-memory live connections           │
          │                                          │
          │ async fetch(req):                        │
          │   if Upgrade: accept WebSocket           │
          │   on message: broadcast to all sessions  │
          │   on alarm: flush or expire state        │
          └──────────────────────────────────────────┘
SQLite Storage (GA)
SQLite-backed Durable Objects are now generally available (moved from beta in 2026). Use ctx.storage.sql.exec("SELECT ...") for structured queries. The database lives with the object instance. New DO classes should use SQLite storage by default.
WebSocket Hibernation
Durable Objects hold WebSocket connections open without burning CPU. During hibernation, the object's memory is freed. Cloudflare wakes it when a message arrives. Supports thousands of concurrent connections per object at near-zero cost when idle.
Alarms API
Schedule work from inside a Durable Object: ctx.storage.setAlarm(Date.now() + 60_000). The runtime calls alarm() at the scheduled time. Use for TTL expiry, batch flushes, session timeouts, or periodic cleanup without external cron infrastructure.
Free Plan (2026)
SQLite-backed Durable Objects are now available on the Workers Free plan with defined limits. The Paid plan (Standard, $5/month) removes those limits and adds higher storage quotas. Check current pricing for exact DO free-tier caps.

Naming Strategies

Three ways to get a Durable Object ID, with different properties:

  • idFromName(name): Deterministic. "room:abc" always maps to the same object. Good for user-visible resource routing. The name is the coordination key.
  • newUniqueId(): Generates a random ID with optional location hint. Good for ephemeral sessions where you don't need to address the object by a predictable key.
  • idFromString(serialized): Reconstruct an ID from a previously serialized string. Use when storing the ID in D1 or KV and retrieving it later to reconnect to the same instance.
Minimal Durable Object with SQLite storage (TypeScript)
import { DurableObject } from 'cloudflare:workers' export class Counter extends DurableObject { constructor(ctx: DurableObjectState, env: Env) { super(ctx, env) ctx.storage.sql.exec(` CREATE TABLE IF NOT EXISTS counts ( key TEXT PRIMARY KEY, value INTEGER DEFAULT 0 ) `) } async increment(key: string): Promise<number> { const result = this.ctx.storage.sql .exec( `INSERT INTO counts (key, value) VALUES (?1, 1) ON CONFLICT(key) DO UPDATE SET value = value + 1 RETURNING value`, key ) .one() return result.value as number } async fetch(request: Request): Promise<Response> { const url = new URL(request.url) const key = url.pathname.slice(1) const count = await this.increment(key) return Response.json({ key, count }) } } // Entry Worker export default { async fetch(request: Request, env: Env): Promise<Response> { const id = env.COUNTER.idFromName('global') const stub = env.COUNTER.get(id) return stub.fetch(request) } }
// When NOT to Use Durable Objects

Durable Objects are not a general-purpose database. Each instance is single-threaded and pinned to one data center. High-throughput reads that don't require coordination belong in KV or D1 read replicas. DO is the right choice for single-writer semantics: rate limiting per user, WebSocket fan-out, presence tracking, or any resource where two concurrent writers would cause a conflict. If you only need read-heavy key-value access from many clients simultaneously, reach for KV first.

Storage Tradeoffs

Four storage options live in the Cloudflare platform. They are not interchangeable. Each has a different consistency model, latency profile, and pricing structure. Picking the wrong one means stale data, unexpected costs, or serialization bottlenecks under load.

Storage Model Consistency Best For Key Limit
KV Key-value Eventual (~60s global) Config, feature flags, public content, session tokens 25MB per value; 1000 ops/s
D1 SQLite (SQL) Strong on primary; eventual on read replicas Relational data, user records, structured queries 10GB per database
R2 Object storage Strongly consistent per key Files, images, videos, large blobs; zero egress 5TB per object; unlimited total
DO Storage SQLite per instance Strongly consistent, single-writer Coordination, WebSocket state, rate limiting, presence Per-instance storage limits per plan

KV: Read Heavy, Eventual Writes

Workers KV replicates values to every Cloudflare data center. Reads are served locally, which makes them fast anywhere in the world. Writes propagate asynchronously: after a put(), the new value may not be visible at other data centers for up to 60 seconds. This makes KV excellent for config, A/B flags, and public content that changes infrequently. It is the wrong choice for anything that needs immediate read-after-write consistency across regions.

D1: SQL at the Edge With Read Replicas

D1 is SQLite. Your schema, queries, and migrations are standard SQLite syntax. The primary database lives in one Cloudflare region. Read replicas are placed at PoPs near your users. Reads against a replica may return data slightly behind the primary (milliseconds to low seconds). Writes always go to the primary.

For most web apps this is acceptable. Need to read your own write? Use D1's first_uncached consistency hint to force the primary. For real-time coordination where two writers must not conflict, D1's central primary write path will become a bottleneck. That is where Durable Objects take over.

R2: Object Storage Without Egress Tax

R2 is S3-compatible object storage. The key difference from AWS S3: zero egress fees. You pay for storage ($0.015/GB/month) and operations (Class A writes at $4.50/million, Class B reads at $0.36/million), but serving data to browsers from R2 or via a Worker costs nothing extra per byte. For media-heavy applications, the egress savings alone justify the choice over S3. Pair R2 with a custom domain on Cloudflare for a CDN-backed file server with one binding declaration.

// Hyperdrive for External Databases

If your primary database is Postgres or MySQL outside Cloudflare (Neon, Supabase, PlanetScale, RDS), use Hyperdrive. It maintains a connection pool between the Cloudflare network and your database, caches read query results at the edge, and provides a connection string that standard pg and postgres.js libraries accept. Without Hyperdrive, every Worker request that hits an external database opens a new TCP connection from the nearest PoP. That is 40-100ms of overhead per request before a single query runs. Hyperdrive collapses that to near-zero for the connection handshake.

Smart Placement

By default, a Worker runs at the data center closest to the incoming request. This is right for logic that executes entirely at the edge: auth checks, response transforms, caching, A/B routing. It is wrong for Workers that make multiple calls to a single-region backend. If a user in Tokyo hits an edge node 10ms away, but the Worker makes three Postgres queries to a database in Virginia (160ms each way), the total latency is dominated by those round trips, not the client's proximity to Cloudflare.

How Smart Placement Works

Enable Smart Placement in wrangler.toml with placement = { mode = "smart" }. Cloudflare analyzes your Worker's subrequest patterns and picks the data center with the lowest total latency to your backend services. The client still gets a fast initial TCP connection at the nearest PoP. The actual Worker execution routes to where it makes sense for your data topology.

wrangler.toml with Smart Placement and Hyperdrive
name = "my-api-worker" main = "src/index.ts" compatibility_date = "2026-08-01" # Run near the database, not near the client [placement] mode = "smart" [[hyperdrive]] binding = "DB" id = "your-hyperdrive-config-id"
01
Without Smart Placement

Worker runs in Tokyo PoP. Makes 3 DB calls to Virginia. Client-to-edge: 10ms. Each DB round trip: 320ms. Total: 970ms minimum, before database time.

02
With Smart Placement

Worker routes execution to a Virginia PoP. Client-to-initial-edge: 160ms. Worker-to-DB: 5ms each call x 3 = 15ms. Total: under 200ms. The client's initial connection goes to Tokyo; execution happens in Virginia.

03
The Real Win

Smart Placement saves the most when a Worker makes 3 or more subrequests to a regional backend. Each round trip multiplies. Moving execution next to the database turns 3 x 320ms into 3 x 5ms. 960ms of overhead becomes 15ms.

Cost Reality Check

Cloudflare Workers pricing is request-based with no egress charges. The Free plan covers most hobby and side-project workloads. The Standard plan (paid, starts at $5/month) unlocks Durable Objects and higher limits. Here is what the numbers actually mean for a production app.

Free Plan
100,000 requests/day. 10ms CPU per invocation. No Durable Objects. KV: 100k reads/day, 1k writes/day. Enough for development and low-traffic production.
Standard ($5/mo minimum)
10 million requests/month included. $0.30 per additional million. 30 million CPU ms included, $0.02 per additional million CPU ms. Max 5 min CPU per invocation (default 30s). Durable Objects included.
CPU ms is the billing unit
A Worker spending 3ms of CPU per request: 10M requests = 30M CPU ms = exactly the Standard plan allotment. A Worker spending 50ms: those same 10M requests cost $0.02 x (500M - 30M) / 1M = $9.40 in CPU overage.
Zero egress
Serving from R2, caching responses, or returning large payloads from Workers has no egress fee. AWS and GCP charge $0.09/GB or more for outbound data. On 1TB/month of data served, that is $90 saved.

Durable Objects Pricing

Durable Objects add their own cost on top of the $5 base. The billing has three components: requests to the DO stub ($0.15/million), CPU time inside the DO handler (same rate as Workers), and storage ($0.20/GB-month for key-value; SQLite-backed DO storage is charged differently, check current pricing).

A chat app with 100,000 daily active users making 10 DO requests each: 1 million DO requests/day = 30 million/month = $4.50/month in stub request fees. Add CPU time and storage. For a real-time product, plan for $20-50/month per 100k DAU at Durable Objects, before application-level optimizations like batching and connection reuse.

KV and D1 Billing

KV on Standard: 10 million reads/month included, 1 million writes/month included. Reads over the limit: $0.50/million. Writes over: $5/million. KV writes are 10x more expensive per unit than reads. Cache aggressively: write once and serve many times from the global replica.

D1 on Standard: 5 million reads/day and 100,000 writes/day included. Storage: $0.75/GB-month after the first 5GB. D1 is cheap for most applications. The real constraint is the 10GB per-database limit, not the cost.

Key Takeaways

  1. Workers are V8 isolates, not containers. Cold starts under 5ms are real. The tradeoff is no filesystem, no arbitrary networking, and CPU limits per request. Design for stateless request handlers; push state to bindings.
  2. Bindings are the security model. A Worker can only access what is declared in wrangler.toml. No leaked credentials, no ambient access. Scope each Worker to exactly the resources it needs.
  3. Choose storage by consistency requirement. KV for globally distributed reads with eventual writes. D1 for SQL with tolerable read-replica lag. R2 for blobs without egress cost. Durable Objects for single-writer coordination where two concurrent writers would conflict.
  4. Durable Objects are a coordination primitive, not a database. Use them for chat rooms, presence, rate limiting, and game state. Do not use them as a high-throughput read store for rows that multiple Workers need to query simultaneously.
  5. Smart Placement flips the optimization target. For Workers that call a regional backend multiple times per request, running next to the database beats running next to the client. Enable it if your Worker makes 3 or more subrequests to a single-region service.
  6. CPU ms, not wall clock, is the billing unit. I/O wait is free. Network-heavy Workers (auth checks, proxy calls, metadata fetches) are cheap. Compute-heavy Workers (image processing, LLM token streaming, compression) burn through CPU ms budget fast.
  7. Pin compatibility_date and bump it deliberately. Cloudflare gates runtime changes behind this field. Never leave it as a default placeholder. Test with wrangler dev before bumping in a production deploy.

Official Documentation

Workers platform overview, runtime APIs, framework guides, and pricing. Updated April 2026.
SQLite storage (now GA), WebSocket hibernation, alarms API, and pricing. Updated July 2026.
Full list of available bindings and how to declare them in wrangler config. Updated July 2026.
Free vs Standard plan limits; CPU ms billing; KV, D1, R2, and DO cost breakdown. Updated July 2026.