← All tech

Cloud / edge

Cloudflare

Stateless code and data that run at the network edge, close to every user.

  • Global low-latency Workers on V8 isolates
  • Stateless edge APIs with near-zero cold starts
  • R2 object storage with no egress fees
  • Strongly-consistent coordination via Durable Objects
Use it when

Reach for Cloudflare when latency to a worldwide audience matters and your compute is stateless or coordinates small bits of state — edge APIs, redirects, auth at the door, image/asset delivery, and real-time rooms built on Durable Objects.

Reach for something else when

Skip Cloudflare when you need long-running processes, a full Node/POSIX runtime, GPUs, or a heavy managed relational database — Workers run on V8 isolates with CPU-time limits, not containers. For those, use a container platform instead.

Official docs ↗


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

Beginner

Install 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
Run these in your terminal / editor
# 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 --version

Write the smallest Worker and run it locally

Beginner

Edit 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
Run these in your terminal / editor
// 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/healthz
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
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

Beginner

Set 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
Run these in your terminal / editor
# 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.dev

Store key/value data with a KV binding

Beginner

Create 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
Run these in your terminal / editor
# 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
For Claude Code / Cursor / an agent that can read & edit this repo.
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

Intermediate

Create 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
Run these in your terminal / editor
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
For Claude Code / Cursor / an agent that can read & edit this repo.
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

Intermediate

Create 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
Run these in your terminal / editor
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
For Claude Code / Cursor / an agent that can read & edit this repo.
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

Intermediate

Bind 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
Run these in your terminal / editor
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 = 10
interface 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
For Claude Code / Cursor / an agent that can read & edit this repo.
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

Advanced

Define 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
Run these in your terminal / editor
# 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
For Claude Code / Cursor / an agent that can read & edit this repo.
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

Advanced

Accept 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
Run these in your terminal / editor
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/abc
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
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

Advanced

Add 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
Run these in your terminal / editor
# 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
For Claude Code / Cursor / an agent that can read & edit this repo.
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

Advanced

Tail 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
Run these in your terminal / editor
# 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
For a plain chat. It returns complete code; you paste it in yourself.
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.