🪴 GoDeep
Search
← Deep dives

Deep dive · Shipping Software · The Cloudflare edge

The Edge Is a Different Computer

Eighteen Pages projects, eight databases, and no servers to patch. The constraints are not in the way — they are the point.

7 min read

You have eighteen Pages projects, eight D1 databases, nine KV namespaces and three R2 buckets. Most of it was written by agents. All of it is running right now, and none of it has a server you could log into if you wanted to. That is not a compromise you settled for. It is the entire reason the estate is that large and still standing. A single virtual machine hosting eighteen sites would, by now, be a part-time job: certificates, operating-system updates, a disk filling up, a process that died at 3am. You do not have that job, because you are not renting a computer. You are renting a different kind of computer, and its peculiar shape is what makes eighteen projects sustainable for one person.

The temptation, especially when agents write the code, is to treat the edge as a normal server that happens to be fast. Almost every painful Cloudflare surprise comes from that assumption. So it is worth being precise about what this computer is, what it will not do, and — the harder question — which of your eighteen projects would still be alive if you vanished for a year.

What the edge actually is

A traditional back end runs in one place. Your FastAPI application on Azure lives in a region; a request from Sydney travels there and back. The machine has a filesystem, a long-lived process, memory that persists between requests, and a rough licence to take as long as it needs.

A Worker — the runtime behind Cloudflare Pages Functions, and what your agent-written API routes are compiled into — is none of that. Your code is deployed across Cloudflare’s network and runs in whichever data centre is nearest the person making the request. It is not a container that boots. It is an isolate: a lightweight V8 sandbox spun up in about a millisecond, given your request, and discarded. The consequences:

  • There is no filesystem. No temporary directory, no writing a file and reading it back. Bytes live in memory for the length of one request, or they live in storage you call over the network.
  • There is no persistent process. A module-level variable might survive between two requests on the same isolate, or might not, and requests at different data centres never share anything. Any in-memory cache or counter is a lie you will eventually be caught by.
  • CPU time is metered, not wall time. Waiting on a fetch is cheap. Thinking is expensive. A Worker gets a modest CPU budget per request (tens of milliseconds by default, raisable, but never minutes). Parsing a 40MB CSV or generating a PDF is the wrong shape of work.
  • The bundle must be small, and Node is only partly there. Your dependencies are bundled and shipped to hundreds of locations. A library that reaches for fs, native bindings or a Node-only API may build locally and fail in production — a distinction agents reliably get wrong.

Each of those is a real loss compared with a server. Together they buy something a server cannot: code that runs 30 milliseconds from nearly every user, scales to a spike with no configuration, costs almost nothing when idle, and has no operating system for you to keep patched. For an estate of small projects maintained by one non-engineer, that trade is overwhelmingly correct.

The pattern that keeps winning

Across your projects, the ones that stay healthy have the same shape, whether or not anyone designed it deliberately:

Static front end → thin edge API → a real back end for heavy work.

The front end is a build artefact: an Astro or React site compiled to HTML, CSS and JavaScript, uploaded to Pages, served from cache. It has no runtime to break. It is the most reliable software you own, and it stays that way precisely because it cannot do anything at request time.

The edge API is the thin layer that needs a secret or a database: reading a row from D1, checking a session in KV, signing an R2 upload, forwarding a request upstream with a key the browser must never see. Each handler is short, does little arithmetic, and spends its time waiting on the network rather than computing.

The heavy work — document processing, long LLM chains, anything that takes seconds and real CPU — belongs on a back end with a generous time budget and a filesystem. Your FastAPI service on Azure. A queue consumer.

This wins because each layer fails independently and cheaply. A broken build does not take down the API; a misbehaving upstream does not take down the static site. And the layer most likely to need fixing at an awkward moment is the one whose deployment is a single command.

Here is the shape of a thin handler done properly:

// functions/api/jobs.ts — a Pages Function. Bindings arrive on env, never process.env.
interface Env { DB: D1Database; SESSIONS: KVNamespace }

export const onRequestGet: PagesFunction<Env> = async ({ request, env }) => {
  const token = request.headers.get("authorization")?.replace("Bearer ", "");
  if (!token || !(await env.SESSIONS.get(`session:${token}`))) {
    return new Response("Unauthorized", { status: 401 });
  }
  // Parameter binding, not string interpolation: D1 is SQL and SQL injects.
  const { results } = await env.DB.prepare(
    "SELECT id, title, company FROM jobs WHERE active = 1 ORDER BY posted_at DESC LIMIT ?",
  ).bind(50).all();
  return Response.json(results);
};

Two details matter more than they look. Bindings come from env, which is handed to the function per request — an agent that writes process.env.DB has produced code that cannot work at the edge. And .bind(...) is not stylistic: a template literal with a user-supplied value in a D1 query is a genuine injection hole, and it is among the most common flaws in agent-written Worker code.

D1, KV and R2, honestly

Cloudflare’s storage products are excellent at the jobs they were designed for and quietly poor at everything else. The trouble is that from a code sample they look like “a database”, “a cache” and “a bucket”, so an agent reaches for whichever it saw most recently.

What it really isWhere it hurts
D1SQLite, one primary copy, accessed over the networkWrites go to one region, so a far-away write is slow; no extensions; size limits; many round trips in one handler add up fast
KVA read-optimised key-value store, cached at the edgeEventually consistent — a write can take up to a minute to be visible everywhere, and a read-after-write may return the old value
R2Object storage with no charge for data leaving itObjects, not files — no partial edits, no directory semantics, no filesystem behaviour

The consistency line on KV catches people. KV is exactly right for your sessions, OAuth state, rate-limit counters and cached news: written rarely, read constantly, and harmless if stale for a few seconds. It is exactly wrong wherever a user performs an action and must immediately see the result. An agent that implements “save the form, then redirect and read it back” on KV has written a bug that appears only occasionally, which is the worst kind.

D1 rewards a different instinct: fewer, larger queries rather than many small ones, because every statement is a network hop from wherever the isolate happens to be.

// N+1 at the edge: 1 + 50 network round trips, each from a random location.
const jobs = await env.DB.prepare("SELECT * FROM jobs LIMIT 50").all();
for (const j of jobs.results) {
  j.company = await env.DB.prepare("SELECT name FROM companies WHERE id = ?")
    .bind(j.company_id).first();     // ← the CPU budget is fine; the latency is not
}
-- One statement, one hop. Almost always the right fix at the edge.
SELECT j.id, j.title, c.name AS company
FROM jobs j
JOIN companies c ON c.id = j.company_id
WHERE j.active = 1
ORDER BY j.posted_at DESC
LIMIT 50;

Then the comparison to be clear-eyed about: D1 versus a Postgres you manage. Postgres gives you real concurrency, extensions, mature tooling, strong consistency and no meaningful size ceiling. It also gives you a thing to run, back up, upgrade, secure and pay for monthly whether or not anyone visits. D1 gives you none of the first list and none of the second burden.

For eight small project databases — a job board, a news feed, a content calendar — D1 is the better answer, and not by a small margin. The moment to reconsider is specific: high write volume spread across regions, analytical queries over hundreds of thousands of rows, data that would be genuinely expensive to lose, or several applications sharing one schema with real transactional guarantees. Until one of those is true, a managed Postgres is an obligation taken on for benefits you are not using.

One caution: “the platform handles backups” is true up to a point, and the point is smaller than people assume. If a D1 database holds something you would be upset to lose, export it on a schedule and keep the export elsewhere.

# A cheap, boring safety net. Run it weekly; keep a few months of output.
wrangler d1 export jobs --remote --output "backups/jobs-$(date +%F).sql"

# Know what a deploy is about to change before it changes it.
wrangler versions upload   # Workers only: upload a version without shifting traffic
wrangler deployments list  # what is actually live right now

Which projects would survive your disappearance?

This question separates an estate from a graveyard, and it is more useful than any architectural principle. Imagine being unreachable for a year — no deploys, no fixes, no logging in — then sort the eighteen honestly.

Survives indefinitely. A static Pages site with no runtime dependencies serves the same bytes in a year. No certificate to renew, no runtime to deprecate, no bill to grow. A genuinely static site is close to immortal, which is an underrated reason to prefer one.

Survives with quiet decay. A Pages site plus a read-only D1 query keeps working, but the content ages. A news feed showing a year-old top story is still up, and still embarrassing. Worth knowing which of yours would look abandoned rather than broken.

Dies on a timer. These are the ones to audit:

  • An OAuth app or API key that expires. The single most common one-year killer. Nothing is wrong with the code; the credential ran out and nobody was there to rotate it.
  • A paid upstream API that hits its limit or a failed card. Your Worker returns 500 and the failure looks like your bug.
  • A cron-triggered Worker writing to a store that fills up, or calling an upstream whose contract changed.
  • Anything leaning on a machine you maintain. A project whose health depends on your Azure service inherits that service’s fragility.

Already dead, unnoticed. In any estate of eighteen there are some: a project whose build broke months ago while the last good deploy keeps serving, a KV namespace no live code reads, a D1 database from an abandoned rewrite. Not a moral failure — it is what happens when creating a project takes ten minutes. But an unused binding is an attack surface, an unused database is data you are responsible for, and both make it harder to reason about what you actually run.

The follow-through is small. For each project, write down what can expire, who would notice if it broke, and whether you would care. Delete what is dead. Put a calendar reminder against every credential with an expiry date. That list — not a diagram, not a dashboard — is what turns eighteen projects from a liability into an estate.

What to check in your own projects

  • Grep for process.env in anything deployed as a Pages Function or Worker. Bindings come from env; process.env at the edge is either undefined or a build-time value baked into the bundle.
  • Look for template literals inside prepare(...) or exec(...) on D1. If a request value reaches a query without .bind(), that is an injection hole, not a style preference.
  • Search for KV reads that immediately follow a KV write in the same user flow. Eventual consistency makes that intermittently wrong.
  • Find any handler doing real computation — parsing a large file, generating an image or PDF, looping over thousands of rows. That work belongs on the back end, not in a CPU-metered isolate.
  • Check for fs, path, child_process or native dependencies in Worker code. If it builds locally and fails deployed, this is usually why.
  • List your bindings and match each to code that reads it: wrangler kv namespace list, wrangler d1 list, wrangler r2 bucket list. Anything unmatched is either dead or a project you have forgotten.
  • For each D1 database, ask when it was last exported. If the answer is never, and the data matters, fix that today.
  • For each project, name the credential most likely to expire first. If you cannot, that is the audit worth doing this weekend.

The edge is not a cheaper server. It is a computer that trades away the filesystem, the long-running process and the generous CPU budget, and gives back speed, scale and the absence of maintenance. Build with the grain of those constraints — static where you can, thin where you must, heavy work elsewhere — and one person really can run eighteen projects. Fight the constraints and you have bought all of the limitations and none of the freedom.