Workers runtime and deployment
Cloudflare Workers run inside workerd — Cloudflare’s open-source JavaScript/WebAssembly runtime built on V8 isolates. Unlike containerized serverless (Lambda, Cloud Run), every Worker invocation runs in a V8 isolate inside the same process at the POP — there’s no container/microVM cold start, just an isolate-creation cost of typically 5-50ms. The runtime is web-standards-first: fetch, Request, Response, URL, Streams, WebCrypto, with optional Node.js compatibility via a nodejs_compat flag. Deployment is via Wrangler — a single command (wrangler deploy) pushes the worker globally to all 330+ POPs within ~30 seconds.
See also
- workers-runtime-apis — the actual APIs available inside the runtime
- workers-bindings-and-storage — how Workers connect to data
- hidden-tricks-and-gotchas-cloudflare-workers — undocumented Wrangler flags + compat tricks
- vercel-edge-network-and-functions — the comparable Vercel edge runtime
1. The V8-isolate model
Per developers.cloudflare.com/workers/. A V8 isolate is a sandboxed JS execution context inside the V8 engine. Cloudflare’s runtime (workerd) holds thousands of isolates per process and reuses them across requests:
- First request to a Worker in a region — isolate is created from the deployed code bundle (cold start, ~5-50ms).
- Subsequent requests — isolate is reused (~0ms isolate cost, just request handling).
- Idle isolates — kept warm for minutes, evicted on memory pressure.
Compared to Lambda’s microVM + Node.js model:
- Cloudflare cold start: 5-50ms typical.
- Lambda cold start: 200ms-2s (Node), 1-3s (Java), 500ms-1s (Python).
- Lambda warm start: ~5-20ms.
- Workers warm start: ~0-3ms (no container, just isolate).
2. Limits
Per developers.cloudflare.com/workers/platform/limits/:
| Limit | Free | Paid ($5/mo+) |
|---|---|---|
| CPU time per invocation | 10 ms | 30 sec (default) — up to 5 min |
| Memory | 128 MB | 128 MB |
| Subrequests per invocation | 50 | 1000 |
| Script size (compressed) | 3 MB | 10 MB |
| Wall-clock time | 30 sec (incl. waiting on I/O) | 15 min (with event.waitUntil) |
| Number of Workers per account | 100 | 500 |
| Environment variables per Worker | 64 | 128 |
| Secrets per Worker | 64 | 128 |
CPU time vs wall-clock: CPU time is when the JS thread is executing. Wall-clock includes time spent awaiting fetch() and other I/O. A Worker doing await fetch(...) for 2s uses ~5ms of CPU and 2s of wall-clock. CPU is what’s billed.
The 10ms CPU on Free is a hard limit — Workers exceeding it return EXCEEDED_CPU and the user sees an error. For real workloads, the Paid plan’s 30-second-default-to-5-minute is what you need.
3. Pricing
Per developers.cloudflare.com/workers/platform/pricing/:
Free plan ($0/mo)
- 100,000 requests / day
- 10 ms CPU per invocation
- 100k KV reads / day, 1k KV writes / day
- 100k Hyperdrive queries / day
- Limited D1, DO (SQLite only), Queues, R2
Paid plan ($5/mo minimum)
- Requests: 10M / month included, then $0.30 / million
- CPU: 30M ms / month included, then $0.02 per additional million ms
- Up to 5-minute CPU per invocation (default 30s)
Service-specific pricing
| Service | Included (Paid) | Overage |
|---|---|---|
| KV reads | 10M / mo | $0.50 / M |
| KV writes / list / delete | 1M / mo | $5 / M |
| D1 rows read | 25B / mo | $0.001 / M |
| D1 rows written | 50M / mo | $1 / M |
| Durable Objects requests | 1M / mo | $0.15 / M |
| DO duration | 400k GB-sec / mo | $12.50 / M |
| R2 storage | First 10 GB free | $0.015 / GB-month |
| R2 Class A ops (writes) | 1M / mo | $4.50 / M |
| R2 Class B ops (reads) | 10M / mo | $0.36 / M |
| R2 egress | $0 (zero egress) | — |
The zero egress on R2 is the killer feature vs S3 — for content-heavy workloads, R2 saves the per-GB-out fee that S3 charges ($0.09/GB for first 10TB).
4. Wrangler CLI
Per developers.cloudflare.com/workers/wrangler/. The CLI for everything Workers-related.
Install
npm i -g wrangler
# Or use locally:
npx wrangler --versionKey commands
# Project lifecycle
wrangler init my-worker # scaffold new Worker
wrangler dev # local dev server (port 8787)
wrangler dev --remote # run locally but with real production bindings
wrangler deploy # deploy to Cloudflare
wrangler delete # tear down
# Bindings
wrangler secret put MY_SECRET # set encrypted secret
wrangler kv namespace create CACHE
wrangler r2 bucket create my-bucket
wrangler d1 create my-db
wrangler queues create my-queue
# Observability
wrangler tail # live tail logs from production
wrangler tail --status error # filter to errors
wrangler tail --format json | jq
# D1
wrangler d1 execute my-db --command "SELECT * FROM users LIMIT 10"
wrangler d1 migrations create my-db init
wrangler d1 migrations apply my-db
# Misc
wrangler whoami
wrangler login
wrangler types # generate TS types for bindings5. wrangler.toml / wrangler.jsonc configuration
Per developers.cloudflare.com/workers/wrangler/configuration/.
wrangler.toml (legacy but still default)
name = "my-worker"
main = "src/index.ts"
compatibility_date = "2026-05-01"
compatibility_flags = ["nodejs_compat"]
# Default Environment ("production")
[vars]
ENVIRONMENT = "production"
# Bindings
[[kv_namespaces]]
binding = "CACHE"
id = "abc123def456"
[[r2_buckets]]
binding = "ASSETS"
bucket_name = "my-app-assets"
[[d1_databases]]
binding = "DB"
database_name = "my-db"
database_id = "uuid-here"
[[durable_objects.bindings]]
name = "ROOMS"
class_name = "Room"
[ai]
binding = "AI"
# Environments
[env.staging]
name = "my-worker-staging"
vars = { ENVIRONMENT = "staging" }wrangler.jsonc (newer, recommended)
{
"name": "my-worker",
"main": "src/index.ts",
"compatibility_date": "2026-05-01",
"compatibility_flags": ["nodejs_compat"],
"vars": {
"ENVIRONMENT": "production"
},
"kv_namespaces": [
{ "binding": "CACHE", "id": "abc123def456" }
],
"r2_buckets": [
{ "binding": "ASSETS", "bucket_name": "my-app-assets" }
],
"d1_databases": [
{
"binding": "DB",
"database_name": "my-db",
"database_id": "uuid-here"
}
],
"durable_objects": {
"bindings": [
{ "name": "ROOMS", "class_name": "Room" }
]
},
"ai": { "binding": "AI" },
"env": {
"staging": {
"name": "my-worker-staging",
"vars": { "ENVIRONMENT": "staging" }
}
}
}6. compatibility_date and compatibility_flags
Per developers.cloudflare.com/workers/configuration/compatibility-dates/. Cloudflare ships changes to the runtime that are usually backward-compatible — but for breaking changes, they’re gated behind a compatibility_date. Your Worker uses the runtime behavior as of the date you set.
compatibility_date = "2026-05-01" # use runtime behavior as of this dateSome features are opt-in via compatibility_flags:
compatibility_flags = [
"nodejs_compat", # Node.js APIs (process, Buffer, etc.)
"global_navigator", # navigator.userAgent
"transformstream_enable_standard_constructor",
"streams_enable_constructors",
"url_standard", # WHATWG URL spec compliance
"no_handle_cross_request_promise_resolution",
]When in doubt, copy the flags from wrangler init’s scaffolded config — they reflect current best practices.
7. Module syntax (ES modules — required)
Per developers.cloudflare.com/workers/configuration/modules/. Modern Workers use ES modules:
// src/index.ts
export interface Env {
CACHE: KVNamespace;
ASSETS: R2Bucket;
DB: D1Database;
AI: Ai;
MY_SECRET: string;
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/cached") {
const value = await env.CACHE.get("key");
return Response.json({ value });
}
return new Response("Hello!");
},
// Other handlers — same Worker can have multiple
async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext) {
// Cron trigger
console.log("Cron fired at", new Date(event.scheduledTime));
},
async queue(batch: MessageBatch, env: Env, ctx: ExecutionContext) {
// Queue consumer
for (const msg of batch.messages) {
await processMessage(msg.body);
msg.ack();
}
},
async email(message: EmailMessage, env: Env, ctx: ExecutionContext) {
// Email Worker
await message.forward("alerts@example.com");
},
};The default export is an object containing handlers. fetch is the most common; scheduled, queue, and email are optional.
8. Local development
Per developers.cloudflare.com/workers/local-development/. wrangler dev runs the Worker locally using workerd (the real production runtime):
wrangler dev # local — uses local KV, local D1, local R2
wrangler dev --remote # local code, but real production bindings
wrangler dev --port 3000
wrangler dev --inspect # enable Chrome DevTools debugging--local (default) gives fast iteration but isolated state. --remote is for testing against real prod data.
Miniflare — the framework wrapper around workerd that powers wrangler dev. Available as a standalone library too for use in tests:
import { Miniflare } from "miniflare";
const mf = new Miniflare({
modules: true,
scriptPath: "./src/index.ts",
kvNamespaces: ["CACHE"],
d1Databases: ["DB"],
});
const response = await mf.dispatchFetch("http://localhost/");9. Smart Placement
Per developers.cloudflare.com/workers/configuration/smart-placement/. By default, Workers run at the POP closest to the user. For backend-heavy Workers that mostly call the same upstream database (e.g. a Postgres in AWS us-east-1), this means most requests pay ~80-200ms of latency between Worker (anywhere globally) and DB.
Smart Placement: Cloudflare analyzes your Worker’s traffic and relocates the Worker close to the most-called upstream. The user → Worker hop becomes slightly slower (e.g. user in Asia hits a Worker in Virginia), but the Worker → DB hop becomes near-zero, and the round-trip is faster overall.
Enable:
[placement]
mode = "smart"Best for: backend-heavy Workers with one dominant DB region. Worst for: read-only Workers serving static-ish data globally.
10. Deployment
wrangler deploy # deploys current code as latest
wrangler deploy --env staging # deploys to staging environment
wrangler rollback # roll back to previous version
wrangler versions list # list deployed versions
wrangler versions deploy <ver> # gradual rollout to a versionDeployments propagate globally to all 330+ POPs within ~30 seconds. There is no concept of “deployment URL” like Vercel — your Worker’s URL is fixed (<worker>.<account>.workers.dev or your custom domain).
Gradual rollouts (re:Invent / 2024)
wrangler versions deploy 2-of-2 --percentage 10 # 10% of traffic to new version
wrangler versions deploy 2-of-2 --percentage 50 # 50%
wrangler versions deploy 2-of-2 --percentage 100 # full cutover11. Workers vs Pages vs Pages Functions
The history is messy. Briefly:
- Workers (2017+) — standalone JS/Wasm at the edge. Serverless functions, APIs, agents.
- Pages (2021+) — static site hosting with git integration. Cloudflare’s Vercel competitor.
- Pages Functions (2022+) — Workers attached to a Pages project, file-system-routed (
functions/api/foo.ts). - Workers Static Assets (2024+) — Workers can now also serve static assets. Cloudflare is converging Workers + Pages.
As of 2026-05: new projects should use Workers with Static Assets for full-stack apps. Pages still exists for pure-static or framework-driven sites (Next.js, Astro, SvelteKit on Pages), but is the older path. Cloudflare’s stated direction is Workers as the unified platform.
# Workers with Static Assets
name = "my-app"
main = "./src/worker.ts"
compatibility_date = "2026-05-01"
[assets]
directory = "./dist"
binding = "ASSETS"12. Multi-region considerations
Workers run at every POP — there’s no “pick a region” decision. The Worker’s code runs wherever the user is. The variables here are:
- Where your data is — KV is globally replicated (eventually consistent reads, ~60s write propagation). R2 has a single home region per bucket but reads are CDN-cached globally. D1 has a primary region + read replicas. Durable Objects live in one region per ID.
- Smart Placement — relocates Worker close to upstream DB.
- Latency from POPs to upstreams — for non-Cloudflare upstreams (your own Postgres in AWS), measure and decide on smart placement.
13. Common patterns
Hello, world
export default {
async fetch(request: Request, env: Env): Promise<Response> {
return new Response("Hello, Cloudflare!");
},
};JSON API with bindings
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/api/user" && request.method === "GET") {
const id = url.searchParams.get("id");
const user = await env.DB.prepare("SELECT * FROM users WHERE id = ?")
.bind(id)
.first();
return Response.json(user);
}
return new Response("Not found", { status: 404 });
},
};Background work after response (waitUntil)
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
// Fire and forget — response returned immediately, work continues
ctx.waitUntil(env.CACHE.put("last-request", Date.now().toString()));
return new Response("OK");
},
};14. Plans and account-level features
- Free — 100k requests/day, 10ms CPU. Great for learning, hobby projects, prototypes.
- Paid (Workers Paid) — $5/month minimum + usage. The real starting point.
- Enterprise — custom pricing, advanced features (Argo Smart Routing, Magic Transit, etc.).
Pro tip: a single Paid account can host hundreds of Workers — the $5/mo gates the account, not per-Worker. Use one account for all your projects to avoid multiplying the floor.