Summary
Cloudflare is a lot more than a CDN. It's a full developer platform: compute, SQL, object storage, key-value, queues, durable execution, analytics, AI inference, and zero-trust auth, all running on the same global network and wired together with bindings instead of connection strings.
This is a field guide to building on it for real: Workers, D1, R2, KV, Hyperdrive, Durable Objects, Queues, Workflows, Analytics Engine, Workers AI, AI Gateway, and Access, plus the rest of the platform worth knowing (Containers, Sandboxes, the Agents SDK, Vectorize, Browser Run, and more). Each chapter covers when to reach for a product, the config and code to get it working, and the gotchas that cost an afternoon if nobody warns you.
This is a living document. It's current as of November 2026, and Cloudflare ships constantly, so I keep it up to date.
Why Cloudflare
Most people know Cloudflare as the orange cloud in front of their DNS. That undersells it badly. Over the last few years Cloudflare has quietly assembled one of the most complete developer platforms around: compute, SQL, object storage, key-value, queues, durable execution, analytics, AI inference, an AI proxy, and zero-trust auth, all running on the same network that already sits in front of a fifth of the web.
I build a lot of my own products and client systems on it now. This site runs on EmDash on Workers with D1 and R2. Dialer and voice tooling, webhook fan-out, scheduled jobs, little internal dashboards: a surprising amount of it ends up as a Worker with a handful of bindings, because it's fast to ship, cheap to run, and there's no server to babysit.
What this guide covers
This is a field guide to the parts of Cloudflare that matter when you're building real things:
- Compute: Workers, and the patterns around them (routing, static assets, cron, placement)
- Astro: creating an Astro app, wiring it to Cloudflare bindings, and deploying it to Workers
- Data: D1, R2, KV, Hyperdrive, and Durable Objects, and how to choose between them
- Async and orchestration: Queues and Workflows
- Observability: Workers Analytics Engine and the built-in logs
- AI: Workers AI and AI Gateway, which are now converging into one inference layer
- Security: Cloudflare Access, Turnstile, rate limiting, and HMAC-signed URLs
- Email: sending transactional email from Workers (and from anything else, over SMTP)
- Caching: Workers Cache in front of your Worker, and the Pages build cache for faster deploys
- Everything else worth knowing about: Containers and Sandboxes, the Agents SDK, Vectorize and AI Search, Browser Run, and private networking
Each chapter covers what the product actually is, when to reach for it, the config and code to get it working, and the gotchas that cost an afternoon if nobody warns you.
Who it's for
Developers who are comfortable with TypeScript and HTTP and want to build on Cloudflare without reading 400 pages of docs first. If you've deployed a Worker before, you'll move fast. If you haven't, the next two chapters get you there.
This is a living document. It's current as of November 2026, and Cloudflare ships constantly (two big launch weeks a year, plus everything in between), so I keep it up to date. Prices and limits in particular move; I've noted where to double-check.
The Mental Model: Isolates, Bindings, and Wrangler
Three ideas make the whole platform click. Once you have them, every product in this guide is just "another binding."
Isolates, not containers
A Worker isn't a container or a VM. It's a V8 isolate: the same sandboxing technology Chrome uses to keep tabs apart. Thousands of isolates share a single process, which is why Workers start in milliseconds instead of seconds and why there's effectively no cold start to design around.
The trade-off is that you're not running "a server." There's no filesystem to write to, no long-lived process holding state in memory, and no background thread that keeps going after the response. Your code runs when a request (or a cron, or a queue message) arrives, and you're billed for the CPU time you actually use. Time spent waiting on fetch(), a database, or KV doesn't count.
Bindings are the API
Every Cloudflare resource your Worker talks to is attached as a binding: a named object on env. D1 is env.DB, R2 is env.BUCKET, a queue is env.JOBS, Workers AI is env.AI. No connection strings, no SDK clients to configure, no credentials in your code. Wrangler wires it up at deploy time.
export default {
async fetch(request, env, ctx) {
const user = await env.DB.prepare("SELECT * FROM users WHERE id = ?").bind(1).first();
const avatar = await env.BUCKET.get(`avatars/${user.id}.png`);
await env.JOBS.send({ type: "viewed", userId: user.id });
return new Response(avatar?.body);
},
} satisfies ExportedHandler<Env>;That's three different products in four lines, and none of them needed a client library or a secret. This is the single biggest reason building on Cloudflare feels fast.
Wrangler and wrangler.jsonc
Wrangler is the CLI, and wrangler.jsonc is where your Worker's shape lives: entry point, compatibility date, and every binding.
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "my-app",
"main": "src/index.ts",
"compatibility_date": "2026-10-01",
"compatibility_flags": ["nodejs_compat"],
"observability": { "enabled": true },
"d1_databases": [{ "binding": "DB", "database_name": "my-app", "database_id": "<id>" }],
"r2_buckets": [{ "binding": "BUCKET", "bucket_name": "my-app-files" }],
"kv_namespaces": [{ "binding": "CONFIG", "id": "<id>" }],
"queues": { "producers": [{ "binding": "JOBS", "queue": "my-app-jobs" }] }
}Run npx wrangler types after changing bindings and you get a generated Env interface, so env.DB is fully typed.
A few habits worth adopting from day one:
- Use `wrangler.jsonc`, not TOML. It's the default now, it supports comments, and the schema gives you autocomplete.
- Pin `compatibility_date`. It freezes runtime behavior so a platform change doesn't break you. Bump it deliberately.
- Turn on `nodejs_compat`. Most npm packages that touch
Buffer,crypto, or streams need it. - Turn on observability. Workers Logs are cheap and you'll want them the first time something breaks.
Local development
wrangler dev runs your Worker locally on workerd (the open-source runtime that powers production), with local, simulated versions of D1, R2, KV, Queues, and Durable Objects. Data persists in .wrangler/state. Add --remote when you need to hit real resources.
For tests, @cloudflare/vitest-pool-workers runs Vitest inside the actual runtime with real bindings, which beats mocking env by a mile.
Workers
Workers are the center of everything. Every other product in this guide is something a Worker talks to.
The handlers
A Worker exports an object with one or more handlers:
export default {
// HTTP requests
async fetch(request, env, ctx) {
return new Response("hello");
},
// Cron Triggers
async scheduled(controller, env, ctx) {
ctx.waitUntil(cleanupExpiredSessions(env));
},
// Queue consumers
async queue(batch, env, ctx) {
for (const msg of batch.messages) msg.ack();
},
} satisfies ExportedHandler<Env>;ctx.waitUntil() is the one to learn early. It lets you return the response immediately and keep doing work (logging, cache writes, analytics) after the client has its bytes. It's not a background job runner, though; anything heavy or important goes to a Queue or a Workflow.
Routing with Hono
For anything beyond a single endpoint, use Hono. It was built for Workers, it's tiny, and it gives you typed bindings on the context:
import { Hono } from "hono";
const app = new Hono<{ Bindings: Env }>();
app.get("/api/posts/:slug", async (c) => {
const post = await c.env.DB.prepare("SELECT * FROM posts WHERE slug = ?")
.bind(c.req.param("slug"))
.first();
return post ? c.json(post) : c.notFound();
});
export default app;Static assets (and what happened to Pages)
Workers can serve static files directly. Point assets.directory at your build output and they're served from the edge for free, with no Worker invocation for asset hits:
{
"assets": { "directory": "./dist", "binding": "ASSETS", "not_found_handling": "single-page-application" }
}This is why Cloudflare now recommends Workers over Pages for new projects. Pages still works, but Workers with static assets does everything Pages did, plus Durable Objects, Queues consumers, cron, and the rest. Frameworks like Astro (Cloudflare acquired the Astro team in January 2026), React Router, SvelteKit, Nuxt, and Next.js (via OpenNext or vinext) all deploy this way. The next chapter walks through Astro end to end.
Cron Triggers
{ "triggers": { "crons": ["*/15 * * * *", "0 9 * * 1"] } }Each cron fires your scheduled handler, and controller.cron tells you which one. Cron invocations get a much longer CPU budget than HTTP requests (up to 15 minutes), which makes them fine for nightly cleanup or report generation. For anything multi-step or that needs retries, kick off a Workflow from the cron instead.
Service bindings
Workers can call other Workers directly through service bindings: no public URL, no network hop, no extra request charge. Combine that with WorkerEntrypoint and you get RPC:
// auth-worker
import { WorkerEntrypoint } from "cloudflare:workers";
export class AuthService extends WorkerEntrypoint<Env> {
async verify(token: string) { /* ... */ return { userId: "u_123" }; }
}
// app-worker: "services": [{ "binding": "AUTH", "service": "auth-worker", "entrypoint": "AuthService" }]
const { userId } = await env.AUTH.verify(token);This is how I split bigger systems up: one Worker per concern, wired together by service bindings, deployed independently.
Placement
By default a Worker runs in the data center closest to the user. That's great until the Worker makes five sequential queries to a database in Virginia. Smart Placement ("placement": { "mode": "smart" }) watches your traffic and moves execution closer to your backend when that's faster overall. Turn it on for anything database-heavy.
Limits worth knowing
- Free plan: 100,000 requests a day and 10 ms of CPU per invocation.
- Paid plan ($5/month): 10 million requests and 30 million CPU-ms included, then usage-based. CPU defaults to 30 seconds per invocation and can be raised to 5 minutes with
limits.cpu_ms. - Memory: 128 MB per isolate on both plans. This is the limit that bites when people try to process big files in memory. Stream instead.
- Duration: there's no wall-clock charge. A Worker that waits 20 seconds on a slow API costs you only the few milliseconds of CPU it used.
Set limits.cpu_ms deliberately on paid accounts. It's your guardrail against a runaway loop turning into a runaway bill.
Building and Deploying an Astro App
Astro is my default framework for anything with pages: content sites, marketing sites, dashboards, and full-stack apps with a sprinkle of interactivity. It's also now a Cloudflare project (Cloudflare acquired The Astro Technology Company in January 2026, and Astro stays open source), so Astro on Workers is about as first-class as a pairing gets. This site is an Astro app on Workers.
This chapter takes you from nothing to a deployed Astro app that talks to D1, R2, and KV, then sets up push-to-deploy. It assumes Astro 6 and @astrojs/cloudflare v13 or later, which is where the setup got much simpler.
1. Create the project
The quickest path is Cloudflare's own scaffolder, which runs the Astro wizard and wires in the adapter, Wrangler, and a deploy script:
npm create cloudflare@latest -- my-astro-app --framework=astro
cd my-astro-app
npm run devAstro 6 needs Node 22 or newer, locally and in CI.
Already have an Astro project? Add the adapter instead:
npx astro add cloudflareThat installs @astrojs/cloudflare and updates astro.config.mjs for you.
2. Static, server, or both
If your site is fully static, you don't even need the adapter. astro build produces dist/, and a Worker serves it as static assets for free.
Once you need anything dynamic (API routes, auth, forms, reading from D1), add the adapter. You can render everything on demand, or keep most pages prerendered and opt individual routes into server rendering:
// astro.config.mjs
import { defineConfig } from "astro/config";
import cloudflare from "@astrojs/cloudflare";
export default defineConfig({
output: "server", // or leave static and add `export const prerender = false` per route
adapter: cloudflare(),
});My usual setup is static by default, with export const prerender = false on the handful of pages and endpoints that actually need the server. Prerendered pages come straight from the asset cache and never wake the Worker.
3. wrangler.jsonc
With adapter v13, Astro generates a sensible default config, so the file is optional. You'll want one as soon as you add bindings:
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "my-astro-app",
"main": "@astrojs/cloudflare/entrypoints/server",
"compatibility_date": "2026-10-01",
"compatibility_flags": ["nodejs_compat"],
"observability": { "enabled": true },
"d1_databases": [{ "binding": "DB", "database_name": "my-astro-app", "database_id": "<id>" }],
"r2_buckets": [{ "binding": "BUCKET", "bucket_name": "my-astro-app-files" }],
"kv_namespaces": [{ "binding": "CACHE", "id": "<id>" }],
"vars": { "SITE_NAME": "My Astro App" }
}If you're upgrading from an older setup, note two changes: main now points at the adapter's entrypoint (not ./dist/_worker.js/index.js), and you can delete the old assets block. The adapter configures static assets itself.
Then add type generation to your scripts so env stays typed:
{
"scripts": {
"dev": "wrangler types && astro dev",
"build": "wrangler types && astro check && astro build",
"preview": "wrangler types && astro preview",
"deploy": "npm run build && wrangler deploy"
}
}4. Using bindings in pages and endpoints
In Astro 6 you import env from cloudflare:workers, in pages, endpoints, middleware, and actions alike. (The old Astro.locals.runtime.env is gone in adapter v13.)
A page that reads from D1:
---
// src/pages/posts/[slug].astro
export const prerender = false;
import { env } from "cloudflare:workers";
const post = await env.DB.prepare("SELECT * FROM posts WHERE slug = ?")
.bind(Astro.params.slug)
.first();
if (!post) return Astro.redirect("/404");
---
<h1>{post.title}</h1>
<article set:html={post.html} />An API endpoint that accepts an upload into R2:
// src/pages/api/upload.ts
import type { APIRoute } from "astro";
import { env } from "cloudflare:workers";
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
const form = await request.formData();
const file = form.get("file") as File;
const key = `uploads/${crypto.randomUUID()}-${file.name}`;
await env.BUCKET.put(key, file.stream(), { httpMetadata: { contentType: file.type } });
return Response.json({ key });
};A few other Cloudflare things you'll reach for:
- Request metadata:
Astro.request.cf(country, city, colo, and so on) - waitUntil and Durable Object exports:
Astro.locals.cfContext - Typed config and secrets:
astro:env/serverworks with Cloudflare variables and secrets too
5. Sessions, images, and the rest
- Sessions: Astro's Sessions API uses KV automatically on Cloudflare. A
SESSIONnamespace is provisioned for you on deploy; rename it withsessionKVBindingName, or setsession: falseif you don't use sessions. Remember KV is eventually consistent, so don't keep anything there that must update instantly. - Images: the default
cloudflare-bindingimage service uses the Cloudflare Images binding, provisioned on deploy. For a mostly static site,imageService: { build: "compile", runtime: "cloudflare-binding" }optimizes images at build time and only transforms at runtime for on-demand pages. - Prerendering with Node-only packages: prerendered pages build inside workerd by default to match production. If a prerendered page needs
node:fsor a Node-only library, setprerenderEnvironment: "node". - Headers and redirects: put
_headersand_redirectsfiles inpublic/for static assets. They don't apply to server-rendered responses; handle those in middleware. - Caching rendered pages: see the Workers Cache chapter for putting a cache in front of server-rendered routes.
6. Secrets and local development
# production secrets
npx wrangler secret put STRIPE_SECRET_KEYFor local development, put secrets in .dev.vars in the project root (and add it to .gitignore). astro dev now runs your code in workerd through the Cloudflare Vite plugin, with local D1, R2, and KV, so what you see locally is very close to production. Apply D1 migrations locally with npx wrangler d1 migrations apply my-astro-app --local.
Before deploying anything big, run npm run build && npm run preview to test the production build in workerd. If an error is hard to read, temporarily set vite.build.minify to false.
7. Deploy
npx wrangler login
npm run deploy # astro build && wrangler deployThe first deploy creates the Worker and gives you a *.workers.dev URL, provisioning any auto-managed bindings (the SESSION KV namespace and the IMAGES binding) along the way.
To put it on your domain (the zone has to be on Cloudflare), add a route and redeploy:
{ "routes": [{ "pattern": "www.example.com", "custom_domain": true }] }For staging, define a staging environment in wrangler.jsonc with its own bindings, then build per environment, because the build bakes in the environment:
CLOUDFLARE_ENV=staging npx astro build && npx wrangler deploy8. Push-to-deploy with Workers Builds
Once it works from your laptop, hand deploys to Cloudflare:
- In the dashboard, open the Worker, go to Settings > Builds, and connect your GitHub or GitLab repo.
- Set the build command to
npm run buildand the deploy command tonpx wrangler deploy. - Make sure the build uses Node 22 or newer (the default image does).
- Add any build-time variables under the build settings; runtime secrets stay in
wrangler secret.
Every push to your production branch deploys, and every other branch gets its own preview URL, which is perfect for sending a client a link before merging.
Gotchas
- Old tutorials are wrong now. Anything using
Astro.locals.runtime.env,platformProxy, ormain: ./dist/_worker.js/index.jspredates Astro 6 and adapter v13. - Prerendered pages can't read bindings at request time. They run once, at build. If a page shows live data, it needs
prerender = false, or it should fetch from an endpoint. - Disable `workers.dev` (
"workers_dev": false) once your custom domain is live, especially if anything sits behind Cloudflare Access. - Run `wrangler types` after every binding change, or TypeScript will complain that
env.DBdoesn't exist. - Each environment needs its own build. Building once and deploying to staging and production with the same
distwon't pick up the right bindings.
If you want a CMS on top of all this, EmDash is the obvious companion. It's an Astro integration that runs on exactly this stack (D1 and R2 on Workers), and I wrote a whole field guide to it: Mastering EmDash.
Choosing Where Your Data Lives
Cloudflare gives you five places to put data, and the most common mistake is picking the wrong one. Here's the cheat sheet I actually use:
- Relational data, SQL, joins → D1. Managed SQLite, great up to 10 GB per database.
- Files, images, backups, anything big → R2. S3-compatible, zero egress fees.
- Config, flags, cached lookups read everywhere → KV. Globally cached, very fast reads, eventually consistent.
- An existing Postgres or MySQL database → Hyperdrive. Pooling and caching in front of the database you already have.
- Per-user or per-room state, coordination, real-time → Durable Objects. Single-threaded, strongly consistent, each with its own SQLite.
- Events and metrics at high volume → Analytics Engine. Write-heavy, queried with SQL, cheap.
A few rules of thumb:
- If you're asking "is this consistent?", it's not KV. KV is for data that's read constantly and changes rarely.
- If it's bigger than a few MB, it goes in R2 and you store the key in D1.
- If one database would grow past 10 GB, split it. D1 is designed for many smaller databases (per tenant, per customer), not one giant one. If you can't split, use Hyperdrive with Postgres.
- If two requests must never step on each other, put the logic in a Durable Object. That's what they're for.
Most real apps use three or four of these together. A typical one of mine: D1 for app data, R2 for uploads, KV for feature flags and cached API responses, and a Durable Object for anything live.
D1
D1 is Cloudflare's managed SQLite database. You get real SQL, transactions, indexes, and migrations, with no connection management because it's a binding.
Setup
npx wrangler d1 create my-app
npx wrangler d1 migrations create my-app initWrite your schema into the generated migration file, then:
npx wrangler d1 migrations apply my-app --local
npx wrangler d1 migrations apply my-app --remoteQuerying
// One row
const user = await env.DB.prepare("SELECT * FROM users WHERE email = ?").bind(email).first();
// Many rows
const { results } = await env.DB.prepare("SELECT * FROM posts WHERE status = ? ORDER BY created_at DESC LIMIT 20")
.bind("published")
.all();
// Several statements in one round trip, run as a transaction
await env.DB.batch([
env.DB.prepare("INSERT INTO orders (id, user_id) VALUES (?, ?)").bind(orderId, userId),
env.DB.prepare("UPDATE users SET order_count = order_count + 1 WHERE id = ?").bind(userId),
]);Always use .bind(). Never interpolate values into SQL strings.
Drizzle
For anything with more than a few tables I use Drizzle. It has first-class D1 support, generates migrations, and gives you fully typed queries:
import { drizzle } from "drizzle-orm/d1";
import { posts } from "./schema";
import { eq } from "drizzle-orm";
const db = drizzle(env.DB);
const published = await db.select().from(posts).where(eq(posts.status, "published"));Read replication
D1 can replicate your database to read replicas around the world. To use them safely, go through the Sessions API, which guarantees each session sees its own writes:
const session = env.DB.withSession(request.headers.get("x-d1-bookmark") ?? "first-unconstrained");
const result = await session.prepare("SELECT * FROM products").all();
// Hand the bookmark back to the client so its next request stays consistent
response.headers.set("x-d1-bookmark", session.getBookmark() ?? "");Don't combine read replicas with Smart Placement pulling everything toward the primary. Pick one strategy.
How D1 actually behaves
This is the part people miss. Each D1 database is backed by a single Durable Object, which means each database is single-threaded and processes one query at a time. Throughput is a function of how fast your queries are. A 1 ms indexed lookup gives you lots of headroom; a 200 ms full-table scan turns into "database overloaded" errors under load.
So:
- Index the columns you filter and sort on. It's the difference between reading 5 rows and reading 500,000, and you're billed per row read.
- Check `meta.rows_read` on query results while developing. It tells you exactly what a query will cost.
- Batch writes and chunk big jobs (1,000 rows at a time is a good starting point).
Limits and pricing
- 10 GB per database on paid (500 MB on free), and that ceiling can't be raised. Up to 50,000 databases per account on paid.
- Time Travel: point-in-time restore for 30 days on paid (7 on free).
wrangler d1 time-travel restorehas saved me once already. - Pricing is per row read, per row written, and storage. The paid plan includes a very large monthly allowance of rows read and tens of millions of rows written; most apps never leave it. No egress charges.
D1 is my default database for new projects. The day it's the wrong choice is the day you need one big shared dataset past 10 GB or heavy concurrent writes, and that's when Hyperdrive plus Postgres comes in.
R2
R2 is S3-compatible object storage with one feature that changes the economics: no egress fees. You pay for storage and operations, never for bandwidth out. For anything that serves files to users, that's a big deal.
From a Worker
// Upload
await env.BUCKET.put(`uploads/${id}.jpg`, request.body, {
httpMetadata: { contentType: "image/jpeg" },
customMetadata: { userId },
});
// Download (streams, never buffers the whole file)
const obj = await env.BUCKET.get(`uploads/${id}.jpg`);
if (!obj) return new Response("Not found", { status: 404 });
const headers = new Headers();
obj.writeHttpMetadata(headers);
headers.set("etag", obj.httpEtag);
return new Response(obj.body, { headers });
// List
const { objects, truncated, cursor } = await env.BUCKET.list({ prefix: "uploads/", limit: 100 });Stream bodies straight through. Don't await obj.arrayBuffer() on a large file; you'll hit the 128 MB memory limit.
From anywhere else (S3 API)
Because R2 speaks S3, existing tools just work: the AWS SDK, rclone, Restic, Litestream, any backup tool. Create an R2 API token, then point the client at https://<account_id>.r2.cloudflarestorage.com with region auto.
Presigned URLs for direct uploads
For big uploads, don't proxy the bytes through your Worker. Generate a presigned URL and let the browser upload straight to R2:
import { AwsClient } from "aws4fetch";
const r2 = new AwsClient({ accessKeyId: env.R2_KEY_ID, secretAccessKey: env.R2_SECRET });
const url = new URL(`https://${env.ACCOUNT_ID}.r2.cloudflarestorage.com/my-bucket/${key}`);
url.searchParams.set("X-Amz-Expires", "600");
const signed = await r2.sign(new Request(url, { method: "PUT" }), { aws: { signQuery: true } });
return Response.json({ uploadUrl: signed.url });Remember to set a CORS policy on the bucket for browser uploads. For private downloads served through your own Worker, see the HMAC-signed URLs chapter.
Public buckets and custom domains
Connect a custom domain to a bucket and it's served through Cloudflare's cache, which is how I serve images and downloads. The r2.dev URL is for testing only; it's rate-limited and isn't meant for production traffic.
Event notifications and lifecycle rules
- Event notifications send a message to a Queue whenever an object is created or deleted. That's the clean way to trigger thumbnailing, virus scanning, or indexing.
- Lifecycle rules expire temporary uploads, or move old objects to Infrequent Access storage automatically.
- Bucket locks enforce retention for compliance.
Migrating in
Super Slurper copies an entire S3 (or GCS, or S3-compatible) bucket into R2 from the dashboard. Sippy does it lazily, copying objects on first request so you can migrate with zero downtime.
Pricing
Storage is cheap, writes (Class A) cost more than reads (Class B), and egress is free. There's a 10 GB-a-month free tier. If you're serving a lot of files from S3 today, run the numbers; the egress savings alone usually justify the move.
Beyond files: R2 as a data lake
R2 has grown into the base layer of Cloudflare's data platform: R2 Data Catalog (managed Apache Iceberg), R2 SQL for querying it, and as of Birthday Week 2026, Cloudflare Basin, which bundles ingest, catalog, and query into one product. If you're doing analytics on large datasets, that's worth a look before standing up a warehouse.
KV
Workers KV is a global key-value store optimized for one thing: very fast reads of data that doesn't change often. Writes go to central storage and propagate outward; reads are served from cache close to the user.
Usage
// Write, optionally with an expiration
await env.CONFIG.put("feature:new-checkout", "true");
await env.CACHE.put(`api:${url}`, JSON.stringify(data), { expirationTtl: 3600 });
// Read
const enabled = (await env.CONFIG.get("feature:new-checkout")) === "true";
const cached = await env.CACHE.get(`api:${url}`, "json");
// Read with longer edge caching for hot keys
const settings = await env.CONFIG.get("site-settings", { type: "json", cacheTtl: 300 });
// List keys by prefix
const { keys } = await env.CONFIG.list({ prefix: "feature:" });What it's good for
- Feature flags and site configuration
- Cached responses from slow third-party APIs
- Session lookups, API key lookups, redirect tables
- Anything you read on almost every request
What it's not good for
KV is eventually consistent. A write can take up to about 60 seconds to show up everywhere. Two requests in different locations can read different values. And you can only write the same key about once per second.
That rules out counters, inventory, locks, and anything where "read your own write" matters. Use D1 or a Durable Object for those. (Rate limiting is the classic borderline case; it gets its own chapter later.)
KV Instant (beta)
On October 1, 2026 Cloudflare opened up Quicksilver (the internal store nearly every Cloudflare request reads from) as a new KV mode called KV Instant. Same API (get, put, list, delete), but writes replicate globally in a few hundred milliseconds and p99 reads drop to under 2 ms.
The catch is the envelope: each Instant namespace is small (around 1 MB and 10,000 keys), writes are throttled per namespace, metadata isn't supported, and storage is priced high while reads are priced low. It's purpose-built for small, hot, rarely-changed configuration, like a feature flag that has to flip at exactly 10:00 during a launch. It's in private beta as of this writing.
Pricing
KV is included on both plans. Free gets 100,000 reads and 1,000 writes a day; paid includes 10 million reads and 1 million writes a month. Writes are about ten times the price of reads, which is a good hint about how it's meant to be used.
Hyperdrive
Sometimes you already have a Postgres or MySQL database: on Neon, Supabase, PlanetScale, RDS, or a droplet. Hyperdrive lets Workers use it without the usual pain.
The problem it solves
Opening a database connection takes several round trips (TCP, TLS, auth). From a Worker running far from your database, that's easily 100+ ms before your first query, on every request, and every request burns a connection your database has a limited supply of.
Hyperdrive keeps a pool of warm connections close to your database, sets up the client side at the edge in one round trip, and can cache read query results. You change one connection string and keep your existing driver and ORM.
Setup
npx wrangler hyperdrive create my-db --connection-string="postgres://user:pass@host:5432/db"{
"hyperdrive": [{
"binding": "HYPERDRIVE",
"id": "<id>",
"localConnectionString": "postgres://user:pass@localhost:5432/db"
}]
}Postgres
import postgres from "postgres";
export default {
async fetch(request, env, ctx) {
const sql = postgres(env.HYPERDRIVE.connectionString, { max: 5, fetch_types: false });
const users = await sql`SELECT id, name FROM users LIMIT 10`;
ctx.waitUntil(sql.end());
return Response.json(users);
},
};Creating a client per request is fine; Hyperdrive holds the real pool. Drizzle works the same way (drizzle(postgres(env.HYPERDRIVE.connectionString))).
MySQL
import { createConnection } from "mysql2/promise";
const conn = await createConnection({
host: env.HYPERDRIVE.host,
user: env.HYPERDRIVE.user,
password: env.HYPERDRIVE.password,
database: env.HYPERDRIVE.database,
port: env.HYPERDRIVE.port,
disableEval: true, // required in Workers
});Caching
Query caching is on by default for read queries, which is great for public data and surprising for anything user-specific. Turn it off per config with --caching-disabled, or create two Hyperdrive configs against the same database: one cached for public reads, one uncached for everything else. Hyperdrive follows Postgres volatility rules, so queries using volatile functions like NOW() aren't cached.
Private databases
If your database isn't on the public internet, put a Cloudflare Tunnel in front of it (or use Workers VPC) and point Hyperdrive at the tunnel hostname, protected with an Access service token. No open ports.
Pricing
Hyperdrive is included on both plans: 100,000 queries a day on free, unlimited on paid. You're still paying for the database itself, of course.
Durable Objects
Durable Objects are the most powerful and most misunderstood thing on the platform. Once they click, you'll find uses for them everywhere.
What they are
A Durable Object is a tiny stateful server with a globally unique name. Every request for "room-42" goes to the same single instance, anywhere in the world. That instance runs single-threaded, keeps state in memory between requests, and has its own private, strongly consistent SQLite database (up to 10 GB per object).
So instead of one big database that everyone contends for, you get millions of small ones, each owning its slice of the world: one per user, per chat room, per document, per tenant, per AI agent.
Defining one
import { DurableObject } from "cloudflare:workers";
export class Counter extends DurableObject<Env> {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
ctx.blockConcurrencyWhile(async () => {
this.ctx.storage.sql.exec(`CREATE TABLE IF NOT EXISTS hits (ts INTEGER)`);
});
}
async increment() {
this.ctx.storage.sql.exec("INSERT INTO hits (ts) VALUES (?)", Date.now());
return this.ctx.storage.sql.exec("SELECT COUNT(*) AS n FROM hits").one().n;
}
}
export default {
async fetch(request, env) {
const stub = env.COUNTER.getByName("homepage");
return Response.json({ count: await stub.increment() });
},
};{
"durable_objects": { "bindings": [{ "name": "COUNTER", "class_name": "Counter" }] },
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["Counter"] }]
}Methods on the class are callable directly over RPC from the stub. Always use new_sqlite_classes for new classes; the older key-value backend is legacy.
Things they're perfect for
- Real-time: chat rooms, multiplayer, live cursors, presence. Use the WebSocket Hibernation API (
ctx.acceptWebSocket()) so idle connections don't bill you for duration. - Coordination: rate limiters, locks, counters, inventory, booking a seat exactly once.
- Per-tenant databases: each customer gets an object with its own SQLite.
- Agents: the Agents SDK is built on Durable Objects. Each agent instance is an object with memory, state, and its own schedule.
Alarms
Each object can set an alarm to wake itself up later:
await this.ctx.storage.setAlarm(Date.now() + 60_000);
async alarm() {
// runs ~60s later, retried automatically on failure
}That's per-entity scheduling without a cron: expire a session, retry a webhook, nudge a user.
Gotchas
- One object is one thread. Great for consistency, a bottleneck if you route everything through a single global object. Shard by a natural key.
- Don't fan out with `idFromName("global")`. If every request hits one object, you've built a very elegant single point of contention.
- Memory state can vanish. Objects get evicted when idle. Anything that matters goes in
ctx.storage. - Location is chosen at creation, near the first request. Use a
locationHintif you know where the users are.
Pricing
SQLite-backed Durable Objects are available on the free plan. On paid you're billed for requests, duration (while the object is active in memory), and storage. Hibernating WebSockets are what keep the duration bill reasonable for real-time apps.
Queues
Queues give you guaranteed, at-least-once message delivery between Workers. It's the right tool any time work doesn't have to happen inside the request: sending email, calling a slow API, processing an upload, fanning out webhooks.
Producer and consumer
{
"queues": {
"producers": [{ "binding": "JOBS", "queue": "jobs" }],
"consumers": [{
"queue": "jobs",
"max_batch_size": 25,
"max_batch_timeout": 5,
"max_retries": 5,
"dead_letter_queue": "jobs-dlq"
}]
}
}// Producer: anywhere in your app
await env.JOBS.send({ type: "send-welcome", userId });
await env.JOBS.sendBatch(users.map((u) => ({ body: { type: "digest", userId: u.id } })));
// Consumer: the same Worker or a different one
export default {
async queue(batch: MessageBatch<Job>, env: Env) {
for (const msg of batch.messages) {
try {
await handle(msg.body, env);
msg.ack();
} catch (err) {
msg.retry({ delaySeconds: 30 });
}
}
},
};Patterns
- Ack or retry per message, not per batch, so one bad message doesn't redeliver twenty good ones.
- Always configure a dead letter queue. Messages that fail
max_retriestimes land there instead of disappearing. - Make handlers idempotent. At-least-once means you will occasionally see a message twice.
- Delay messages with
send(body, { delaySeconds })for simple "do this in 10 minutes" scheduling. - Pull consumers let something outside Cloudflare (a server, a container) pull messages over HTTP.
Queues vs Workflows
If the job is one step ("send this email"), use a Queue. If it's several steps that each need retries and you need to know where it got to ("charge the card, then provision the account, then wait for the user to verify, then send the welcome email"), use a Workflow. Lots of systems use both: a Queue to absorb a spike, and a consumer that starts Workflows.
Pricing
Since February 2026, Queues is on the free plan too, with message retention capped at 24 hours (14 days on paid). Paid pricing is per operation, and each write, read, and delete counts.
Workflows
Workflows is Cloudflare's durable execution engine. You write a multi-step process as ordinary async code, and the platform guarantees it runs to completion: each step's result is persisted, failed steps retry on their own, and the whole thing can sleep for days and pick up where it left off.
If you've used Temporal or Inngest, it's that idea, built into Workers.
A workflow
import { WorkflowEntrypoint, WorkflowStep, WorkflowEvent } from "cloudflare:workers";
type Params = { userId: string; email: string };
export class Onboarding extends WorkflowEntrypoint<Env, Params> {
async run(event: WorkflowEvent<Params>, step: WorkflowStep) {
const account = await step.do("create account", async () => {
return createAccount(event.payload.userId, this.env);
});
await step.do(
"send welcome email",
{ retries: { limit: 5, delay: "30 seconds", backoff: "exponential" }, timeout: "2 minutes" },
async () => sendEmail(event.payload.email, "welcome", this.env),
);
const verified = await step.waitForEvent<{ ok: boolean }>("wait for email verification", {
type: "email-verified",
timeout: "3 days",
});
await step.sleep("give them a day", "1 day");
await step.do("send getting-started tips", async () => {
return sendEmail(event.payload.email, "tips", this.env);
});
}
}{ "workflows": [{ "name": "onboarding", "binding": "ONBOARDING", "class_name": "Onboarding" }] }Starting and signaling
const instance = await env.ONBOARDING.create({ id: `onboard-${userId}`, params: { userId, email } });
// Later, from the verification link handler:
const wf = await env.ONBOARDING.get(`onboard-${userId}`);
await wf.sendEvent({ type: "email-verified", payload: { ok: true } });
// Check on it
const status = await wf.status();Using a deterministic instance ID (onboard-${userId}) makes creation idempotent and makes it easy to find the instance later.
Rules that keep you out of trouble
- Everything with side effects goes inside `step.do()`. Code outside a step can re-run when the workflow resumes.
- State lives in step return values. Don't rely on variables set outside steps surviving a sleep. Return what you need and keep each return value under 1 MiB.
- Name steps deterministically. The name is how the engine matches up cached results on replay. No timestamps or random values in step names.
- Steps should be idempotent. A step can retry after doing its work but before its result was saved.
- Throw `NonRetryableError` for failures that retrying won't fix.
- Always `await` steps.
Limits
Paid accounts get 10,000 steps per instance by default (configurable up to 25,000); free is 1,024. Sleeping and waiting instances don't count against concurrency, so you can have huge numbers of workflows parked waiting on events. waitForEvent can wait up to a year. Completed instance state is kept for 30 days on paid.
Cloudflare rebuilt the Workflows control plane during Agents Week in April 2026, mostly to support much higher scale for agent workloads. If you tried Workflows in its 2024 beta and bounced off the limits, it's worth another look.
Where I use them
Onboarding sequences, payment flows, anything that waits on a human or a webhook, multi-step AI pipelines (fetch, summarize, classify, store), and long-running data jobs that need to survive a deploy.
Workers Analytics Engine
Analytics Engine is a time-series event store you write to from a Worker and query with SQL. It's built for the high-cardinality, high-volume data you'd never want to put in D1: per-request metrics, usage metering, feature analytics, per-customer API counts.
Writing
{ "analytics_engine_datasets": [{ "binding": "EVENTS", "dataset": "api_events" }] }env.EVENTS.writeDataPoint({
indexes: [customerId], // the sampling key
blobs: [request.method, url.pathname, country], // strings
doubles: [durationMs, responseBytes, 1], // numbers
});writeDataPoint doesn't return a promise and doesn't block your response. Fire and forget.
Querying
Query over HTTP with a SQL dialect. Columns are positional (blob1, double1, and so on), so keep a note of what's what:
SELECT
blob2 AS path,
SUM(_sample_interval) AS requests,
quantileWeighted(0.95)(double1, _sample_interval) AS p95_ms
FROM api_events
WHERE timestamp > NOW() - INTERVAL '1' DAY
GROUP BY path
ORDER BY requests DESC
LIMIT 20curl "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/analytics_engine/sql" \
-H "Authorization: Bearer $API_TOKEN" -d @query.sqlSampling, and why _sample_interval matters
At high volumes Analytics Engine samples data per index. That's what keeps it cheap and fast, but it means you must weight by `_sample_interval`. COUNT(*) gives you wrong numbers; SUM(_sample_interval) gives you right ones. Pick your index carefully: it's the unit that gets sampled together, so the customer ID is usually a good choice for per-customer metering.
Limits
Up to 20 blobs, 20 doubles, and one index per data point, up to 250 data points per invocation, and three months of retention. If you need longer, roll data up on a schedule into D1 or R2.
Grafana
Analytics Engine exposes a ClickHouse-compatible interface, so you can point Grafana at it and build dashboards without writing a frontend.
Pricing
Pricing is per data point written and per read query, with free daily allowances. At the time of writing, Cloudflare's docs still say usage isn't being billed yet, so check the pricing page before you rely on it for something huge.
Workers AI
Workers AI runs open models on Cloudflare's GPUs, called from a Worker through a binding. No API keys, no separate account, and inference happens on the same network as your code.
Basic usage
{ "ai": { "binding": "AI" } }const answer = await env.AI.run("@cf/meta/llama-3.3-70b-instruct-fp8-fast", {
messages: [
{ role: "system", content: "You are a concise assistant." },
{ role: "user", content: "Summarize this support ticket: ..." },
],
});Pass stream: true and you get a server-sent event stream you can return directly as a response.
What's in the catalog
- Text generation: Llama, Mistral, Qwen, Gemma, DeepSeek distills, and since March 2026, large frontier-class open models built for agentic work (Kimi K2.5 was the first)
- Embeddings: BGE and friends, for semantic search with Vectorize
- Speech: Whisper for speech-to-text, plus text-to-speech and real-time voice models
- Images: Flux and Stable Diffusion variants for generation, plus vision models
- Utility: translation, classification, summarization, reranking
The catalog changes constantly, so browse it in the dashboard rather than trusting any list (including this one).
Calling third-party models through the same binding
Since April 2026, env.AI.run() can also call third-party models from providers like OpenAI and Anthropic, routed through AI Gateway. That means one binding and one code path whether a model runs on Cloudflare's GPUs or someone else's, and swapping models is a one-line change. More on that in the next chapter.
Structured output and tools
Many models support JSON mode (response_format: { type: "json_schema", json_schema: {...} }) and function calling. For anything beyond a single call, use the Vercel AI SDK with the workers-ai-provider package, or the Agents SDK. Both handle tool loops and streaming for you.
Where it fits
I reach for Workers AI for the high-volume, latency-sensitive, "good enough" tasks: embeddings, classification, extraction, summarizing, transcription, and routing. For the hardest reasoning I still call a frontier model, but through AI Gateway, so it's all observable in one place.
Pricing
Billed in "neurons," Cloudflare's unit for GPU compute, with a free daily allocation on both plans. Smaller models are dramatically cheaper per token than large ones, which is a good reason to route easy tasks to small models.
AI Gateway
AI Gateway is a proxy that sits between your app and any AI provider. You change the base URL your SDK calls, and in return you get logging, caching, rate limiting, retries, fallbacks, cost tracking, and guardrails, across every provider, in one dashboard.
If you're shipping anything with LLM calls in production, this is the cheapest observability you'll ever add.
Setup
Create a gateway in the dashboard, then point your existing SDK at it:
import OpenAI from "openai";
const openai = new OpenAI({
apiKey: env.OPENAI_API_KEY,
baseURL: `https://gateway.ai.cloudflare.com/v1/${env.ACCOUNT_ID}/my-gateway/openai`,
});The same pattern works for Anthropic, Google, Mistral, Groq, Bedrock, and a long list of others. There's also an OpenAI-compatible /compat endpoint that lets you call any supported provider's model with the OpenAI SDK by changing just the model string.
From a Worker, you can skip URLs entirely and pass a gateway to the AI binding:
await env.AI.run("@cf/meta/llama-3.3-70b-instruct-fp8-fast", { prompt }, { gateway: { id: "my-gateway" } });What you get
- Logs of every request and response, with tokens and cost, filterable by metadata you attach (user ID, feature, tenant)
- Caching of identical requests, which is very effective for repeated classification or embedding calls
- Rate limiting per gateway, so one runaway loop can't drain your OpenAI budget
- Retries and fallbacks: try provider A, fall back to B if it's down or slow
- Guardrails for prompt and response content
- Dynamic routing: define routing rules (A/B splits, fallbacks, per-user budgets) in the dashboard and change them without redeploying
- BYOK and unified billing: store provider keys in the gateway, or skip provider accounts entirely and pay for third-party models through your Cloudflare bill
Where it's going
In 2026 AI Gateway and Workers AI have been merging into one inference layer: one binding and one REST API for Cloudflare-hosted and third-party models, automatic default gateways, shared billing, and failover between providers. Birthday Week 2026 added an Auto Router that classifies each request at the edge and sends it to a suitable (often cheaper) model, a Web Search API for grounding model calls, and User Insights that flag where you're overpaying for a bigger model than the task needs.
My setup
Every LLM call in my projects goes through a gateway, even ones from outside Cloudflare (n8n workflows, server-side scripts, agents). I tag requests with metadata for the project and feature, which makes "what is this costing me, and where?" a dashboard filter instead of a spreadsheet exercise.
Cloudflare Access
Access is the part of Cloudflare's Zero Trust suite that puts a login in front of any app, without you writing auth code. It's how I protect admin panels, staging sites, internal dashboards, and the occasional tool a client needs to reach but the public shouldn't.
How it works
You create an Access application for a hostname or path (say admin.example.com or example.com/admin/*) and attach a policy: "emails ending in @mycompany.com," "these five people," "members of this GitHub org," "this Google Workspace group." Visitors are sent to a Cloudflare login page that uses whatever identity providers you've configured (one-time email PINs, Google, GitHub, Okta, Entra ID, and others) before a request ever reaches your app.
The free Zero Trust plan covers up to 50 users, which is plenty for most small teams.
Verifying Access in your Worker
Access adds a signed JWT to every request it lets through, in the Cf-Access-Jwt-Assertion header. Verify it. If someone finds your Worker's workers.dev URL or the origin directly, the Access check doesn't happen unless your code enforces it.
import { jwtVerify, createRemoteJWKSet } from "jose";
const JWKS = createRemoteJWKSet(new URL(`https://${TEAM}.cloudflareaccess.com/cdn-cgi/access/certs`));
async function requireAccess(request: Request, env: Env) {
const token = request.headers.get("cf-access-jwt-assertion");
if (!token) throw new Response("Unauthorized", { status: 401 });
const { payload } = await jwtVerify(token, JWKS, {
issuer: `https://${TEAM}.cloudflareaccess.com`,
audience: env.ACCESS_AUD,
});
return payload.email as string;
}And disable the workers.dev route ("workers_dev": false) on anything behind Access.
Service tokens for machines
Scripts, cron jobs, and other services can't click through a login page. Create a service token and add a "Service Auth" policy. The caller sends two headers:
curl https://internal.example.com/api/report \
-H "CF-Access-Client-Id: $CLIENT_ID" \
-H "CF-Access-Client-Secret: $CLIENT_SECRET"Tunnels: exposing private services
Cloudflare Tunnel (cloudflared) creates an outbound-only connection from a private machine to Cloudflare, so you can publish an internal app (a self-hosted n8n, a Grafana, a database admin tool) on a hostname with no open inbound ports. Put Access in front of it and you've replaced a VPN for most use cases.
Agents and MCP
Access is increasingly how agents get secure access too. Managed OAuth for Access (April 2026) lets AI agents authenticate to internal apps on a user's behalf instead of using shared service accounts, and Access policies are the standard way to lock down a remote MCP server so only your team's agents can use it.
Turnstile: Stop Bots Without Annoying CAPTCHAs
Every public form eventually gets found by a bot. Turnstile is Cloudflare's answer: a free CAPTCHA replacement that checks visitors with browser signals and lightweight challenges instead of making humans click on traffic lights. Most real people never see a puzzle at all. It works on any site, whether or not it's proxied through Cloudflare.
It has two halves, and both are mandatory: a widget in the browser that produces a token, and a server-side check where your Worker asks Cloudflare whether that token is real. A Turnstile setup without the server check is decoration.
1. Create a widget
In the dashboard, open Turnstile, add a widget, and list every hostname that will serve the form (including localhost and staging if you want). Pick a mode:
- Managed (the right default): Cloudflare decides per visitor, and only shows a checkbox when signals are weak.
- Non-interactive: shows a widget that never asks for interaction.
- Invisible: no visible widget at all.
You get a site key (public, goes in your HTML) and a secret key (server only, goes in wrangler secret put TURNSTILE_SECRET).
2. Add the widget to your form
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<form method="POST" action="/api/contact">
<input name="email" type="email" required />
<textarea name="message" required></textarea>
<div class="cf-turnstile" data-sitekey="YOUR_SITE_KEY" data-action="contact"></div>
<button type="submit">Send</button>
</form>The widget adds a hidden cf-turnstile-response field to the form. Load api.js straight from Cloudflare; don't bundle, proxy, or cache it.
3. Verify on the server
type SiteverifyResult = { success: boolean; "error-codes": string[]; action?: string; hostname?: string };
export async function verifyTurnstile(token: string | null, ip: string | null, env: Env) {
if (!token) return { success: false, "error-codes": ["missing-input-response"] } as SiteverifyResult;
const body = new FormData();
body.append("secret", env.TURNSTILE_SECRET);
body.append("response", token);
if (ip) body.append("remoteip", ip);
const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { method: "POST", body });
return (await res.json()) as SiteverifyResult;
}
// In your handler
const form = await request.formData();
const result = await verifyTurnstile(
form.get("cf-turnstile-response") as string | null,
request.headers.get("CF-Connecting-IP"),
env,
);
if (!result.success || result.action !== "contact") {
return new Response("Verification failed", { status: 403 });
}Checking action (and hostname, if you serve several sites) stops someone from reusing a token minted on a different form.
Rules worth knowing
- Tokens expire after 5 minutes and are single-use. A second check on the same token fails with
timeout-or-duplicate. If your code might retry the verification call, send anidempotency_key(a UUID you generate) so retries return the original result. - Treat a missing token as a failure, not a pass.
- Log the `error-codes`. They tell you whether you're looking at expired tokens, replays, or a misconfigured key.
- Use the test keys in development and CI. Cloudflare publishes dummy site keys and secrets that always pass (or always fail), so your tests never touch real keys.
- For SPAs, render the widget explicitly with
turnstile.render()and callturnstile.reset()after each submit so the next submission gets a fresh token.
Where I put it
Signup, login, password reset, contact forms, waitlists, comment forms, and any "free trial" endpoint that costs me money when abused. Pair it with the rate limiting in the next chapter: Turnstile keeps bots out of the form, and rate limits slow down anyone who gets past it.
Rate Limiting (with KV, and When Not To)
Rate limiting is one of the first things people build on Workers, and KV is usually the first tool they reach for. It works, within limits you should understand. This chapter shows the KV version, then the two better options for when "roughly right" isn't good enough.
The KV version: a fixed-window counter
The idea: for each client and time window, keep a counter in KV with a TTL. If the counter is over the limit, return a 429.
type RateLimitResult = { allowed: boolean; remaining: number; reset: number };
export async function rateLimitKV(
kv: KVNamespace,
id: string, // IP, user ID, API key...
limit: number, // e.g. 20
windowSeconds: number // e.g. 60
): Promise<RateLimitResult> {
const window = Math.floor(Date.now() / 1000 / windowSeconds);
const key = `rl:${id}:${window}`;
const reset = (window + 1) * windowSeconds;
const count = parseInt((await kv.get(key)) ?? "0", 10);
if (count >= limit) return { allowed: false, remaining: 0, reset };
// KV's minimum TTL is 60 seconds
await kv.put(key, String(count + 1), { expirationTtl: Math.max(windowSeconds * 2, 60) });
return { allowed: true, remaining: limit - count - 1, reset };
}Wire it into a Hono middleware:
app.use("/api/login", async (c, next) => {
const ip = c.req.header("CF-Connecting-IP") ?? "unknown";
const rl = await rateLimitKV(c.env.RATE_LIMIT, `login:${ip}`, 10, 300);
c.header("RateLimit-Remaining", String(rl.remaining));
c.header("RateLimit-Reset", String(rl.reset));
if (!rl.allowed) return c.text("Too many attempts, try again later", 429, { "Retry-After": String(rl.reset - Math.floor(Date.now() / 1000)) });
await next();
});Why KV is only approximately right
This is where people get burned, so it's worth being explicit:
- It's eventually consistent. Two requests hitting different data centers can both read
count = 9and both get through. A burst from a botnet spread across locations can go well over the limit. - Read-then-write isn't atomic. Even in one location, concurrent requests race.
- One write per key per second. A client hammering one key can make writes fail outright.
- Writes cost about ten times reads, and this pattern writes on every allowed request.
So KV rate limiting is fine for low-volume, soft limits: login attempts per account, "you can request a magic link 5 times an hour," polite throttling of a free API tier. It's the wrong tool for abuse prevention under load or for anything you bill on.
Better option 1: the Rate Limiting binding
Cloudflare has a built-in rate limiter, backed by the same infrastructure as its WAF rate limiting rules. It's a binding, it adds essentially no latency, and there's no extra charge for it:
{
"ratelimits": [
{ "name": "LOGIN_LIMITER", "namespace_id": "1001", "simple": { "limit": 10, "period": 60 } }
]
}const { success } = await env.LOGIN_LIMITER.limit({ key: `login:${ip}` });
if (!success) return new Response("Too many requests", { status: 429 });The trade-offs: period must be 10 or 60 seconds, and limits are counted per Cloudflare location, so a client routed through several data centers gets the limit in each. It's fast and cheap, a great first line of defense, but deliberately not exact.
Better option 2: a Durable Object
When the count has to be right (per-customer API quotas, anything tied to billing), use a Durable Object keyed by the client. One object per key means one thread and one source of truth, so there's no race:
import { DurableObject } from "cloudflare:workers";
export class RateLimiter extends DurableObject<Env> {
async hit(limit: number, windowMs: number) {
const now = Date.now();
const state = (await this.ctx.storage.get<{ start: number; count: number }>("w")) ?? { start: now, count: 0 };
if (now - state.start > windowMs) { state.start = now; state.count = 0; }
state.count++;
await this.ctx.storage.put("w", state);
return { allowed: state.count <= limit, remaining: Math.max(0, limit - state.count) };
}
}
// In the Worker
const rl = await env.RATE_LIMITER.getByName(`api:${apiKey}`).hit(1000, 60_000);Keep the counter in ctx.storage, not just in memory: objects get evicted when idle, and an in-memory counter resets with them.
Which one I use
- WAF rate limiting rules (dashboard, no code) for blunt protection of whole paths
- The Rate Limiting binding as the default in-code limiter
- KV only for soft, low-volume limits where I already have a namespace handy
- Durable Objects when the number has to be exactly right
And for AI endpoints, add AI Gateway's own rate limits on top, so a runaway loop can't drain a model budget even if it gets past everything else.
HMAC-Signed URLs on Workers
A signed URL is a link that carries its own permission slip: a path, an expiry time, and a signature that only your server could have produced. Anyone holding the link can use it until it expires, and nobody can forge or alter one. No session, no database lookup, no login.
I use them for private file downloads from R2, unsubscribe and one-click action links in emails, time-limited share links, and callback URLs I hand to third-party services.
How it works
- Build a message from the things you want to lock down: the path, an expiry timestamp, and optionally a user ID.
- Sign that message with HMAC-SHA256 and a secret only your Worker knows.
- Put the expiry and signature in the query string.
- When the link comes back, rebuild the message, check the signature, and check the expiry.
Workers have Web Crypto built in, so this needs no dependencies.
The code
const enc = new TextEncoder();
async function hmacKey(secret: string) {
return crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]);
}
function toBase64Url(buf: ArrayBuffer) {
return btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function fromBase64Url(s: string) {
const b64 = s.replace(/-/g, "+").replace(/_/g, "/");
return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
}
export async function signUrl(url: string, secret: string, ttlSeconds: number) {
const u = new URL(url);
const exp = Math.floor(Date.now() / 1000) + ttlSeconds;
u.searchParams.set("exp", String(exp));
const message = `${u.pathname}?exp=${exp}`;
const sig = await crypto.subtle.sign("HMAC", await hmacKey(secret), enc.encode(message));
u.searchParams.set("sig", toBase64Url(sig));
return u.toString();
}
export async function verifyUrl(url: string, secret: string) {
const u = new URL(url);
const exp = Number(u.searchParams.get("exp"));
const sig = u.searchParams.get("sig");
if (!exp || !sig) return false;
if (Math.floor(Date.now() / 1000) > exp) return false;
const message = `${u.pathname}?exp=${exp}`;
// crypto.subtle.verify compares in constant time
return crypto.subtle.verify("HMAC", await hmacKey(secret), fromBase64Url(sig), enc.encode(message));
}Using it: private R2 downloads
// Issue a link (after checking the user is allowed to see the file)
app.get("/api/files/:id/link", async (c) => {
const url = await signUrl(`https://example.com/download/${c.req.param("id")}`, c.env.URL_SIGNING_SECRET, 600);
return c.json({ url });
});
// Serve it
app.get("/download/:id", async (c) => {
if (!(await verifyUrl(c.req.url, c.env.URL_SIGNING_SECRET))) return c.text("Link expired or invalid", 403);
const obj = await c.env.BUCKET.get(`private/${c.req.param("id")}`);
if (!obj) return c.notFound();
const headers = new Headers();
obj.writeHttpMetadata(headers);
return new Response(obj.body, { headers });
});The bucket stays private, the Worker checks the signature, and the link dies after ten minutes.
Getting the details right
- Sign everything that matters. If the file ID, user ID, or action isn't in the signed message, someone can change it. Here the path carries the file ID, so it's covered.
- Always include an expiry. A signed URL without one is a permanent password.
- Use `crypto.subtle.verify`, not
===on two strings. String comparison leaks timing information. - Keep the secret in `wrangler secret put`, and make it long and random (32+ bytes).
- Plan for rotation. Add a
kid(key ID) parameter, keep the old secret around for verification until its links expire, and sign new links with the new one. - Mind caching. If you put Workers Cache in front of these routes, make sure the query string is part of the cache key (it is by default) and that error responses aren't cached.
- Signed links can be shared. That's the point, but it means anything sensitive should also have a short expiry or a one-time-use check in D1.
Signed URLs vs R2 presigned URLs
R2 can presign URLs itself (via the S3 API, as in the R2 chapter). Those send the browser straight to R2, which is ideal for large direct uploads. Your own HMAC links route through your Worker, which lets you log downloads, enforce your own rules, rename files with Content-Disposition, and use your own domain. I use presigned URLs for uploads and HMAC links for downloads.
Transactional Email from Workers
For years the answer to "how do I send email from a Worker" was "call Postmark, Resend, or SendGrid over HTTP." That still works, but Cloudflare now has its own: Email Service, which handles both sending (Email Sending) and receiving (Email Routing). You can send from a Worker with a binding, from anywhere with a REST API, and, since June 2026, from any app that speaks SMTP.
Email Sending is in beta and needs the Workers Paid plan. Email Routing (receiving) is free.
1. Set up your sending domain
In the dashboard, go to Email Service, add the domain you want to send from (it needs to be on Cloudflare), and let it add the DNS records. Because Cloudflare runs your DNS, SPF, DKIM, and the rest are set up for you, and messages are DKIM-signed automatically. That's normally the fiddliest part of transactional email, and here it's a button.
2. Send from a Worker (the binding)
{ "send_email": [{ "name": "EMAIL" }] }await env.EMAIL.send({
to: "user@example.com",
from: "welcome@yourdomain.com",
subject: "Welcome aboard",
html: "<h1>Welcome!</h1><p>Thanks for signing up.</p>",
text: "Welcome! Thanks for signing up.",
});No API key to store or leak: the binding is the credential. That's my favorite thing about it.
3. Send from anything else (SMTP)
For apps that already speak SMTP (a Node server, a Python script, n8n, WordPress, a legacy app), Email Service accepts authenticated SMTP submission:
- Host:
smtp.mx.cloudflare.net - Port:
465(implicit TLS) - Username: the literal string
api_token - Password: a Cloudflare API token with the Email Sending: Edit permission
With Nodemailer:
import nodemailer from "nodemailer";
const transport = nodemailer.createTransport({
host: "smtp.mx.cloudflare.net",
port: 465,
secure: true,
auth: { user: "api_token", pass: process.env.CLOUDFLARE_EMAIL_TOKEN },
});
await transport.sendMail({
from: "alerts@yourdomain.com",
to: "you@example.com",
subject: "Nightly job finished",
text: "All 1,204 records synced.",
});Or test it from a terminal with curl:
curl --ssl-reqd --url "smtps://smtp.mx.cloudflare.net:465" \
--user "api_token:$CLOUDFLARE_EMAIL_TOKEN" \
--mail-from "welcome@yourdomain.com" --mail-rcpt "user@example.com" \
--upload-file mail.txtSMTP, the REST API, and the binding all go through the same pipeline, with the same limits, signing, and dashboard logs. Inside a Worker, use the binding; SMTP is for everything outside one.
4. Receive and reply
Email Routing sends incoming mail for an address to a Worker's email() handler, which means a Worker can answer email too:
export default {
async email(message, env, ctx) {
if (message.to.includes("support@yourdomain.com")) {
await message.forward("team@yourdomain.com");
await env.EMAIL.send({
to: message.from,
from: "noreply@yourdomain.com",
subject: "We got your message",
text: "Thanks, we'll get back to you within a day.",
});
}
},
} satisfies ExportedHandler<Env>;That's the building block for support inboxes, "reply to this email to post a comment," and agents that work over email.
Patterns that keep email reliable
- Send from a Queue or Workflow step, not inline. A slow or failed send shouldn't fail the user's signup. Push a message, and let a consumer send with retries.
- Make sends idempotent. Retries happen; record what you sent in D1 so a retry doesn't double-send a receipt.
- Always include a text version alongside HTML.
- Use a subdomain for transactional mail (
mail.yourdomain.comor similar) so marketing mail and transactional mail don't share a reputation. - Sign your unsubscribe and action links with the HMAC approach from the previous chapter.
- Watch the logs in the Email Service dashboard for bounces and failures.
If you need marketing campaigns, templates edited by non-developers, or very high volume today, a dedicated provider is still the safer bet. For the welcome emails, password resets, receipts, and alerts most apps send, Email Service means one less vendor and one less API key.
Workers Cache: A Cache in Front of Your Worker
When your Worker *is* your app (an Astro site, a Next.js app, an API), there's nothing in front of it. Every request runs your code, even when the response is identical to the one you rendered a second ago.
Workers Cache, launched in July 2026, fixes that. It puts a tiered cache directly in front of your Worker. On a cache hit, your code doesn't run at all: Cloudflare returns the stored response, and you pay no CPU time for it. It's available on every plan.
Turning it on
One line in wrangler.jsonc:
{
"name": "my-app",
"main": "src/index.ts",
"compatibility_date": "2026-10-01",
"cache": { "enabled": true }
}No zone settings, no cache rules. Workers Cache belongs to the Worker and ignores zone-level Cache Rules, Page Rules, and the file-extension list entirely. It follows the Worker wherever it runs: custom domain, workers.dev, previews, and service bindings.
You control it with headers
The cache does whatever your response headers say:
return new Response(html, {
headers: {
"Content-Type": "text/html",
"Cache-Control": "public, max-age=300, stale-while-revalidate=86400",
"Cache-Tag": "posts,post:my-first-post",
},
});- `max-age` is how long the response is fresh. During that window, the Worker doesn't run.
- `stale-while-revalidate` is the magic part. After
max-age, Cloudflare keeps serving the stale copy while your Worker refreshes it in the background (you'll seeCf-Cache-Status: UPDATING). Nobody waits for a render, so a server-rendered site feels static. - `private` skips the cache. Use it on anything personalized.
- `Vary` creates a separate cached copy per value of the listed request headers.
Vary: *disables caching.
Purging by tag
Tag responses with Cache-Tag, then purge from inside the Worker when data changes:
// After saving an edit to the post
await ctx.cache.purge({ tags: ["post:my-first-post", "posts"] });You can also purge by path prefix or purge everything. Purges are scoped to your Worker, so you can't accidentally wipe the cache for the rest of your zone.
Caching between Workers and entrypoints
This is the clever part. The cache sits in front of every entrypoint, including calls between Workers over service bindings, and you choose which ones cache:
{
"cache": { "enabled": true },
"exports": {
"default": { "type": "worker", "cache": { "enabled": false } },
"Renderer": { "type": "worker", "cache": { "enabled": true } }
}
}The pattern: an outer entrypoint that runs on every request (auth, A/B assignment, header normalization) and is never cached, calling an inner entrypoint that does the expensive rendering and is cached. You get per-request logic and a cache in front of the slow part, in one Worker. The cache key includes the entrypoint, path, query string, and ctx.props, so you can partition cached entries per tenant by passing props.
With Astro
The Astro Cloudflare adapter has a cache provider for Workers Cache. You turn it on with cacheCloudflare() from @astrojs/cloudflare/cache and set per-route rules (max age, stale-while-revalidate, tags), then purge with Astro's cache invalidation helper. It's still an experimental Astro feature, so check the adapter docs for the current config shape before you copy anything.
Gotchas
- Be explicit about caching. Don't rely on defaults: set
Cache-Controlon every response, and mark anything user-specificprivate. The classic mistake is a logged-in user (or an editor) being served the cached anonymous page. - Don't cache errors by accident. Send
no-storeon 4xx and 5xx responses you don't want stuck in the cache. - Watch for variant explosion.
Varyon a header with lots of values (likeUser-Agent) creates one cached copy per value. Normalize headers in an outer entrypoint first. - Billing changes when you turn it on. Cache hits still count as a request (with no CPU). But with caching enabled, requests that used to be free, like static asset hits and Worker-to-Worker calls, now count as standard requests, because each one consults the cache. For most server-rendered apps the CPU savings win easily, but check your numbers.
- Size limit: at launch, responses up to 512 MB are cacheable on every plan, with per-plan limits promised later.
Workers Cache vs the Cache API
The older Cache API (caches.default) is still there. It's a per-data-center cache you call from inside your Worker, so your code always runs first. Workers Cache sits in front, so on a hit your code never runs. For whole responses, use Workers Cache; for caching individual subrequests or computed fragments inside a request, the Cache API (or KV) is still the right tool.
How the Pages Build Cache Works
Every push to a Cloudflare Pages project triggers a fresh build in a clean environment: reinstall every dependency, rebuild every page. The build cache makes that faster by saving some directories at the end of one build and restoring them at the start of the next.
It's off by default, it's easy to turn on, and it has one gotcha that's worth knowing before it costs you an afternoon.
Turning it on
In the dashboard, open your Pages project, go to Settings > Build > Build cache, and select Enable. It requires the V2 build system or later. The first build after enabling seeds the cache; every build after that restores from it.
The same screen has a Clear Cache button, which is the first thing to try when a build behaves strangely.
What it caches
The build cache doesn't save your whole project. It saves two specific things.
Your package manager's global cache, so installs are faster:
- npm:
.npm - yarn:
.cache/yarn - pnpm:
.pnpm-store - bun:
.bun/install/cache
Your framework's cache directory, detected automatically:
- Astro:
node_modules/.astro - Next.js:
.next/cache - Nuxt:
node_modules/.cache/nuxt - SvelteKit:
node_modules/.cache/imagetools - Gatsby:
.cacheandpublic - Docusaurus:
node_modules/.cache,.docusaurus, andbuild - Eleventy:
.cache - Hugo:
.cache(pass--cacheDir=$PWD/.cacheto Hugo, since it doesn't use that folder by default)
That's the whole list.
The gotcha: it's an allow-list
Cloudflare only restores those exact folders, and you can't add your own. Anything your build writes elsewhere is thrown away after every build.
This bites when a tool in your build has its own cache. A common example is Open Graph image generation: a plugin renders an image per post and caches them so unchanged posts aren't redrawn. If that cache lives in, say, node_modules/.og-cache, it isn't on the list, so every deploy regenerates every image. On a site with hundreds of posts, that's minutes per build.
The fix is to put your tool's cache inside a folder Cloudflare keeps. For Astro, that's node_modules/.astro:
// Point the plugin's cache inside Astro's cached folder
getImageOptions: (path, page) => ({
cacheDir: "./node_modules/.astro/og-images",
title: page.title,
}),Now the cache rides along with Astro's, and after one warm-up build only new and changed posts get regenerated. The same trick works for any tool that lets you choose a cache directory: image optimization, remote content fetches, search index builds.
Limits
- Retention: cache artifacts are deleted seven days after they were last read (or seven days after creation if never read). A project that doesn't build for a week starts cold.
- Storage: 10 GB per project. Past that, the least recently read artifacts are evicted first.
Pages vs Workers Builds
This cache is a Pages feature. New projects should generally deploy as Workers with static assets and use Workers Builds for CI (see the Workers and Astro chapters), which has its own build settings. If you're still on Pages, turn the build cache on, check where your build tools write their caches, and move them inside your framework's cached folder.
Beyond the Core: The Rest of the Platform
The products above are the foundation. These are the ones I reach for when a project needs something more, roughly in order of how often I use them.
Agents SDK
The agents package gives you a base class for building stateful AI agents on Durable Objects: each agent instance has its own SQLite state, WebSocket connections to clients, scheduling (this.schedule()), tool calling, and built-in support for being an MCP server or client. If you're building an agent that needs to remember things, run on a schedule, and talk to users in real time, it saves weeks. A batteries-included next edition was previewed in April 2026 alongside managed Agent Memory.
Containers and Sandboxes
When something genuinely needs a Linux environment (ffmpeg, a Python library, a headless tool, a language Workers can't run), Containers let you run a Docker image that's controlled from a Worker and started on demand. Sandboxes build on Containers with an SDK for running untrusted code: execute commands, clone repos, write files, expose a preview URL, snapshot and restore. Both went GA in April 2026 with active-CPU pricing, so you only pay for cycles actually used. It's paid-plan only.
For lighter-weight isolation, Dynamic Workers spin up a fresh isolate (with its own Durable Object storage if needed) to run AI-generated code in milliseconds. That's the pattern EmDash uses for sandboxed plugins.
Vectorize and AI Search
Vectorize is the vector database: store embeddings from Workers AI, query by similarity, filter by metadata. AI Search (formerly AutoRAG) is the managed version of the whole pipeline: point it at an R2 bucket or website and it chunks, embeds, indexes, and answers queries with citations. If you need RAG and don't want to build it, start with AI Search.
Browser Run
Formerly Browser Rendering. A managed headless Chrome you drive from a Worker with Puppeteer or Playwright, or via simple REST endpoints for screenshots, PDFs, markdown, and scraping. It's great for OG image generation, PDF invoices, and giving agents a browser. Live View and human-in-the-loop handoff were added in 2026.
Images and Stream
Images resizes, optimizes, and transforms on the fly (including images stored in R2) via URL parameters or a binding. Stream handles video upload, encoding, and adaptive playback.
Private networking: Workers VPC and Mesh
Workers VPC lets Workers reach services in private networks (AWS VPCs, on-prem) through Tunnels, without exposing them. Cloudflare Mesh (April 2026) extends private networking to users, nodes, and agents.
Secrets Store and Workers Builds
Secrets Store is account-level secret management, so one API key can be shared across Workers instead of being copied into each. Workers Builds connects a GitHub or GitLab repo for push-to-deploy with preview URLs per branch.
The new cf CLI
Cloudflare is rolling out cf, a unified CLI that mirrors the entire Cloudflare API with JSON output, designed to be friendly for both humans and coding agents. Wrangler isn't going anywhere for Workers development, but cf is the one to watch for everything else.
Putting It Together: A Reference Architecture
Here's how the pieces combine in a typical SaaS app I'd build today. Picture a small B2B product: customers sign up, upload files, the app processes them with AI, and there's an admin panel for the team.
The shape
- App Worker (Astro or Hono + static assets): serves the frontend and the API. Smart Placement on, Workers Cache in front of public pages.
- D1: users, accounts, files metadata, billing state. One database for the app, or one per tenant if tenants are large.
- R2: uploaded files, generated reports. Browser uploads go direct via presigned URLs; downloads go through HMAC-signed links.
- KV: feature flags, plan limits, cached third-party lookups.
- Queues: R2 event notifications drop a message when a file lands; a consumer kicks off processing.
- Workflows: the processing pipeline (extract text, classify with Workers AI, summarize with a frontier model, store results, notify the user), with retries per step.
- AI Gateway: every model call, Workers AI or third-party, goes through one gateway, tagged by tenant.
- Durable Objects: a per-account object handles real-time progress updates over hibernating WebSockets and enforces exact per-tenant API quotas.
- Analytics Engine: per-tenant usage events for metering and dashboards.
- Email Service: welcome emails, password resets, and "your report is ready" notifications, sent from Queue consumers.
- Access:
admin.example.comand the internal tooling, verified in code, with service tokens for scripts. - Turnstile and the Rate Limiting binding: on signup, login, and contact forms.
The flow for one upload
- Browser asks the API for a presigned URL and uploads straight to R2.
- R2 publishes an object-created event to a Queue.
- The consumer starts a Workflow with the object key as a deterministic instance ID.
- Each Workflow step does one thing and retries on its own; the AI steps go through AI Gateway.
- Steps report progress to the account's Durable Object, which pushes it to any connected browser.
- Results are written to D1, a usage event goes to Analytics Engine, and the user gets an email with a signed link to the result.
Nothing in that list needs a server, a VPC, a Kubernetes cluster, or a Redis instance. That's the point.
Bill of materials
For a small-to-medium app, all of this fits comfortably in the $5 Workers Paid plan plus modest usage charges, with AI inference and any third-party model costs usually the biggest line item. Egress is free throughout.
Costs, Limits, and Gotchas
A consolidated list of the things that have bitten me or people I've helped.
Costs
- Start on Workers Paid ($5/month). The free plan's 10 ms CPU limit and daily caps are fine for experiments, but production on free is false economy.
- CPU time is the thing to watch, not requests. Awaiting I/O is free; parsing a 5 MB JSON blob isn't.
- Set `limits.cpu_ms` to cap any single invocation's CPU.
- D1 bills per row read. One missing index can turn a cheap query into an expensive one. Watch
rows_read. - KV writes cost about ten times reads. If you're writing KV on every request (rate limiting, say), you're probably using the wrong product.
- Durable Objects bill for duration while active. Use WebSocket Hibernation, and don't keep objects busy with
setInterval-style loops. - Workers Cache changes request billing. Hits save CPU, but static asset and Worker-to-Worker requests become billable once caching is on.
- Set budget alerts in the billing dashboard. Cloudflare's pricing is generous but usage-based.
Limits that surprise people
- 128 MB memory per Worker. Stream large files; don't buffer them.
- D1's 10 GB per database is a hard ceiling. Design for multiple databases or use Hyperdrive.
- D1 is single-threaded per database. Slow queries become overload errors under concurrency.
- KV is eventually consistent, up to about 60 seconds. Never use it for counters or locks.
- The Rate Limiting binding counts per location and only supports 10- or 60-second windows.
- Subrequest limits apply per invocation. A Worker that fans out to thousands of fetches should hand that to a Queue.
- Workflows step results must stay under 1 MiB. Store big intermediates in R2 and return the key.
- Turnstile tokens last 5 minutes and work once.
Gotchas
- `workers.dev` bypasses your Access and WAF rules. Disable it on anything protected.
- Bindings differ between environments.
[env.staging]blocks don't inherit bindings from the top level. Declare them explicitly. - Secrets go in `wrangler secret put`, not
vars. Use.dev.varslocally and keep it out of git. - Compatibility dates matter. Upgrading the date can change runtime behavior. Read the compatibility flags list when you bump it.
- Durable Object migrations are forever. Renaming or deleting a class needs a migration entry, and deleting a class deletes its data.
- Hyperdrive caches by default. Disable caching for user-specific or rapidly changing queries.
- Workers Cache serves whatever you tell it to. Mark personalized responses
private, and don't cache errors. - The Pages build cache only keeps specific folders. Move your tools' caches inside your framework's cache directory.
- Local and remote state are separate.
wrangler d1 migrations applywith--localdoesn't touch production, and vice versa. That's a feature, until you forget which one you ran. - Analytics Engine samples. Weight aggregates by
_sample_interval.
Staying current
Cloudflare ships big releases twice a year (Developer/Agents Week in spring, Birthday Week in the fall) and smaller ones constantly. The changelog at developers.cloudflare.com/changelog is the single best place to keep up, and every docs page has an llms.txt and markdown version, which makes it easy to feed current docs to your coding agent instead of relying on whatever it remembers.
About Roger
I'm Roger Stringer. I build things, break them, and write up what I learned so you don't have to learn it the hard way. These Field Guides come straight out of that work.
Working on something bigger? I take on a handful of fractional CTO engagements, helping founders and teams set technical direction, build AI-powered workflows, and actually ship the hard parts. If you're wrestling with the kind of problem this guide covers and want someone in your corner who's done it before, that's exactly what I help with. Drop me a line.
And if a guide helped, got something wrong, or you just want to compare notes, I'd love to hear from you:
- Email: roger.stringer@hey.com
- X: @freekrai
- GitHub: github.com/freekrai
- LinkedIn: linkedin.com/in/rogerstringer
New guides go up as I hit problems worth documenting. Follow along wherever suits you.



