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

ComponentWhat it isBest for
WorkersV8 isolates running JS/TS at edgeHTTP handlers, transformations, AI proxies
D1SQLite at the edge with replicationPer-tenant SaaS data, content, sessions
Durable ObjectsStateful actors with co-located SQLitePer-room chat, counters, rate limiters
R2S3-compatible object storageFiles, blobs, media
QueuesPersistent message queuesAsync jobs
KVEventually consistent global key-valueConfig, low-write caches
Workers AIInference at edgeEmbeddings, smaller models
VectorizeVector indexRAG 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

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 .