Every team that adopts Cloudflare Workers eventually tries to put a database transaction on the edge. It usually starts with good intentions: the auth check is already there, the request is already at the edge, why route it another 40ms to an origin server just to write a row. Then a tenant hits a race condition during a plan upgrade, two Workers in different colos both think they hold the last available seat, and the debugging session teaches everyone the lesson the hard way.
Edge-first architecture is a genuinely good idea for a specific slice of a request lifecycle. It is a bad idea for the parts of your system that need a single source of truth, a durable lock, or a multi-statement transaction. The mistake isn't choosing Workers. It's not deciding, up front, which category each piece of your platform falls into.
We've shipped edge-first stacks on Workers, D1, R2 and Pages with Postgres and Supabase behind them, and we've also pulled logic back off the edge when it didn't belong there. The pattern that works is boring: draw the boundary before you write the first Worker, not after the first incident.
What a Worker is actually good at
A Cloudflare Worker runs close to the request, spins up in single-digit milliseconds, and has no persistent connection pool to manage. That combination is excellent for anything that is stateless, idempotent, or read-mostly:
- Terminating TLS and routing by hostname or path to the right tenant or service
- Validating a JWT or session token and rejecting bad requests before they cost you an origin round trip
- Serving cached responses and static assets from R2 or the CDN layer without touching origin at all
- Rate limiting and abuse detection using edge KV counters
- Cheap, single-row entitlement checks — "does this tenant's plan include this feature" — the kind of lookup we cover in feature gating that survives a pricing change
- Transforming or redirecting requests, geo-routing, and A/B assignment
Every one of these shares a property: if the Worker's answer is wrong or stale by a few seconds, nothing breaks that a retry or a cache invalidation can't fix. That's the test that matters more than raw speed.
What doesn't belong at the edge
Anything that needs a consistent, ordered view of state across concurrent requests does not belong on a Worker running in dozens of colos with no shared memory. That includes:
- Multi-row transactions — decrementing inventory, applying a payment, and writing a ledger entry in one atomic unit
- Mid-cycle billing changes, where a proration calculation has to see the current subscription state and nothing else can mutate it mid-calculation, the exact problem we walk through in mid-cycle plan change billing
- Anything requiring a database-level unique constraint or row lock to prevent double-booking or double-charging
- Long-running jobs — PDF generation, OCR, embedding a document — that exceed Worker CPU and wall-clock limits
- Cross-tenant reporting queries that need to join and aggregate large tables efficiently
The failure mode is always the same shape: two edge invocations, in two different regions, both read a value, both decide it's safe to act, and both write. Postgres with a real transaction boundary catches this. A stateless function at the edge cannot, because there's no single place for the lock to live.
A decision rule you can apply per endpoint
We run every new endpoint through the same four questions before deciding where it lives:
- Does it write more than one row, or write and then depend on that write elsewhere in the same request? If yes, it goes to origin behind a real transaction.
- Would a stale read for 1-5 seconds cause a wrong business outcome (double-selling the last unit, granting access after cancellation)? If yes, origin.
- Does it need more than about 50ms of CPU or touch a file over a few hundred KB? Worker CPU limits will bite you; send it to a queue or a background worker instead.
- Is the value read far more often than it's written (plan tier, feature flags, cached catalog data)? Cache it at the edge with a short TTL and invalidate on write.
A simple Worker that does the safe part and defers the rest looks like this:
export default {
async fetch(req: Request, env: Env) {
const session = await verifyJWT(req, env.AUTH_SECRET);
if (!session) return new Response("unauthorized", { status: 401 });
// cheap, cached, single-key read — safe at the edge
const plan = await env.PLAN_CACHE.get(session.tenantId);
if (!plan?.includes("inventory_writes")) {
return new Response("forbidden", { status: 403 });
}
// multi-row write with transactional integrity — not safe at the edge
return fetch(env.ORIGIN_URL + "/inventory/decrement", {
method: "POST",
headers: { "x-tenant-id": session.tenantId },
body: req.body,
});
},
};
The Worker does the fast, safe, idempotent part. The origin does the part that needs a lock. Nothing here is clever, and that's the point — clever is what causes the 2am page.
Where D1 and Durable Objects actually fit
D1 is SQLite at the edge, and it's genuinely useful for per-tenant data that's small, low-concurrency, and doesn't need cross-tenant joins — a single tenant's settings, a session store, an audit log that's append-only. It struggles once you need heavy write concurrency on the same database or reporting across tenants, because each D1 database is still a single SQLite file with the write-serialization that implies.
Durable Objects solve the coordination problem Workers otherwise can't: a single object instance gives you one consistent point of truth per key, which makes them a legitimate place for a rate limiter, a WebSocket room, or a short-lived lock — but they are not a drop-in replacement for a relational database with real transactions and query flexibility. We've used them for coordination primitives, not for the tenant's system of record.
The architecture we actually ship
The stack that has worked across our own products and client platforms puts Workers and Pages at the edge for auth, routing, static delivery and cache-served reads, R2 for object storage, and Postgres or Supabase behind it as the actual system of record for anything transactional. That split shows up in our services work and across the platforms in our portfolio — it's the same shape whether the origin is handling retail inventory, construction estimating data, or subscription billing.
It pairs well with offline-first client design, where the device itself is the thing tolerating latency and the edge is just the fast path when connectivity is good — the same reconciliation discipline we describe in offline-first architecture for software that can't assume a signal applies whether the gap is a spotty warehouse network or an edge node that can't see your primary database.
The tradeoff nobody puts on the landing page
Edge-first architecture trades a faster average request for a harder debugging session on the slow ones.
Splitting logic across edge and origin means two runtimes, two deploy pipelines, and a trace that has to be stitched across both to understand a single slow request. For a low-traffic internal tool or an admin console used by a handful of support staff, that complexity buys nothing — origin-only, boring server-rendered architecture will be faster to build and easier to operate. Edge-first pays off when you have real global traffic and a meaningful share of requests that are read-heavy and latency-sensitive. If neither is true yet, don't build the split for a future you don't have.
Frequently asked
Can I run my whole backend on Cloudflare Workers instead of a traditional server?
You can, but you shouldn't for anything transactional. Workers have no persistent connection pool and no shared memory across invocations, so multi-row writes, locking, and long-running jobs need an origin server or a durable coordination primitive. Use Workers for routing, auth, caching and single-key reads; keep transactions on a real database behind them.
Is D1 a replacement for Postgres in a multi-tenant SaaS app?
Not generally. D1 is SQLite per database, which fits small, low-concurrency, per-tenant data well but struggles with cross-tenant reporting and heavy concurrent writes to the same database. Most teams get better results running Postgres as the system of record and D1 for edge-local caches or per-tenant settings.
When do Durable Objects make sense in an edge architecture?
Durable Objects are useful when you need one consistent point of coordination per key — a rate limiter, a WebSocket session, a short-lived lock — not as a general-purpose database. Each object serializes access to its own state, which solves coordination but doesn't give you relational queries or multi-object transactions.
How do I decide if my app should be edge-first at all?
Edge-first pays off when you have real global traffic and a meaningful share of read-heavy, latency-sensitive requests. If your users are mostly in one region, or your traffic is low, a conventional origin-only architecture will ship faster and be easier to debug than maintaining two runtimes for marginal latency gains.
What's the biggest mistake teams make putting logic on the edge?
Treating a Worker as if it has the same consistency guarantees as a database transaction. Two Workers running concurrently in different regions can both read a stale value and both act on it, causing double-writes or double-charges. Anything that needs a lock or an atomic multi-row write belongs on the origin, not the edge.