Skip to the solution
MediumSystem Design13 min read

How would you design an edge-native app on Cloudflare Workers + R2?

388 views

The problem

Question presented to candidate: "We want to build a photo-sharing app with no servers of our own: code runs on Cloudflare Workers and images live in R2. Design it. Where does each kind of data live, what does a request look like, and what will it cost?"

What a strong answer should cover:

  • The model: a Worker runs in every Cloudflare location, is stateless between requests, and has limits (128 MB memory per isolate, CPU time limits per request); all state lives in bound storage services
  • Picking the right store per data type: R2 for objects (S3-compatible, no egress fees), the Cache API for hot reads (per location, not replicated), KV for read-mostly config (eventually consistent, up to about 60 s), Durable Objects for anything needing coordination or exact counts, D1 for relational metadata
  • The read path: cache first, R2 on a miss, and why per-location caches make the hit rate depend on the number of locations and the catalogue size
  • The write path: uploads streamed or presigned straight to R2, metadata in D1, counters and rate limits in Durable Objects, slow work pushed to a queue
  • A priced estimate from the published Workers and R2 prices, and what breaks first as traffic grows

Clarifying questions expected:

  • Read to write ratio, object sizes, and how much of the catalogue is hot?
  • Which data needs strong consistency (counts, purchases, uniqueness) and which can be a minute stale?
  • Where are users, and are there data residency requirements?

Code / implementation expected: Optional โ€” the example simulates per-location caching, a counter in KV versus a Durable Object, and computes the monthly bill.

  • edge
  • cloudflare
  • r2

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

13 min read ยท 15 sections

Read the code

Workers + R2 photo app: per-PoP Cache API hit rates, a KV counter vs a Durable Object, and the monthly bill
// Edge-native photo app on Workers + R2, simulated in one process:
// per-location Cache API in front of R2, a view counter in KV vs a Durable Object, and the monthly bill.

let seed = 12345; // 32-bit LCG so every language draws the same numbers
const rand = () => { seed = (seed * 1664525 + 1013904223) % 4294967296; return seed / 4294967296; };

// ---- 1. Cache API is per location: each PoP misses each object once before it can hit ----
function zipfTable(n) { const c = []; let s = 0; for (let k = 1; k <= n; k++) { s += 1 / k; c.push(s); } return c; }
function pick(cum) {
  const x = rand() * cum[cum.length - 1];
  let lo = 0, hi = cum.length - 1;
  while (lo < hi) { const mid = (lo + hi) >> 1; if (cum[mid] < x) lo = mid + 1; else hi = mid; }
  return lo;
}
console.log("== 1. Cache API in front of R2 (200000 requests, Zipf popularity, no eviction) ==");
console.log("  objects  PoPs  hit rate  R2 reads (Class B)");
for (const [objects, popsN] of [[1000, 4], [1000, 40], [100000, 4], [100000, 40]]) {
  seed = 12345;
  const cum = zipfTable(objects);
  const cached = new Set();
  let misses = 0;
  const requests = 200000;
  for (let i = 0; i < requests; i++) {
    const pop = Math.floor(rand() * popsN), obj = pick(cum);
    const key = pop + "/" + obj;
    if (!cached.has(key)) { misses++; cached.add(key); } // miss: Worker reads R2, then cache.put()
  }
  console.log(`  ${String(objects).padStart(7)}  ${String(popsN).padStart(4)}  ${((1 - misses / requests) * 100).toFixed(1).padStart(7)}%  ${String(misses).padStart(9)}`);
}

// ---- 2. A global view counter: KV read-modify-write vs one Durable Object ----
// 4 PoPs share 1000 increments over 120 s. A KV read at a PoP is "lag" seconds old (assumed 0, 2, 5, 10 s; KV allows up to 60 s).
const lags = [0, 2, 5, 10];
const writes = []; // [time, value], appended in time order
const visible = (t, lag) => { let v = 0; for (const [wt, wv] of writes) { if (wt <= t - lag) v = wv; else break; } return v; };
let doCount = 0;
for (let i = 0; i < 1000; i++) {
  const t = i * 0.12, pop = i % 4;
  writes.push([t, visible(t, lags[pop]) + 1]); // KV: get, add one, put (last write wins)
  doCount++;                                   // Durable Object: one instance applies requests one at a time
}
console.log("== 2. view counter after 1000 views from 4 PoPs ==");
console.log(`  KV read-modify-write: ${writes[writes.length - 1][1]}  (lost ${1000 - writes[writes.length - 1][1]} updates)`);
console.log(`  Durable Object:       ${doCount}`);

// ---- 3. Monthly bill (prices from the Cloudflare Workers and R2 pricing pages) ----
const req = 100e6, cpuMsPerReq = 2, storageGB = 500, uploads = 1e6, r2Reads = 15e6, avgKB = 150;
const workers = 5 + Math.max(0, req - 10e6) / 1e6 * 0.30 + Math.max(0, req * cpuMsPerReq - 30e6) / 1e6 * 0.02;
const r2Storage = Math.max(0, storageGB - 10) * 0.015;
const r2A = Math.max(0, uploads - 1e6) / 1e6 * 4.50;
const r2B = Math.max(0, r2Reads - 10e6) / 1e6 * 0.36;
const egressTB = req * avgKB / 1e9;
console.log("== 3. monthly bill: 100M requests, 2 ms CPU each, 500 GB stored, 1M uploads, 15M R2 reads ==");
console.log(`  Workers (base + requests + CPU): $${workers.toFixed(2)}`);
console.log(`  R2 storage $${r2Storage.toFixed(2)}  Class A $${r2A.toFixed(2)}  Class B $${r2B.toFixed(2)}`);
console.log(`  egress: ${egressTB.toFixed(1)} TB served, R2 egress fee $0.00`);
console.log(`  total: $${(workers + r2Storage + r2A + r2B).toFixed(2)} per month`);

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 39 of 106 in System design

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

Up nextMedium

Kafka vs SQS โ€” how do you choose for an event-driven system in 2025?

Continue