Cloudflare runs your code in V8 isolates inside data centres a few milliseconds from almost every user on Earth. This track moves from “hello, Worker” to bindings for KV, D1, R2, and Queues, then to Durable Objects for stateful real-time coordination — tagged by level so you can read only as deep as you need. The whole point is the edge: low latency everywhere, near-zero cold starts, and a runtime that is not Node, so you learn its real limits early.
Install Wrangler and create a Worker
BeginnerInstall Node.js, then scaffold a Worker with npm create cloudflare. It installs Wrangler, the Cloudflare CLI.
What Wrangler is, and what a Worker is
A Worker is a small script that runs on Cloudflare’s edge in a V8 isolate — the same engine as Chrome,
but not a Node.js process and not a container. Isolates start in well under a millisecond, which is why
Workers have effectively no cold start. Wrangler is the official CLI: it scaffolds, runs a local
simulation of the edge runtime, and deploys. The npm create cloudflare command (the C3 scaffolder) sets
up the project and pins Wrangler as a dev dependency, so you don’t install it globally.
Scaffold and inspect
# Node 18+ recommended
node --version
# C3 scaffolder: creates a "Hello World" Worker project
npm create cloudflare@latest tidal-edge -- --type=hello-world
cd tidal-edge
npx wrangler --versionWrite the smallest Worker and run it locally
BeginnerEdit src/index.ts so the Worker’s fetch handler answers GET /healthz with ok. Run it with wrangler dev.
The fetch handler is the whole program
A Worker exports a default object with a fetch(request, env, ctx) method — the edge calls it for every
incoming request. request is a standard Web Request; you return a standard Web Response. There is no
http module and no app.listen — the platform owns the socket. wrangler dev runs your Worker in
workerd, the same open-source runtime Cloudflare runs in production, so local behaviour matches the edge
closely.
A minimal Worker
// src/index.ts
export default {
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (request.method === "GET" && url.pathname === "/healthz") {
return new Response("ok", { status: 200 });
}
return new Response("not found", { status: 404 });
},
};npx wrangler dev
# in another terminal:
curl -i http://localhost:8787/healthzAgent prompt — paste into an agent with repo access
Role: Senior Cloudflare Workers engineer working in this repo.
Context: Fresh C3 project "tidal-edge", TypeScript, Wrangler installed. src/index.ts has the default fetch export.
Task: Implement the fetch handler in src/index.ts to route GET /healthz.
Requirements:
- Use the Web Platform URL and Request APIs only; no Node built-ins (no "http", "fs").
- GET /healthz returns 200 with body "ok".
- Any other path returns 404 with body "not found".
- Keep the default export shape: { async fetch(request, env, ctx) }.
Tests / acceptance:
- `npx wrangler dev` serves the Worker locally.
- `curl -s -o /dev/null -w "%{http_code}" http://localhost:8787/healthz` prints 200.
- `curl -s -o /dev/null -w "%{http_code}" http://localhost:8787/nope` prints 404.
Output: a unified diff plus a one-paragraph summary of the request flow.Configure and deploy with wrangler.toml
BeginnerSet your Worker’s name and entry point in wrangler.toml, then deploy with wrangler deploy.
Why a config file, and what compatibility_date does
wrangler.toml is the Worker’s manifest: its name (which becomes the subdomain), main (the entry
file), and compatibility_date. The compatibility date pins which version of the runtime’s behaviour
your Worker sees, so Cloudflare can ship runtime changes without breaking deployed Workers — set it to the
day you start and bump it deliberately. wrangler deploy uploads the Worker and makes it live on
Cloudflare’s global network within seconds.
Manifest and first deploy
# wrangler.toml
name = "tidal-edge"
main = "src/index.ts"
compatibility_date = "2024-09-23"# Authenticate once in your browser, then ship:
npx wrangler login
npx wrangler deploy
# -> https://tidal-edge.<your-subdomain>.workers.devStore key/value data with a KV binding
BeginnerCreate a KV namespace, bind it in wrangler.toml, and read/write it through env in the Worker.
What KV is good and bad at
Workers KV is a global, eventually-consistent key/value store. Reads are fast and cached at the edge;
writes propagate worldwide within seconds, so KV is ideal for data that is read far more than written —
config, feature flags, cached API responses, session lookups. It is not a database: there are no
transactions, no secondary indexes, and a write you just made may not be visible from another data centre
for a moment. A binding exposes the namespace to your code as a property on the env argument, so no
connection string or SDK is needed.
Create, bind, and use KV
# Creates the namespace and prints the id to paste into wrangler.toml
npx wrangler kv namespace create CACHE# wrangler.toml — add the binding (use the id printed above)
[[kv_namespaces]]
binding = "CACHE"
id = "<paste-the-id-here>"// src/index.ts — env.CACHE is the bound namespace
interface Env {
CACHE: KVNamespace;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
await env.CACHE.put("greeting", "hello edge", { expirationTtl: 60 });
const value = await env.CACHE.get("greeting");
return new Response(value ?? "miss");
},
};Agent prompt — paste into an agent with repo access
Role: Senior Cloudflare Workers engineer in this repo.
Context: Worker "tidal-edge", TypeScript. A KV namespace bound as CACHE exists in wrangler.toml.
Task: Add a tiny read-through cache for GET /quote that stores a JSON payload in KV under a fixed key.
Requirements:
- Define an Env interface with CACHE: KVNamespace.
- On GET /quote: read key "quote:today" from KV; on hit return it with Content-Type application/json
and header "x-cache: HIT"; on miss, compute a payload, write it with expirationTtl 300, and return it
with "x-cache: MISS".
- Use only Web Platform + KV APIs; no Node built-ins.
Tests / acceptance:
- `npx wrangler dev` serves the Worker.
- First `curl -si http://localhost:8787/quote` shows "x-cache: MISS"; a second within 5 minutes shows
"x-cache: HIT".
Output: a unified diff plus a note on why KV's eventual consistency is acceptable for this use.Query a SQL database at the edge with D1
IntermediateCreate a D1 database, apply a schema, bind it, and run a parameterised query from the Worker.
D1 is SQLite, bound to your Worker
D1 is Cloudflare’s serverless SQL database, built on SQLite. You get real SQL — tables, indexes,
transactions — exposed through a binding, so you query it with env.DB.prepare(sql).bind(...) instead of a
connection pool. Always use .bind() placeholders (?) rather than string-concatenating values, which
invites SQL injection. D1 is a great fit for read-heavy relational data that lives near your edge logic;
for very large or write-heavy OLTP workloads, a managed Postgres is still the better tool.
Create D1, migrate, and query
npx wrangler d1 create tidal-db
# paste the printed database_id into wrangler.toml under [[d1_databases]]
# Apply a schema file to the local dev database
npx wrangler d1 execute tidal-db --local --command \
"CREATE TABLE posts (id INTEGER PRIMARY KEY, author TEXT, body TEXT);"# wrangler.toml
[[d1_databases]]
binding = "DB"
database_name = "tidal-db"
database_id = "<paste-the-id-here>"interface Env {
DB: D1Database;
}
// inside fetch:
const { results } = await env.DB
.prepare("SELECT id, author, body FROM posts WHERE author = ?")
.bind(author)
.all();
return Response.json(results);Agent prompt — paste into an agent with repo access
Role: Senior Cloudflare engineer in this repo.
Context: Worker "tidal-edge", TypeScript. A D1 database bound as DB exists with a table
posts(id INTEGER PRIMARY KEY, author TEXT, body TEXT).
Task: Add two routes backed by D1: POST /posts to insert and GET /posts?author=... to list.
Requirements:
- Define Env with DB: D1Database.
- POST /posts reads JSON { author, body }, rejects missing/empty fields with 422, inserts with a
parameterised statement (.bind), returns 201 + the created row.
- GET /posts requires an author query param; use .prepare(...).bind(author).all(); return JSON array.
- Use placeholders (?) only — never interpolate user input into SQL.
Tests / acceptance:
- `npx wrangler d1 execute tidal-db --local --command "SELECT 1;"` succeeds.
- POST {"author":"","body":"hi"} returns 422.
- POST a valid post then GET /posts?author=... returns it.
Output: a unified diff plus a one-paragraph note on why .bind() matters.Store files in R2 with no egress fees
IntermediateCreate an R2 bucket, bind it, and put/get objects through env using the S3-style object API.
Why R2 instead of S3
R2 is Cloudflare’s object store. Its API is S3-compatible, so existing tooling and SDKs work, but its
headline feature is no egress fees — you do not pay to read your data out, which is the cost that makes
serving large media from traditional object stores expensive. From a Worker you skip the S3 SDK entirely:
the bound bucket gives you put, get, head, delete, and list directly on env.BUCKET. R2 is ideal
for user uploads, generated assets, backups, and anything you serve globally.
Create R2, bind, and serve an object
npx wrangler r2 bucket create tidal-media# wrangler.toml
[[r2_buckets]]
binding = "MEDIA"
bucket_name = "tidal-media"interface Env {
MEDIA: R2Bucket;
}
// inside fetch — stream an object straight back to the client:
const object = await env.MEDIA.get(key);
if (object === null) {
return new Response("not found", { status: 404 });
}
const headers = new Headers();
object.writeHttpMetadata(headers);
headers.set("etag", object.httpEtag);
return new Response(object.body, { headers });Agent prompt — paste into an agent with repo access
Role: Senior Cloudflare engineer in this repo.
Context: Worker "tidal-edge", TypeScript. An R2 bucket bound as MEDIA exists in wrangler.toml.
Task: Add PUT /media/:key to upload and GET /media/:key to download from R2.
Requirements:
- Define Env with MEDIA: R2Bucket.
- PUT stores request.body under the key (parsed from the path); return 201.
- GET fetches with env.MEDIA.get(key); on null return 404; on hit, copy R2 http metadata onto the
response headers via object.writeHttpMetadata(headers), set etag, and stream object.body.
- Use the bound bucket API only; do not import the AWS S3 SDK.
Tests / acceptance:
- `npx wrangler dev` serves the Worker.
- `curl -X PUT --data-binary @file.txt http://localhost:8787/media/file.txt` returns 201.
- `curl -i http://localhost:8787/media/file.txt` returns 200 with the bytes and an etag header.
Output: a unified diff plus one sentence on why no-egress R2 changes media-serving economics.Offload slow work to a Queue
IntermediateBind a Queue as a producer in your Worker, send messages from a request, and consume them in a separate handler.
Why queue work off the request path
Workers have a CPU-time limit per request, and users wait while you process. A Queue lets the request
handler enqueue a message and return immediately, while a consumer Worker processes messages in batches in
the background — the classic producer/consumer split for emails, webhooks, thumbnails, or fan-out. The same
Worker can be both: it gets a queue(batch, env, ctx) handler that Cloudflare invokes with a batch of
messages, and you ack/retry them. Sending uses the bound producer: await env.JOBS.send(payload).
Producer binding and consumer handler
npx wrangler queues create tidal-jobs# wrangler.toml — bind as producer AND register a consumer
[[queues.producers]]
binding = "JOBS"
queue = "tidal-jobs"
[[queues.consumers]]
queue = "tidal-jobs"
max_batch_size = 10interface Env {
JOBS: Queue;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
await env.JOBS.send({ type: "welcome_email", at: Date.now() });
return new Response("queued", { status: 202 });
},
// Cloudflare invokes this with a batch of messages:
async queue(batch: MessageBatch, env: Env): Promise<void> {
for (const msg of batch.messages) {
// ...do the slow work...
msg.ack();
}
},
};Agent prompt — paste into an agent with repo access
Role: Senior Cloudflare engineer in this repo.
Context: Worker "tidal-edge", TypeScript. A Queue "tidal-jobs" is bound as producer JOBS, with this same
Worker registered as its consumer.
Task: Enqueue a job on POST /notify and process the batch in the queue() handler.
Requirements:
- Define Env with JOBS: Queue.
- POST /notify reads JSON { userId }, validates it is a non-empty string (422 if not), sends a message
{ type: "notify", userId, at: Date.now() }, and returns 202.
- The queue() handler iterates batch.messages, performs the (stubbed) work, and calls msg.ack();
on a thrown error call msg.retry() instead so the message is redelivered.
- No Node built-ins.
Tests / acceptance:
- `npx wrangler dev` runs both fetch and queue handlers locally.
- POST {"userId":""} returns 422; POST {"userId":"u1"} returns 202.
Output: a unified diff plus a one-paragraph note on at-least-once delivery and idempotency.Coordinate state with a Durable Object
AdvancedDefine a Durable Object class for a single chat room, bind it, and route requests to one instance by name.
Durable Objects give you one consistent, single-threaded place
KV and D1 are shared stores; a Durable Object is the opposite — a single, globally-unique, addressable
instance that runs one piece of code with strongly-consistent, transactional storage and no concurrency
within it (requests to one object are serialised). That makes Durable Objects the right tool for
coordination: a chat room, a game lobby, presence tracking, a rate limiter, a counter that must be exactly
right. You get one object per id, so env.ROOMS.idFromName("room-42") always routes to the same instance
worldwide, and that instance owns its state.
A minimal Durable Object room
# wrangler.toml — declare the class and a migration to create it
[[durable_objects.bindings]]
name = "ROOMS"
class_name = "ChatRoom"
[[migrations]]
tag = "v1"
new_classes = ["ChatRoom"]export class ChatRoom {
state: DurableObjectState;
constructor(state: DurableObjectState) {
this.state = state;
}
async fetch(request: Request): Promise<Response> {
// storage is strongly consistent and private to this instance
let count = (await this.state.storage.get<number>("count")) ?? 0;
count++;
await this.state.storage.put("count", count);
return Response.json({ count });
}
}
interface Env {
ROOMS: DurableObjectNamespace;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const id = env.ROOMS.idFromName("room-42"); // same id -> same instance
return env.ROOMS.get(id).fetch(request);
},
};Agent prompt — paste into an agent with repo access
Role: Senior Cloudflare Durable Objects engineer in this repo.
Context: Worker "tidal-edge", TypeScript. wrangler.toml declares a Durable Object binding ROOMS bound to
class ChatRoom, with a migration creating ChatRoom.
Task: Implement ChatRoom as a per-room counter the default Worker routes to by room name.
Requirements:
- Export class ChatRoom with a constructor(state: DurableObjectState) and an async fetch.
- On POST it increments a persisted "count" via state.storage (get/put) and returns JSON { count }.
- On GET it returns the current { count } without incrementing.
- The default Worker derives the id with env.ROOMS.idFromName(roomName) from a /room/:name path and
forwards the request to that instance.
- Rely on the single-threaded, strongly-consistent guarantee — do not add locks.
Tests / acceptance:
- `npx wrangler dev` runs locally.
- Two POSTs to /room/abc return count 1 then 2; a GET to /room/abc returns 2; a POST to /room/xyz
returns 1 (separate instance).
Output: a unified diff plus a one-paragraph explanation of why this needs a Durable Object, not KV.Add real-time presence with WebSockets in a Durable Object
AdvancedAccept a WebSocket upgrade inside the ChatRoom Durable Object and broadcast messages to everyone connected to that room.
Why the Durable Object owns the sockets
Real-time chat needs every participant in a room to reach the same coordinator. Because a Durable Object
is a single instance per id, it is the natural place to hold the live WebSocket connections for that room:
it accepts the upgrade with new WebSocketPair(), keeps the server sockets in memory, and fans a message
out to all of them. Use the Hibernation API (state.acceptWebSocket(server) plus webSocketMessage)
so the object can be evicted from memory between messages without dropping connections — you only pay for
active CPU, not idle sockets.
WebSocket upgrade and broadcast
export class ChatRoom {
state: DurableObjectState;
constructor(state: DurableObjectState) {
this.state = state;
}
async fetch(request: Request): Promise<Response> {
if (request.headers.get("Upgrade") !== "websocket") {
return new Response("expected websocket", { status: 426 });
}
const { 0: client, 1: server } = new WebSocketPair();
this.state.acceptWebSocket(server); // hibernation-aware
return new Response(null, { status: 101, webSocket: client });
}
// called on each incoming frame; sockets survive hibernation
webSocketMessage(ws: WebSocket, message: string) {
for (const peer of this.state.getWebSockets()) {
peer.send(message);
}
}
}# Local dev serves WebSockets too:
npx wrangler dev
# connect with any ws client, e.g.: npx wscat -c ws://localhost:8787/room/abcAgent prompt — paste into an agent with repo access
Role: Senior Cloudflare Durable Objects engineer in this repo.
Context: Worker "tidal-edge", TypeScript. ChatRoom is a Durable Object the Worker routes to by room name.
Task: Add WebSocket presence + broadcast to ChatRoom using the Hibernation API.
Requirements:
- On an Upgrade: websocket request, create new WebSocketPair(), call state.acceptWebSocket(server),
and return status 101 with the client socket; non-upgrade requests return 426.
- Implement webSocketMessage(ws, message): broadcast the message to every socket from
state.getWebSockets() (including or excluding the sender — pick one and document it).
- Implement webSocketClose to let peers know a member left (broadcast a JSON {type:"leave"}).
- Use the hibernation methods (acceptWebSocket / getWebSockets), not a manually held array, so idle
rooms cost nothing.
Tests / acceptance:
- `npx wrangler dev` runs locally.
- Two clients connected to ws://localhost:8787/room/abc: a message sent by one is received by the other.
- Disconnecting one client delivers a {"type":"leave"} frame to the remaining client.
Output: a unified diff plus a paragraph on why hibernation matters for cost at idle.Schedule background jobs with a Cron Trigger
AdvancedAdd a [triggers] cron schedule to wrangler.toml and handle it in a scheduled export.
Cron Triggers run a Worker on a schedule, no request needed
Beyond responding to requests, a Worker can run on a clock. A Cron Trigger fires the scheduled(event, env, ctx) handler on a standard cron expression — for cache warming, nightly aggregation, cleanup, or
polling. Use ctx.waitUntil() to keep async work alive past the handler’s return, and test the path
locally with wrangler dev --test-scheduled so you don’t have to wait for the clock. Keep each run within
the Worker CPU-time budget; for big jobs, fan work out to a Queue from inside scheduled.
Cron config and scheduled handler
# wrangler.toml — runs every 5 minutes
[triggers]
crons = ["*/5 * * * *"]export default {
async fetch(request: Request, env: Env): Promise<Response> {
return new Response("ok");
},
async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext) {
ctx.waitUntil(refreshCache(env));
},
};# Trigger the scheduled handler locally without waiting for the clock:
npx wrangler dev --test-scheduled
curl "http://localhost:8787/__scheduled?cron=*/5+*+*+*+*"Agent prompt — paste into an agent with repo access
Role: Senior Cloudflare engineer in this repo.
Context: Worker "tidal-edge", TypeScript. wrangler.toml has [triggers] crons = ["*/5 * * * *"].
A KV namespace bound as CACHE exists.
Task: Implement a scheduled() handler that refreshes a cached value in KV every run.
Requirements:
- Define Env with CACHE: KVNamespace.
- scheduled() computes a fresh payload and writes it to KV under "warm:latest" with expirationTtl 600,
wrapped in ctx.waitUntil() so the write completes after the handler returns.
- Keep the work well under the CPU-time limit; if it could grow, note where you would offload to a Queue.
Tests / acceptance:
- `npx wrangler dev --test-scheduled` then
`curl "http://localhost:8787/__scheduled?cron=*/5+*+*+*+*"` returns 200 and triggers the handler.
- A subsequent GET route that reads "warm:latest" from KV returns the refreshed value.
Output: a unified diff plus a one-paragraph note on why ctx.waitUntil() is required here.Stream logs and respect the runtime's limits
AdvancedTail live production logs with wrangler tail, and learn the CPU-time and runtime-API limits before you hit them.
Workers is not Node — know the edges
Two limits define how you design for Workers. First, CPU time per request is bounded (the free tier
caps at a low budget; paid plans allow far more), and wall-clock time spent awaiting I/O does not count —
so being I/O-bound is fine, being CPU-bound is not. Second, the runtime exposes the Web Platform APIs
(fetch, Request, crypto.subtle, URL, streams), not the full Node standard library; many node:
modules are unavailable unless you opt in with the nodejs_compat compatibility flag, and even then not
everything works. Design stateless, push heavy or blocking work to Queues or Durable Objects, and watch
production with wrangler tail to see real errors and timings.
Live logs and the compat flag
# Stream live logs from the deployed Worker:
npx wrangler tail tidal-edge# wrangler.toml — opt into a subset of Node APIs only if you truly need them
compatibility_flags = ["nodejs_compat"]Chat prompt — paste into a chat to get the code
Role: Cloudflare Workers teacher. The reader has no repo access here — return complete code.
Task: Show a single TypeScript Worker that demonstrates staying inside the runtime limits: it hashes a
request body with the Web Crypto API (crypto.subtle) instead of a Node "crypto" import, and returns the
hex digest, with a guard that rejects bodies over 1 MB with 413.
Requirements:
- Default export { async fetch(request, env, ctx) }; no `node:` imports, no nodejs_compat needed.
- Read the body as an ArrayBuffer; if its byteLength > 1_000_000, return 413.
- Use crypto.subtle.digest("SHA-256", buffer) and encode the result as lowercase hex.
- Return the hex string with Content-Type text/plain.
Tests / acceptance (describe, since no repo):
- POST a small body returns 200 with a 64-char hex string.
- POST a 2 MB body returns 413.
Output: the complete Worker file, no commentary.Where to take it next
- Put Workers to work in Helix Assistant, which streams its RAG answers from a Cloudflare Worker at the edge.
- Want one language across the Worker and the browser, with the richest edge ecosystem? See TypeScript.
- Need an in-memory store for leaderboards, pub/sub, or rate limiting alongside the edge? See Redis.
- Comparing edge-everywhere against managed containers and managed SQL? See Google Cloud and AWS.