By 2026 Cloudflare Workers + D1 + Durable Objects + R2 form a credible “serverless backend” without servers. For a specific shape of app, it’s the cheapest, fastest, lowest-ops stack you can build. This post is the working knowledge — what works, what doesn’t, and a starter pattern.
The platform
| Component | What it is | Best for |
|---|---|---|
| Workers | V8 isolates running JS/TS at edge | HTTP handlers, transformations, AI proxies |
| D1 | SQLite at the edge with replication | Per-tenant SaaS data, content, sessions |
| Durable Objects | Stateful actors with co-located SQLite | Per-room chat, counters, rate limiters |
| R2 | S3-compatible object storage | Files, blobs, media |
| Queues | Persistent message queues | Async jobs |
| KV | Eventually consistent global key-value | Config, low-write caches |
| Workers AI | Inference at edge | Embeddings, smaller models |
| Vectorize | Vector index | RAG with cheap edge embeddings |
A backend can be all of the above without provisioning a single server.
A complete tiny backend
// wrangler.toml
name = "my-app"
main = "src/index.ts"
compatibility_date = "2026-04-01"
[[d1_databases]]
binding = "DB"
database_name = "my-app"
database_id = "..."
[[r2_buckets]]
binding = "FILES"
bucket_name = "my-app-files"
[[durable_objects.bindings]]
name = "ROOM"
class_name = "ChatRoom"
// src/index.ts
import { Hono } from "hono";
import { drizzle } from "drizzle-orm/d1";
import { eq } from "drizzle-orm";
import { users } from "./schema";
type Env = {
DB: D1Database;
FILES: R2Bucket;
ROOM: DurableObjectNamespace;
};
const app = new Hono<{ Bindings: Env }>();
app.get("/users/:id", async (c) => {
const db = drizzle(c.env.DB);
const id = Number(c.req.param("id"));
const user = await db.select().from(users).where(eq(users.id, id)).get();
if (!user) return c.json({ error: "not_found" }, 404);
return c.json(user);
});
app.post("/files/:name", async (c) => {
const name = c.req.param("name");
await c.env.FILES.put(name, await c.req.arrayBuffer());
return c.json({ ok: true });
});
app.get("/files/:name", async (c) => {
const obj = await c.env.FILES.get(c.req.param("name"));
if (!obj) return c.text("not found", 404);
return new Response(obj.body);
});
// Durable Object for a chat room
export { ChatRoom } from "./room";
app.get("/room/:id/ws", async (c) => {
const id = c.env.ROOM.idFromName(c.req.param("id"));
const obj = c.env.ROOM.get(id);
return obj.fetch(c.req.raw);
});
export default app;
// src/room.ts — Durable Object for a chat room
export class ChatRoom {
state: DurableObjectState;
sessions: WebSocket[] = [];
constructor(state: DurableObjectState) {
this.state = state;
}
async fetch(req: Request): Promise<Response> {
const upgrade = req.headers.get("upgrade");
if (upgrade !== "websocket") return new Response("expected ws", { status: 400 });
const pair = new WebSocketPair();
const [client, server] = [pair[0], pair[1]];
server.accept();
this.sessions.push(server);
server.addEventListener("message", (e) => {
// Broadcast to all sessions in this room
for (const s of this.sessions) {
if (s !== server) s.send(e.data);
}
});
server.addEventListener("close", () => {
this.sessions = this.sessions.filter((s) => s !== server);
});
return new Response(null, { status: 101, webSocket: client });
}
}
That’s a complete backend with: HTTP API, D1 for relational data, R2 for blob storage, Durable Object for stateful WebSocket rooms. Deploy with wrangler deploy.
What works extremely well on Workers
1. Edge HTTP APIs
Latency from any user to your API: 20–80 ms globally. No region selection. The Worker runs at whichever Cloudflare data center is closest. This is the killer property.
2. Per-tenant SaaS
Workers + D1 lets you have one database per tenant, all managed by Cloudflare:
const tenant = c.req.header("x-tenant-id")!;
const db = drizzle(c.env[`DB_${tenant}`] as D1Database);
Or — better — a Durable Object per tenant that owns the tenant’s SQLite:
const tenantDO = c.env.TENANT.get(c.env.TENANT.idFromName(tenant));
const resp = await tenantDO.fetch("/query", { ... });
Hard isolation. Linear cost per tenant. No sharding to design.
3. Real-time with Durable Objects
Each Durable Object is a single-threaded actor. It has consistent state, lives in one location, can hold WebSocket connections. Perfect for:
- Chat rooms (Design WhatsApp at smaller scale).
- Multiplayer game rooms.
- Live document collaboration (1 DO per document).
- Per-tenant rate limiters (Design a Rate Limiter ).
- Counters, leaderboards, presence.
4. AI gateway / proxy
Workers shine as the bridge between your client and an LLM provider. SSE streaming, auth, observability. See AI Gateways in 2026 .
5. Edge transformations
Image resize, header manipulation, CSP, A/B routing — all naturally fits on Workers.
What doesn’t work well
1. Long-running CPU
Workers have CPU time limits. A 30-second image render or ML inference doesn’t fit. Use a regular Node/Python service or Cloudflare Containers (newer, mid-2025).
2. Heavy memory
Workers are limited to 128 MB by default (more on paid plans). A large in-memory cache or Pandas-shaped workload doesn’t fit.
3. Unbounded SQL ecosystem
D1 is SQLite. No pgvector, no PostGIS, no advanced extensions. For AI vector search at the edge, use Vectorize (Cloudflare’s vector index) instead.
4. Strict atomic transactions across multiple stores
A transaction across D1 + R2 + Durable Object isn’t atomic. Plan with idempotency keys (Idempotency post ).
5. Apps that need every Postgres feature
If you absolutely need recursive CTEs at scale, PostGIS, pgvector with HNSW, or Postgres LISTEN/NOTIFY — stick with Postgres on a regular host.
Limits to know
The actual numbers (will move):
- Workers CPU time: 50ms (Free), 30s (Paid Standard).
- Workers memory: 128 MB.
- Worker bundle size: 1 MB compressed (Free), 10 MB (Paid).
- D1 database size: 10 GB per database (paid plans up to higher).
- Durable Object storage: 50 GB SQLite.
- R2 object size: 5 TB.
- Vectorize index: 5M vectors per index (paid).
- WebSocket connection time on DO: Effectively unlimited (DO hibernates between messages).
For a SaaS with hundreds of tenants where each tenant has <1 GB of data and moderate traffic, Workers + D1 is dramatically cheaper than a managed RDS + ECS deploy.
Patterns I keep reaching for
Per-tenant Durable Object as the database
class Tenant {
state: DurableObjectState;
async fetch(req: Request) {
const url = new URL(req.url);
if (url.pathname === "/users") {
const users = await this.state.storage.sql.exec("SELECT * FROM users").toArray();
return Response.json(users);
}
// ...
}
}
Each Durable Object has its own SQLite (introduced in 2024). Strongly consistent reads. Linear scale per-tenant.
R2 for blobs + signed URLs
const url = await c.env.FILES.createPresignedUrl(name, "GET", { expiresIn: 3600 });
return c.json({ url });
Client uploads/downloads directly to R2. The Worker only signs URLs. Bandwidth is free egress to the public internet.
Queues for async work
await c.env.JOBS.send({ type: "send_email", userId: 42 });
// Consumer in another worker
export default {
async queue(batch: MessageBatch, env: Env) {
for (const msg of batch.messages) {
// process
}
},
};
Persistent, retry-able, ordered. See Background Jobs in Python for the conceptual basis (different runtime, same patterns).
Workers AI for cheap embeddings
const embedding = await c.env.AI.run("@cf/baai/bge-base-en-v1.5", { text: "hello" });
await c.env.VECTORIZE.upsert([{ id: "1", values: embedding.data[0] }]);
Fast, cheap, edge-local embeddings + vector index. For RAG over modest corpora, this is the lowest-friction path. See Build a Production RAG App with pgvector and FastAPI for the alternative on Postgres.
Observability
Workers expose logs and metrics in the dashboard. For deeper traces, Workers Tracing ships OTel-compatible spans. Pair with OpenTelemetry End-to-End .
Common mistakes
1. Treating Workers like containers
Workers are isolates. They cold-start in microseconds; they don’t keep state between requests. Don’t write code that assumes warm-state caching.
2. Heavy npm dependencies
A 50 MB npm tree won’t fit Worker bundle limits. Tree-shake aggressively. Many Node-only libraries don’t work.
3. Forgetting D1’s SQLite limits
D1 isn’t infinite-write Postgres. It’s SQLite. One writer at a time per database. For high-write workloads, shard into many D1 databases.
4. Using KV for hot data
KV is eventually consistent. Reads up to 60s stale. Use Durable Objects or D1 for hot, consistent data.
5. Over-coupling to Cloudflare
If portability matters, write your code against generic interfaces. Hono runs on Workers, Bun, Node, Deno; Drizzle works against D1 and Postgres; you can move.
Read this next
- Modern TypeScript Backend with Hono on Bun — Hono is your Workers framework too.
- Drizzle ORM Deep Dive — works seamlessly with D1.
- SQLite at the Edge in 2026 — D1 is one of the SQLite-at-edge options.
- Design WhatsApp / Chat — Durable Objects are a natural fit for chat rooms.
If you want a complete starter (Hono + D1 + Drizzle + Durable Objects + R2 + Vectorize) you can clone, it’s at rajpoot.dev .
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .