System Design Basics, Explained Through This Blog's Own Architecture
Most system design guides draw boxes for an imaginary Twitter. This one uses a real, small system, the site you're reading, to show caching, atomic counters, a genuine race condition, and what would have to change at scale.
Ram

Most system design guides start with a diagram of an imaginary social network: load balancers, a dozen services, sharded databases. It's useful for interviews, but it's hard to connect to anything you've actually built.
So this guide does the opposite. It starts with a real, small production system, the blog you're reading, and uses it to explain the core ideas. Then it asks what would have to change if traffic grew a thousandfold. That second part is where the classic building blocks come in, and you'll see why each one exists rather than just that it exists.
The system as it is today
RCInsights is a Next.js app deployed on Vercel. Roughly:
Reader ──► CDN edge ──► pre-built HTML for posts and tool pages
│
└──► serverless functions ──► Upstash Redis
/api/track (view counts)
/api/newsletter (subscriber list)
/api/contact ──► SMTP (sends an email)
Three ideas are already at work in that small picture.
1. The fastest request is one you don't compute
Every blog post is rendered to HTML at build time, not when you request it. The pages are files, so the hosting platform can serve them from a CDN edge close to the reader. There's no database query and no server rendering per visit.
That's caching taken to its extreme. The content changes when a new version is deployed, roughly weekly, but it's read thousands of times in between. Computing it once and serving the result is the right trade.
Questions to ask before caching anything:
- How often does this change, compared with how often it's read? A blog post: rarely changed, read often, so cache aggressively. A bank balance: never serve it stale.
- How do I invalidate it? Here, a new deploy replaces everything. In most systems, invalidation is the hard part: knowing which cached entries a write made stale.
- What happens on a miss? If a popular cache entry expires and a thousand requests miss at once, they all hit the database together. That's a cache stampede. The usual defences are letting one request refresh the entry while the others briefly get the old value, or adding some randomness to expiry times.
2. Counters must be atomic
Each post has a view counter stored in Redis. The code does this:
await redis.incr(`post:views:${slug}`);
INCR is atomic. Redis applies each increment as a single indivisible step, so two readers arriving at the same moment produce two increments, never one.
The tempting alternative is subtly broken:
// ❌ read-modify-write
const views = await redis.get(key);
await redis.set(key, views + 1);
If two requests both read 41, both write 42, and a view is lost. At low traffic you'll never notice. At high traffic you'll lose counts constantly, and no error will tell you.
3. …and here's where this site gets it wrong
The newsletter subscriber list does use that broken read-modify-write pattern. It stores every email as one JSON array under a single key:
const subscribers = await getSubscribers(); // read the whole array
if (subscribers.includes(email)) return false;
subscribers.push(email); // modify it in memory
await redis.set("subscribers", JSON.stringify(subscribers)); // write it all back
If two people subscribe within the same few milliseconds, both requests read the same list, each appends its own email, and the second write overwrites the first. One subscriber silently disappears.
At this site's traffic, that's very unlikely to happen. But it's a perfect example of a race condition, and of how a data model creates or prevents one. The fix isn't a lock. It's choosing the right data structure:
// Redis Set: SADD is atomic, and returns 1 only if the email was new
const added = await redis.sadd("subscribers", email);
return added === 1;
Duplicate checking, appending and atomicity now come free, and the request no longer re-downloads the whole list on every signup.
The general lesson: when two requests can touch the same data at once, ask whether your update is a single atomic operation. If it isn't, use one that is (INCR, SADD, a SQL UPDATE ... SET n = n + 1), a transaction, or an optimistic version check.
What would change at 1,000× the traffic
Now imagine the site went from hundreds of readers a day to hundreds of thousands. Here's what would break first, and what you'd add.
Slow work inside requests → a queue
Right now, the contact form and newsletter signup send their email inside the request. The visitor waits while the function talks to an SMTP server. If that server is slow, the page hangs. If it's down, the signup fails, even though saving the subscriber worked.
The standard fix is a message queue. The request records the job and returns immediately, and a separate worker sends the email, retrying if it fails:
POST /api/newsletter ──► save subscriber ──► enqueue "send welcome email" ──► 200 OK
│
worker ◄───┘ (retries on failure)
This also smooths out spikes. A burst of 10,000 signups becomes a backlog that workers drain at their own pace, instead of 10,000 simultaneous SMTP connections. Common choices are SQS, RabbitMQ and Kafka, or a managed option like Upstash QStash on a serverless setup like this one.
Unprotected endpoints → rate limiting
There's currently nothing stopping a script from posting to the contact form thousands of times a minute, and every post sends a real email. A rate limiter caps requests per client, for example five contact submissions per IP per hour.
The common algorithm is a token bucket. Each client has a bucket that refills at a steady rate. Each request takes a token. An empty bucket means 429 Too Many Requests. It allows short bursts but enforces a steady average. With Redis, a simple fixed-window version is an INCR on a key like ratelimit:{ip}:{hour} with an expiry, which is the same atomic counter as before.
One server → many servers, and a load balancer
On a traditional server, a traffic spike means CPU at 100% and slow responses for everyone. You can scale vertically (a bigger machine: simple, but there's a ceiling and it's still a single point of failure) or horizontally (more machines behind a load balancer).
Horizontal scaling only works if servers are stateless: no user session or uploaded file lives on one particular machine. That's why the counters here live in Redis rather than in the function's memory. Serverless platforms like Vercel do horizontal scaling for you, starting more function instances as requests arrive. But that only works because the state is kept elsewhere.
One database → replicas, then partitions
If Redis, or a SQL database in a bigger app, became the bottleneck, the usual progression is:
- Indexes and query fixes. Most "the database is slow" problems are a missing index or an N+1 query, not a scaling problem.
- Read replicas. Copies of the database that handle reads while the primary handles writes. They work well when reads vastly outnumber writes. The trade-off is that replicas lag slightly, so a user might not see their own write immediately.
- Sharding. Splitting the data across databases by a key, such as user ID or slug. It scales writes almost without limit, but cross-shard queries and rebalancing get hard. Postpone it as long as you reasonably can.
The building blocks, in one place
| Problem | Building block | Seen here as |
|---|---|---|
| Same content read many times | Cache / CDN | Pre-rendered pages at the edge |
| Concurrent updates to one value | Atomic operations | INCR for views (and SADD for subscribers, once fixed) |
| Slow or flaky work in a request | Message queue + workers | Email sending (not yet queued) |
| Abuse or overload of an endpoint | Rate limiting | Contact form (not yet limited) |
| More traffic than one machine handles | Stateless servers + load balancing | Serverless functions with state in Redis |
| Database becomes the bottleneck | Indexes → replicas → sharding | Not needed at this size |
If you're preparing for interviews
Interviewers care far more about your reasoning than your diagram. A good shape for a 45-minute answer:
- Pin down requirements. What must it do? How many users? Read-heavy or write-heavy? What's allowed to be stale?
- Rough numbers. Requests per second, data size per year. "100M reads a day is about 1,200 per second on average, maybe 5× that at peak" tells you whether one database is fine.
- Simple design first, then find what breaks under your numbers.
- Fix the bottleneck you found, and say what each fix costs: complexity, consistency, money.
The best practice isn't memorising architectures. It's looking at systems you already use, like this one, and asking: what would break first, and why?
For going deeper, Martin Kleppmann's Designing Data-Intensive Applications is the standard book, and it's worth the effort.
