Skip to main content
System design, step by step · Lesson 7 of 7 · 14 min

System Design Case Study Example: Designing This Website

A worked system design case study: the seven-step framework applied to this website, from requirements and numbers to the request path and its weak spots.

By Matthews WongReviewed on

Key takeaway

A system design case study applies one repeatable framework to a real system: requirements, estimates, API, data model, high-level design, a deep dive and trade-offs. Here the system is this website: prerendered pages behind Cloudflare and nginx, one URL that answers in HTML or Markdown, a small rate-limited API, and one container whose limits decide what breaks first.

You will be able to

  • Run the seven steps of the framework on a real system, ending each step with something concrete.
  • Read a design's requirements and limits out of its configuration instead of guessing them.
  • Name the riskiest component and the first bottleneck, and say what each trade-off costs.
Watch it work

Step through the diagram

Press Play, or step with the arrows. Every step is written out below the diagram.

The seven steps, applied to this website

Requirements name the readers, the API and data model follow, the build turns content into files, a visit reads one, the same URL serves Markdown to an agent, and one container is the limit.

Step 1 of 7. We design this website with the seven-step framework: readers on the left, Cloudflare and one server in the middle, the build below. Each step adds a layer.

Keys: ← → step · Home / End · K play or pause

  1. We design this website with the seven-step framework: readers on the left, Cloudflare and one server in the middle, the build below. Each step adds a layer.

  2. Requirements: articles in English and Indonesian that read without JavaScript, and the same URL as Markdown for agents. The numbers are already in config: one CPU, 512 MB.

    mem_limit: 512m cpus: "1.0"
  3. API: every page is a GET, and three POST endpoints live under /api/v1/ with per-IP limits. Data model: no database at all; posts are files in git.

    chat: 20, followups: 30, contact: 5,
  4. Writes happen at build time. A push runs CI, which prerenders every post in both languages into the image, so serving a page means reading a file.

    // Prerender every blog post at build time (x2 locales via the root layout's // generateStaticParams).Predict the next step A visitor opens a post. What does the container do? (Reads a prerendered file · Queries a database · Renders the page from scratch)
  5. High-level design: Cloudflare proxies the request, nginx terminates TLS and passes it to the container on loopback, and the page comes straight from the prerendered files.

    Predict the next step An AI agent asks the same URL for Markdown. Where does it go? (To the prerendered HTML · To a separate Markdown route · To a redirect at /md/)
  6. Deep dive on the riskiest part: one URL, two bodies. middleware.ts rewrites a request that asks for text/markdown to the Markdown route, which answers with Vary: Accept.

    if (prefersMarkdown(request.headers.get("accept")))
  7. Bottleneck: one container on one server, replaced on every deploy without waiting for its health check. Trade-off: cheap reads, slow builds. Next step: a second instance.

What are the seven steps of a system design case study?

Every case study in this module runs the same seven steps, and each step hands something concrete to the next: requirements, then estimates, then the API, then the data model, then a high-level diagram of the request path, then a deep dive into the riskiest part, then bottlenecks and trade-offs. The order matters because every later choice is judged against the requirements and the numbers. A diagram drawn before them is a list of tool names.

Read more (1)

The usual practice prompt is a product you have never built, such as a URL shortener. This lesson does the opposite and designs a system whose every decision you can check: this website. Each step ends in a real file instead of a guess, and where the repository holds no number, such as daily traffic, the lesson says so rather than inventing one. Refusing to make up a number is the first habit the framework teaches.

How do requirements and numbers shape this website?

The functional requirements are short. Visitors read articles and lessons in English and Indonesian at URLs that always carry the locale. Crawlers and AI agents must get the text without running JavaScript, and an agent that sends Accept: text/markdown gets Markdown at the same URL. A chatbot and a contact form need a small API. Out of scope: accounts, comments and anything a visitor writes, which is why there is no database.

Read more (1)

The non-functional side is where the numbers live, and here they are written down. docker-compose.yml caps the container at 512 MB of memory and one CPU. next.config.ts shrinks Next's in-memory page cache from the 50 MB default to 16 MB, because nearly every page is prerendered and read off disk. lib/api/versioning.ts allows 20 chat, 30 follow-up and 5 contact requests per minute per client IP. Traffic is not in the repository, so the useful estimate is cost per request: a prerendered page is a file read, while a chat request calls a language model, and that difference is why the limits sit on the API.

What do the API, the data and the request path look like?

The API is mostly GETs for pages. The JSON endpoints carry their version in the path, under /api/v1/; the old unversioned paths still answer as deprecated aliases, and a breaking change would ship as /api/v2/ rather than as an edit to v1. The data model is files: BLOG_META holds each post's title, description, keywords and dates, the message files hold the bodies in both languages, and lib/blog-sources.json is a derived index that a prebuild check rejects when it is stale. A write is a git push.

Read more (1)

At build time, generateStaticParams lists every post and Next prerenders each one in both locales. At request time, Cloudflare proxies the visitor's request and, since it does not cache HTML by default, forwards it to the server. nginx terminates TLS and passes it to the container, which is published on the server's loopback address only, so nothing reaches it except through nginx. Requests under /api/ skip the middleware and go straight to their route handlers.

Where does this design break first, and what would you change?

The riskiest part is the one URL with two bodies. middleware.ts sends a Markdown request to its own route, which answers with Vary: Accept so that a cache keys on the Accept header. A rendered HTML page cannot carry that header, because Next's page handler replaces Vary at render time, as the middleware's own comment records. Today that is safe only because the edge does not store HTML. Switch on edge caching for HTML without adding Vary: Accept in nginx, and an agent could receive HTML, or a browser Markdown.

Read more (2)

The first bottleneck is the single container. It is one process on one server, so a crash or a replacement is a gap for every request Cloudflare cannot answer itself, and the deploy runs docker compose up -d without --wait, so the pipeline turns green before the container is healthy. A second instance behind nginx is the obvious next step, and it exposes a hidden assumption: lib/rate-limit.ts keeps its counters in a Map inside the process, so two instances would each allow the full limit. That state has to move out before the tier can scale.

The trade-offs are deliberate, and saying what each one costs is the last step of the framework. Prerendering buys cheap, predictable reads at the price of long builds, which is why CI restores Next's build cache. Having no database removes a whole class of failures and makes every write a reviewed commit, at the price of having no visitor content. One server keeps the system small enough for one person to run, at the price of redundancy.

How this site does it

The real config behind this lesson

Excerpts from the repository that builds and deploys this website, quoted as they are.

next.config.ts:16-21
  // In-memory LRU for rendered pages, on top of the on-disk prerender cache.
  // Default is 50 MB. Nearly every route here is prerendered at build time and
  // read straight off disk, and Cloudflare absorbs the repeat traffic, so a
  // large resident cache buys little on a 2 GB VPS that also runs the other
  // self-hosted services. 16 MB still holds the hot pages.
  cacheMaxMemorySize: 16 * 1024 * 1024,

The estimate step, written as config. The comment states the requirement (pages are prerendered and read off disk), the constraint (a small server shared with other services) and the decision with its number. That is the framework's rule in miniature: attach a number to every choice and say why.

lib/api/versioning.ts:32-44
/** Current endpoints; trailing slash because the site sets trailingSlash. */
export const API_PATHS = {
  chat: "/api/v1/chat/",
  followups: "/api/v1/chat/followups/",
  contact: "/api/v1/contact/",
} as const;

/** Requests per minute per client IP, enforced by each route. */
export const API_RATE_LIMITS = {
  chat: 20,
  followups: 30,
  contact: 5,
} as const satisfies Record<keyof typeof API_PATHS, number>;

The whole API surface and its limits in one module. The routes enforce these numbers, and next.config.ts, the OpenAPI description and the developer guide read them, so the API step of the design cannot drift from what actually runs. The satisfies clause makes a new endpoint without a limit a type error.

app/[locale]/(pages)/blog/[slug]/layout.tsx:169-173
// Prerender every blog post at build time (x2 locales via the root layout's
// generateStaticParams). Without this, all 300+ posts were rendered on demand.
export function generateStaticParams() {
  return Object.keys(BLOG_META).map((slug) => ({ slug }));
}

The data model and the high-level design meet here. The list of posts is the keys of BLOG_META, a file in the repository, and returning it from generateStaticParams moves rendering from request time to build time. The comment records the alternative it replaced.

lib/rate-limit.ts:1-11
/**
 * Simple in-memory rate limiter for API routes
 * In production, consider using Redis (Upstash) for distributed rate limiting
 */

interface RateLimitEntry {
  count: number;
  resetTime: number;
}

const rateLimitMap = new Map<string, RateLimitEntry>();

The bottleneck step's hidden assumption. A Map in one process is correct while there is exactly one process. The file's own comment names the fix a second instance would force: move the counters to a shared store.

What breaks

Mistakes that cost real time

  • Caching HTML at the edge without Vary: Accept

    One URL answers HTML or Markdown, and the HTML response carries no Vary: Accept. A shared cache that stores it would hand the stored copy to every client, whatever it asked for. Keep HTML off the edge, or add Vary: Accept in nginx first, as the comment in middleware.ts says.

  • Scaling out with state in the process

    Add a second container and the in-memory rate limiter quietly doubles every limit, because each process counts on its own. Move shared state, such as rate-limit counters, to a shared store before you add instances.

  • A deploy that reports success too early

    docker compose up -d returns once the container starts, not once it is healthy, so a broken image still turns the pipeline green. The --wait flag waits for services to be running and healthy, which makes the health check part of the deploy.

  • Choosing a feature without measuring its cost

    next.config.ts records that encoding one cover image to AVIF took 4 to 9 seconds on this server, against about 0.6 seconds for WebP, so AVIF stays off until the edge caches optimised images. A requirement that looks free can be the most expensive line in the design.

Render on each request or prerender at build
Work per page viewRun the code that builds the pageRead a file that already exists
A content change goes liveOn the next requestAfter the next build and deploy
Build timeShortGrows with every post
Needs at request timeData sources and CPUDisk and a little memory
FitsPages that differ per userArticles that change by commit
Try it yourself

Do it on your own machine

You need curl and a terminal. First observe this site's design from outside, then run the seven steps on a system of your own.

  1. Fetch a post's headers. Look for server: cloudflare, x-nextjs-prerender, which marks a page served from the build, and cf-cache-status, which says whether the edge answered from its cache.

    curl -sI https://www.matthewswong.com/en/blog/system-design-interview-framework-step-by-step/ \
      | grep -iE "^(server|cf-cache-status|x-nextjs-prerender|vary|content-type)"
  2. Ask the same URL for Markdown. The content type changes to text/markdown and a Vary: Accept line appears; compare it with the HTML response above.

    curl -s -D - -o /dev/null -H "Accept: text/markdown" \
      https://www.matthewswong.com/en/blog/system-design-interview-framework-step-by-step/ \
      | grep -iE "^(content-type|vary)"
  3. Call an API path with the wrong method. The answer is a JSON error with a stable code and a hint: the API boundary keeps its contract even when you misuse it.

    curl -s https://www.matthewswong.com/api/v1/contact/
  4. Now write the seven steps for a system you run or use, one line each, and check every number against a file rather than memory.

    1. Requirements: functional, non-functional, out of scope
    2. Numbers: limits already in config, cost per request
    3. API: paths, methods, versioning
    4. Data: what is stored where, what is derived
    5. Request path: every hop from user to data
    6. Deep dive: the riskiest component
    7. Bottlenecks and trade-offs: what fails first, what each choice costs
Quick quiz

Three questions before you move on

Pick an answer to see why it is right or wrong. Nothing is saved or graded, so guess freely.

  1. 1.Asked to design this website in an interview, you open by choosing Postgres and Redis. Which step did you skip?
  2. 2.A browser and then an AI agent request the same URL. A cache in front stored the browser's HTML without Vary: Accept. What does the agent get?
  3. 3.You add a second container behind nginx. Now the contact form accepts up to 10 messages a minute from one IP. Why?
Think it through

Answer in your head, then check

  1. 1.Why does this site have no database, and what does that cost?Show the answer

    Answer:Its requirements contain no visitor writes: content arrives as commits and is prerendered at build. The cost is no visitor content, and a build and deploy for every content change.

  2. 2.Why would caching the site's HTML at the edge be risky as things stand?Show the answer

    Answer:The same URL can answer Markdown, and the HTML response does not carry Vary: Accept, so a shared cache could hand one representation to the wrong client. The Markdown responses do carry it.

  3. 3.What has to change before a second container can run safely?Show the answer

    Answer:The rate limiter's counters live in a Map inside one process; with two containers each would allow the full limit. They have to move to a shared store first.

Read the module

Every post in this module, in order

The lesson above is the map. Each post goes deep on one part of it, with the full worked examples.

  1. System Design Interview Framework: A Step-by-Step ApproachHow to approach a system design interview step by step: requirements, scale estimates, API and data model, diagram, deep dive, trade-offs, with a worked pastebin example.
  2. Design a URL Shortener Like bit.ly: System Design GuideHow to design a URL shortener like bit.ly: capacity arithmetic, base62 key generation, a Postgres schema, 301 versus 302, Redis caching, analytics and abuse control.
  3. Design Search Autocomplete (Typeahead): Tries, Redis, PostgresHow to design search autocomplete at scale: capacity arithmetic, a 100 ms latency budget, tries versus Postgres and Redis, decayed ranking, offline log aggregation, top-K caching and a debounced client.
  4. News Feed System Design: Fan-Out on Write vs Fan-Out on ReadHow to design a news feed: fan-out on write versus fan-out on read, the celebrity problem, Redis sorted set feeds, cursor pagination and ranking, with the arithmetic shown.
  5. Design a Chat App Like WhatsApp: Real-Time System DesignHow to design a real-time chat application like WhatsApp: WebSocket gateways, ack and ordering with per-conversation sequence numbers, offline delivery, presence and group fan-out.
  6. How to Design a Scalable Notification System (Push, Email, SMS)A notification system is an event ingester, one queue per channel, a preference check, a dedup constraint, retries into a dead-letter queue and webhook-driven delivery tracking.
  7. Geohash vs Quadtree: How Nearby Driver Search WorksHow do apps find nearby drivers or places? Geohash vs quadtree explained with TypeScript, PostGIS ST_DWithin, Redis GEOSEARCH and write-rate maths.
  8. Design a Distributed Job Scheduler That Runs Each Job OnceHow to design a distributed job scheduler that runs each job once: slot rows, Postgres SKIP LOCKED leases, advisory locks, catch-up, retries and idempotent handlers.
  9. Design a Web Crawler: URL Frontier, robots.txt and DedupHow do you design a web crawler that scales? A worked design: URL frontier, robots.txt under RFC 9309, dedup, DNS caching, crawler traps and recrawl scheduling.
  10. Design a Metrics and Monitoring System: A Prometheus PipelineHow to design a metrics and monitoring system end to end: instrumentation, pull versus push, time-series storage maths, cardinality, retention and alerting.
  11. Design a Video Streaming System: HLS, CDN and Adaptive BitrateHow video streaming works and how to design it: a transcode ladder, HLS segments and manifests, object storage, a CDN, adaptive bitrate logic, signed URLs and live versus VOD, with the storage and egress arithmetic shown.
  12. Design a Flash Sale System That Never Oversells StockHow to design a flash sale system that does not oversell stock: atomic decrements in Postgres and Redis Lua, TTL reservations, a waiting room, sharded counters and reconciliation.
  13. Design a Payment System: Ledger, Idempotency, ReconciliationHow to design a payment system that never loses or double-charges money: a payment state machine, an append-only double-entry ledger in integer cents, idempotency keys, webhooks and daily reconciliation.
  14. Design a Distributed Key-Value Store, Dynamo-StyleHow to design a distributed key-value store the Dynamo way: consistent hashing, N/W/R quorums, vector clocks, hinted handoff, Merkle trees, gossip and an LSM node.