Edge vs origin, decided by a count
Count the sequential round trips. The count picks the side.
One countable property of a request settles it. Not the category the workload belongs to, and not a bucket list. Only the number of sequential trips the request makes to the data it needs.
-
Sequential trips 0
0 ms of data wait
Belongs at The user's edge Nothing to wait for, so distance to the data costs nothing.
-
Sequential trips 1
about 25 ms either way
Belongs at Close to a wash One crossing costs the same near the user or near the data.
-
Sequential trips 2 or more
about 25 ms per trip, unplaced
Belongs at Beside the data Every extra trip multiplies the distance, so the chain pays for it.
The 20 to 30 ms band is Cloudflare's published figure for an unplaced Worker reaching a database, and 1 to 3 ms is the placed figure. Every crossing drawn above is one of those round trips.
Edge vs origin: which parts of an application belong at the edge
One countable property of a request settles the placement. Count the sequential round trips it makes to its data, then apply the rule per route.
Why the category lists never settled edge vs origin#
Sorting workloads by category cannot answer the edge vs origin question. In practice, two implementations of the same category land on opposite sides. The deciding difference is never the category name, but rather how that implementation talks to its data.
| Implementation | Category the guides file it under | Sequential trips to data | Where it belongs |
|---|---|---|---|
| Signed token verified locally | Category the guides file it underAuthentication, "safe at the edge" | Sequential trips to data0 | Where it belongsThe user's edge |
| Session row read from the primary | Category the guides file it underAuthentication, "safe at the edge" | Sequential trips to data1 | Where it belongsA wash, nothing moves |
| Marketing page from bundled content | Category the guides file it underRendering, "it depends" | Sequential trips to data0 | Where it belongsThe user's edge |
| Dashboard render chaining five lookups | Category the guides file it underRendering, "it depends" | Sequential trips to data5 | Where it belongsBeside the data |
Authentication, filed under safe at the edge#
Take authentication, which every guide files under "safe at the edge". A check that verifies a signed token locally makes zero data trips, so the edge wins outright. In contrast, a check that reads a session row from the primary makes one trip, so the edge buys nothing at all. Same category, opposite answers, and the deciding difference was the data path.
Rendering, filed under it depends#
Now take rendering, which those same guides file under "it depends". A marketing page rendered from bundled content makes zero trips. A dashboard render chaining five lookups makes five, so the bucket list was never the tool.
The one property that decides edge vs origin#
Placement is decided by the number of sequential round trips a request makes to the data it needs. Only sequential trips multiply the distance between compute and data. A single trip does not multiply anything, so it moves nothing.
Cloudflare's Smart Placement documentation (opens in new tab) makes the single-query case explicit. A Worker making only one database query per request gains nothing from placement near the database. Because that 20 to 30 ms round trip costs the same near the user or near the data, nothing moves. The boundary moves only when sequential queries demand colocation.
Because the property is a count, the rule is falsifiable. For example, you can hold it against one endpoint and watch it fail.
The arithmetic behind edge vs origin, run on one endpoint#
Run the numbers and the break-even shows up in under a minute. Take an illustrative /api/dashboard endpoint on a global product. It issues five sequential queries for user, org, entitlements, recent activity, and unread count. The database is a single-homed Postgres in aws:us-east-1. Also, the user sits in Sydney, which is the worst case for this endpoint.
Unplaced: five crossings at the full price#
Unplaced, every query crosses the Pacific and returns.
The 25 ms is the midpoint of Cloudflare's published 20 to 30 ms band for an unplaced Worker.
Placed: the same chain at local prices#
Placed in the database region, the same chain pays the published 1 to 3 ms.
So the swing is about 115 ms, and it scales linearly with the chain. At ten queries the same one-line change moves roughly 230 ms. Substitute your own measured segment latencies and the working still holds.
Run the same switch against your own numbers#
A worked example is one endpoint. So set the depth and the two per-trip costs, then flip placement on and watch what it does to two routes at once.
Set one endpoint, then flip the placement switch
0 sequential trips
Signed token verified locally
Prefers this placement0 mson this placement
Better here by 25 ms
5 sequential trips
5 sequential queries
Prefers the other placement125 mson this placement
Worse here by 90 ms
Placement off: every crossing is a full round trip#
The chain is payingThe zero-trip route resolves at the user's edge for 0 ms. The 5-trip route pays 125 ms, because every one of its 5 crossings costs the full 25 ms. Placing this Worker would move about 115 ms of data wait off the chained route, and it would cost the zero-trip route a forward hop it currently avoids.
| Route | Sequential trips | Unplaced | Placed |
|---|---|---|---|
| /api/session | 0 | 0 ms | 25 ms |
| /api/dashboard | 5 | 125 ms | 35 ms |
Notice which way each route moves. Because the zero-trip route waits on nothing, placement can only add a forward hop to it. Meanwhile the chained route clears its break-even at two trips and keeps improving from there.
How to count sequential depth in your own code#
Sequential depth is countable from the source, so for every await in the handler, ask one question. Is its input the output of the await before it? If yes, it is a new sequential trip. If no, it belongs to the same trip.
// ONE sequential trip. Nothing here reads its input from anything
// else here, so all four lookups leave together and the request
// pays a single round trip to the data.
const [user, org, entitlements, unread] = await Promise.all([
db.user.findById(userId),
db.org.findById(orgId),
db.entitlements.findByOrg(orgId),
db.notifications.countUnread(userId),
]);
return renderDashboard({ user, org, entitlements, unread }); // FOUR sequential trips. Every await reads its input from the
// await before it, so each one has to finish before the next can
// start. Same four rows, four crossings.
const user = await db.user.findById(userId);
const org = await db.org.findById(user.orgId);
const entitlements = await db.entitlements.findByOrg(org.id);
const unread = await db.notifications.countUnread(user.id);
return renderDashboard({ user, org, entitlements, unread }); The two panels move the same four rows and land on opposite counts. The wall clock difference between those shapes is roughly 75 ms in the example above, and no data changed hands differently.
In practice most dashboard code chains by habit rather than by need. The count is a property you control, not one you inherit. That matters more than the placement decision it feeds.
How to confirm the count before you move anything#
A count is a prediction, so confirm it against the deployed request first. The confirmation costs less than any placement change it might justify.
Instrument every awaited data call#
Instrument each awaited data call from inside the deployed Worker, recording the start and end of every call. Then sum the per-trip costs and compare the observed total against your predicted count multiplied by the per-trip cost. When the two disagree, the code has a trip you did not read.
// Wrap every awaited data call, then compare the observed total
// against your predicted count times the per-trip cost.
const PER_TRIP_MS = 25; // your own measured segment latency
const trips: { name: string; ms: number }[] = [];
async function trip<T>(name: string, run: () => Promise<T>): Promise<T> {
const start = Date.now();
try {
return await run();
} finally {
trips.push({ name, ms: Date.now() - start });
}
}
const user = await trip('user', () => db.user.findById(userId));
const org = await trip('org', () => db.org.findById(user.orgId));
const observed = trips.reduce((total, t) => total + t.ms, 0);
const predicted = trips.length * PER_TRIP_MS;
// When observed and predicted disagree, the handler has a trip you did not read.
console.log({ trips, observed, predicted }); Where the CPU number will mislead you#
Also check where you are reading the number.
Why lowering the count beats moving the compute#
Removing a sequential trip improves every placement option at once. Moving the compute improves exactly one option, which is why the count comes first.
Two platform mechanisms remove specific trips without touching your topology. Hyperdrive pools connections and caches queries, which removes the connection-setup round trips a cold database connection pays on each request. Its behaviour is documented in how Hyperdrive works (opens in new tab). D1 read replication (opens in new tab) puts read-only copies near users, which removes the cross-ocean trip from read paths entirely.
In addition, all three changes leave your topology alone, so try them before you argue about edge vs origin at all.
The work that cannot move to the edge#
Some work does not move, whatever the count says. Its budget is CPU or process lifetime rather than waiting, so relocation changes nothing.
For example, video transcoding, large image pipelines, heavy cryptography, and long-running batch jobs all sit here. The Workers runtime bounds request duration and memory per isolate, which is what the platform limits (opens in new tab) page enumerates. When a workload exceeds that shape, the honest answer is a different primitive, not a different region.
For that case Cloudflare now ships Containers (opens in new tab), with published limits (opens in new tab) of its own, so read those numbers first.
When the state, not the code, decides the placement#
Sometimes the state pins the answer and the code follows. For instance, writes are the clearest case of the state deciding the placement. A write goes to the primary regardless of where the handler runs, so no placement makes a write path local.
Per-entity state behaves differently again, and it behaves better. A Durable Object holds its storage beside its own compute, so state and code share a location by construction. Its trip count is one by design, and it stays one.
Although that sounds like a free win, it comes with a throughput ceiling per object. Read the object model before you commit a hot path to it.
When NOT to use the edge vs origin rule#
Several cases disqualify this analysis before the counting starts, so say so out loud rather than quietly. The table gathers them, including the two that the sections either side of it cover.
| Disqualifier | What the budget really is | What the count says | What to do instead |
|---|---|---|---|
| CPU or lifetime bound work | What the budget really isCPU time and process lifetime | What the count saysNothing, because the count measures waiting | What to do insteadA different primitive, not a different region |
| Traffic already regional and near the database | What the budget really isA distance you do not pay | What the count saysThere is nothing to move | What to do insteadLeave the placement alone |
| Write-dominated routes | What the budget really isThe trip to the primary | What the count saysWrites reach the primary either way | What to do insteadReplication does not help, so read the write path |
| High tolerated staleness | What the budget really isA cache miss you rarely take | What the count saysZero trips, by definition | What to do insteadCache the response and the question disappears |
| Residency and contractual commitments | What the budget really isThe agreement, not the latency | What the count saysOverruled before it is computed | What to do insteadHonour the region the agreement names |
First, if traffic is regional and already near the database, placement solves a problem you do not have. Second, if writes dominate, replication does not help, because writes still reach the primary. Third, if a route's budget is CPU rather than waiting, no relocation changes the number.
Finally, if tolerated staleness is high, cache the response and the trip count falls to zero by definition. In that case caching is the whole answer, and edge vs origin never comes up.
What the count cannot see: residency and contracts#
The arithmetic is blind to contracts, and contracts usually win. For instance, data residency commitments can pin a region whatever the latency arithmetic prefers. GDPR, HIPAA, and SOC 2 obligations all show up this way.
Consider an illustrative buyer, a European health platform whose processing agreement names one region. In practice its dashboard would benefit from a replica in Sydney. Yet the agreement forbids it, so the count is overruled before it is computed.
This is not legal advice. Consult a licensed attorney. The buyer above is illustrative and not a client.
The failure mode: moving compute on a model alone#
The common failure is acting on the count before measuring it. Because placement is only a configuration value, it feels reversible. Its costs, though, are not distributed evenly across your users.
Two regressions land on the same distant user#
First, that user now pays a forward hop on every request, including requests that never touch the database. Second, any static asset the Worker serves is now served from the placed region rather than from the user's own edge.
The safe shape is to split the routes#
The safe shape is to split the routes, not the application. Keep the zero-trip routes unplaced, and pin only the chains. Apply the rule twice.
Where this edge vs origin rule goes next#
The next depth is the same decision applied to a concrete workload. For rendering, the walkthrough of React rendering on Cloudflare sorts SSR, SSG, and edge rendering by this same per-route logic on the same runtime.
The 2026 site optimization guide frames the wider latency budget a page lives inside. For measuring the payoff afterwards, the field guide to Lighthouse performance audits for mobile-first work covers the instruments. The other half of the argument, the work that belongs beside the database, is easier to see through common MySQL performance bottlenecks.
The one edge vs origin call worth a second opinion#
Most of this is safely reversible within a sprint, though one part is not. A residency decision, or a state model pinned to a region, is expensive to unwind after the data lands there.
If that is the call in front of you, our DevOps and infrastructure team will walk the route table with you before anything moves. Atyantik has run 50+ enterprise engagements across seven countries since 2015. This counting exercise is usually where those engagements start. No pitch deck, just the count applied to your own traffic.