You can run a real SaaS backend — database, storage, background jobs, stateful coordination — entirely on Cloudflare’s edge, with no servers to manage and a free tier that carries you further than you’d expect. Here’s the production setup, piece by piece, and the traps that bite teams late.

Workers: the request handler

A Worker is a function that runs in every Cloudflare data center, milliseconds from your users. There’s no cold start in the traditional sense and no region to pick. The mental shift from a Node server: no long-lived process, no local filesystem, and a CPU-time budget per request — so heavy work belongs in Queues or Durable Objects, never inline in a request.

export default {
  async fetch(req, env, ctx) {
    const url = new URL(req.url);
    if (url.pathname === '/api/health') return Response.json({ ok: true });
    return new Response('Not found', { status: 404 });
  },
};

D1: your SQL database

D1 is SQLite at the edge with a familiar SQL API. Bind it in wrangler.toml and query with prepared statements — always parameterized, never string-concatenated:

const row = await env.DB
  .prepare('SELECT * FROM users WHERE id = ?')
  .bind(userId)
  .first();

Trap to avoid: D1 is not for multi-hundred-millisecond analytical scans on the request path. Keep request queries indexed and small; push reporting to a scheduled job.

R2: object storage with no egress fees

R2 is S3-compatible storage where the killer feature is zero egress cost — you’re not billed to serve files out. Use it for uploads, generated assets, and backups. Bind it and put/get objects directly; stream large files rather than buffering them into memory (that CPU/memory budget again).

Durable Objects: single-threaded state

When you need coordination — a counter that must not race, a live session, a per-entity lock — a Durable Object gives you one addressable instance with its own storage, processing messages one at a time. It’s the right tool for a WebSocket hub or anything where “exactly one writer” matters. It is the wrong tool for bulk data (that’s D1/R2). On newer accounts, Durable Objects must be SQLite-backed — declare the migration accordingly or the deploy is rejected.

Queues: get heavy work off the request

Never make a user wait on a slow third-party call or a big computation. Enqueue it and return immediately; a consumer Worker drains the queue with retries and a dead-letter queue for failures:

await env.JOBS.send({ type: 'process_upload', key });   // producer, returns instantly
// consumer runs later, retried automatically on throw

Caching and headers: the part everyone gets wrong

Hashed build assets (/assets/app-a1b2.js) should be immutable and cached forever — but only if a missing asset returns a real 404. If your SPA fallback serves index.html (200, text/html) for a not-yet-propagated asset URL, the edge can cache that HTML under the asset’s URL as immutable — and every visitor then gets a “MIME type text/html” module error until you bump the filename. The fix is a headers/redirects rule that 404s missing /assets/* instead of falling through to the app shell. It’s a subtle one that takes real sites down; design for it up front.

Putting it together

Workers handle requests, D1 holds relational data, R2 holds files, Durable Objects coordinate state, and Queues absorb heavy work — all on one platform, one wrangler deploy, one bill. That’s the entire backend for most SaaS products, running at the edge with nothing to patch. Frabo runs its whole operator this way; the constraint of “no long-lived server” forces an architecture that’s cheaper and more resilient than the VM it replaced.