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.
██████╗██╗ ██████╗ ██╗ ██╗██████╗ ███████╗██╗ █████╗ ██████╗ ███████╗ ██╔════╝██║ ██╔═══██╗██║ ██║██╔══██╗██╔════╝██║ ██╔══██╗██╔══██╗██╔════╝ ██║ ██║ ██║ ██║██║ ██║██║ ██║█████╗ ██║ ███████║██████╔╝█████╗ ██║ ██║ ██║ ██║██║ ██║██║ ██║██╔══╝ ██║ ██╔══██║██╔══██╗██╔══╝ ╚██████╗███████╗╚██████╔╝╚██████╔╝██████╔╝██║ ███████╗██║ ██║██║ ██║███████╗ ╚═════╝╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝ ███████╗██████╗ ██████╗ ███████╗ ██╔════╝██╔══██╗██╔════╝ ██╔════╝ █████╗ ██║ ██║██║ ███╗█████╗ ██╔══╝ ██║ ██║██║ ██║██╔══╝ ███████╗██████╔╝╚██████╔╝███████╗ ╚══════╝╚═════╝ ╚═════╝ ╚══════╝
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)
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.
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.
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.
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
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 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.
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.
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 │
└──────────────────────────────────────────┘
ctx.storage.sql.exec("SELECT ...") for structured queries. The database lives with the object instance. New DO classes should use SQLite storage by default.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.Three ways to get a Durable Object ID, with different properties:
"room:abc" always maps to the same object. Good for user-visible resource routing. The name is the coordination key.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.
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 |
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 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 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.
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.
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.
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.
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.
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.
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.
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.
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 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.
wrangler dev before bumping in a production deploy.