Skip to main content

ADR-0002 — Cloudflare Workers and D1

Date2026-08-17
Superseded by

Context

The backend has a narrow job: serve read-mostly guidebook content, and accept a small stream of user-owned writes. The client is offline-first, so the API is not on the critical path of any screen.

What it does need:

  • Low cold-start latency, everywhere. The app is used in places with bad connectivity; adding a 900ms Lambda cold start to a request that already took two seconds to establish is unnecessary.
  • A per-PR preview, with its own database, deployable from CI in under a minute. That is a hard requirement, not a nicety — see ADR-0006.
  • Near-zero cost at zero users, because there are currently zero users.
  • SQLite semantics, because the phone runs SQLite and having one dialect and one mental model across both sides is worth a lot.

Decision

The API is a Hono app on Cloudflare Workers, with Cloudflare D1 as the database.

  • Hono for routing, with @hono/zod-validator binding the shared Zod schemas to request validation. Small, Workers-native, no Node polyfill needed.
  • D1 bound as DB. It is SQLite — the same engine as expo-sqlite on the phone.
  • Drizzle for queries and for generating migrations from src/db/schema.ts. Migrations are applied with wrangler d1 migrations apply, never drizzle-kit push — D1 is the authority on what has run.
  • Three environments in wrangler.jsonc: local (Miniflare), preview, production, each with its own D1 database.
  • Types come from wrangler types, not @cloudflare/workers-types. The generated worker-configuration.d.ts derives Cloudflare.Env from the actual bindings, so a binding that is not configured is a type error. It is gitignored and regenerated by the typecheck script.

Consequences

What gets harder:

  • D1 has limits. Database size, query duration, and rows-read per query are all bounded. Fine for guidebook data; it would not be fine for something write-heavy or analytical.
  • No connection-level transactions across requests. D1 supports batches and single-statement transactions; anything needing a long-lived transaction has to be redesigned. The sync push loop is written per-mutation partly because of this.
  • Workers runtime, not Node. No filesystem, no long-running processes, no arbitrary npm package that assumes Node. nodejs_compat covers a lot but not everything.
  • Cold data is slower than the marketing suggests. D1 reads from the primary region; a request from a Worker in Sydney to a database in Europe is not free. Read replication exists and we are not using it yet.

What we are committed to:

  • Cloudflare. Not catastrophically — Hono runs on several runtimes and D1 is SQLite, so the exit is "export the SQLite file, redeploy the Hono app elsewhere". But the preview-deployment workflow, the bindings model, and the migration tooling are all Cloudflare-shaped.

What this creates:

  • Two D1 databases to keep migrated (crag-topo-db, crag-topo-db-preview), both applied from CI.
  • A migrations CI job that applies every migration to a throwaway local D1 on each PR, so a broken migration fails before it reaches a real database.

Alternatives considered

Postgres on a managed host (Supabase, Neon, RDS)

More capable in every dimension: real transactions, real joins at scale, extensions, no row-read limits. Rejected for this project because the workload does not need any of it, and the per-PR-database requirement gets meaningfully harder and more expensive. Revisit if the data model grows a genuinely relational problem — moderation queues and consensus scoring (roadmap phase 3) are the likely trigger.

Cloudflare Durable Objects with SQLite storage

Strong consistency, per-object transactions, and a natural fit if the model were partitioned by crag. Rejected as premature: it is a more specialised tool, the access patterns are not established yet, and the mental overhead of "which object owns this row" is real. Worth revisiting if per-crag write contention ever becomes a thing.

Serverless Postgres via Hyperdrive from a Worker

Keeps Workers, gets Postgres. Rejected because it reintroduces the connection-pool and cold-start problems D1 avoids, and doubles the number of systems in the deploy path for a workload that fits SQLite comfortably.

A conventional container on Fly / Render / a VPS

Simplest to reason about, no runtime restrictions. Rejected on cold-start latency and on the cost of per-PR environments — spinning a container and a database per pull request is a lot more moving parts than wrangler versions upload.

Open questions

  • Rate limiting. There is none. A public sync endpoint without limits is a bad idea before launch, and it needs its own decision.
  • Read replication. Worth turning on before there are users outside Europe.
  • Image storage. R2 is the obvious answer and is not configured. Deliberately left out of wrangler.jsonc so day-one deploys do not fail on a missing bucket. Needs its own ADR — see roadmap phase 1.
  • Backups. D1 has time-travel; we have not established a retention policy or tested a restore.