1 Our caching toolkit β a quick recap
Imagine your toy box is too small to hold every toy in the house, so you keep only the toys you play with most right next to you. That's a cache. But two questions follow you around: when the box is full, which toy do you put back to make room? And if Mum buys a newer version of a toy, how do you make sure the old broken one isn't still in your box? Those two questions are our whole toolkit β and today we use it to build real things.
A cache is a small, fast store that keeps copies of data so we don't have to re-fetch or re-compute it from a slow source (like a database) every time. We've spent two sessions on the details; here we line up the tools we'll reach for today so the case studies read smoothly.
Tool 1 β Eviction (from Session 3)
A cache has limited memory, so when it fills up it must evict (kick out) something to make room. The rule it uses is an eviction policy:
| Policy | Kicks out⦠| Best when⦠|
|---|---|---|
| LRU (Least Recently Used) | the item untouched for the longest time | recent things are likely to be used again (the usual default) |
| LFU (Least Frequently Used) | the item with the fewest total hits | some items are perennial favourites |
| FIFO | the oldest inserted item, regardless of use | simple, order-of-arrival fairness |
| TTL (Time To Live) | any item older than a set age, automatically | data that goes stale on a clock (prices, sessions) |
For the eviction step, just use LRU unless you have a specific reason not to. It's the sensible default and almost always the right answer in an interview.
Tool 2 β Write & invalidation strategies (from Session 4)
The second half of caching is keeping the cache honest when the underlying data changes. If we update the database but the cache still serves the old copy, we've served stale data. The strategies:
| Strategy | How it works | Trade-off |
|---|---|---|
| Cache-aside (lazy loading) | App checks cache first; on a miss it reads the DB and fills the cache. | Simple & popular; first read after a miss is slow. |
| Write-through | Every write goes to the cache and the DB together, synchronously. | Cache always fresh (immediate consistency); writes are a little slower. |
| Write-back (write-behind) | Write to cache now, flush to DB later in the background. | Fast writes; risk of data loss if the cache dies before flushing. |
| Write-around | Write straight to the DB (or recompute on a schedule), skip the cache; let reads/CRON populate it later. | Eventual consistency; avoids caching data nobody reads. |
| TTL | Cache entries expire after a set age and are re-fetched. | Eventual consistency; choosing the right TTL is the hard part. |
And to fight staleness directly, we invalidate β explicitly delete or refresh a cache entry when its source changes β or we lean on TTL to let entries expire on their own. We also met the classic dangers: the thundering herd (many requests stampede the DB the moment a hot key expires) and the hot key problem (one key so popular it overloads a single cache node). Remember the consistency mapping: write-through β immediate consistency; TTL / write-around β eventual consistency.
Every caching decision is really two questions glued together: "what do I drop when I'm full?" (eviction) and "how do I avoid serving a lie?" (writes + invalidation). Hold those two questions in your head and the case studies below almost design themselves.
2 Do we always want a cache? & the 5-step design process
Keeping your favourite snacks on your desk instead of the kitchen means you grab them fast β most of the time. But now you have two places snacks can live, so sometimes you check the desk, find nothing, and still have to walk to the kitchen β that trip is now slower than if you'd never had a desk stash. And every time Mum buys new snacks she has to restock two places. A cache is great, but it's never free.

Do we always want to cache? Not always.
A cache is not a free win you sprinkle on everything. It is a deliberate design decision with real trade-offs. Let's be honest about both sides.

| Pros of caching | Cons of caching |
|---|---|
| Improves read throughput (assuming a high hit rate) | Adds complexity β it has to be designed |
| Improves best/average-case read latencyβ¦ | β¦but worsens worst-case read latency (a miss now checks 2 places: cache, then DB) |
| Offloads the database on the read path | Extra infrastructure to maintain |
| Caching logic gets mixed into business logic in the app server (mitigate by moving caching into middleware) | |
| Can worsen write throughput β now you may write to 2 places (DB + cache) |
Throughput = the average number of tasks completed per unit time. High throughput is good. Caching raises read throughput when the hit rate is high, but always raises the worst-case latency because a miss means checking the cache and the DB.
The 5-step process for designing a cache
Whenever a system-design question calls for a cache, walk these five steps in order. Every case study below is just this checklist applied.
- Establish the need for caching. Don't cache reflexively β show why the slow path hurts.
- Determine the type of cache. Local (lives on each app server) vs Global (a separate shared cache tier). If global, then also decide Single vs Distributed. (Single/Distributed is only a question for global caches β a local cache is automatically "distributed" across your app servers.)
- Identify the eviction algorithm. Just use LRU.
- Identify the invalidation algorithm, based on (a) your consistency requirements and (b) the complexity of the data/query.
- Think about the load balancer β consistent hashing vs round robin.
Always ask: "why do we have more than one server?"
- Because the data was too large to fit on one server β data is sharded β you MUST use consistent hashing.
- Because the request/compute load was too much for one server β servers hold the same data (replication) β you can just use round robin.
3 Case study: the Scaler Code Judge
Imagine a teacher who grades your homework against a giant answer book that lives in a locked library across town. Hundreds of students hand in the same problem. Fetching that heavy answer book from the library every single time would take forever. So the teacher photocopies it once and keeps the copy in a drawer on the desk β grading is now fast. If the library later prints a corrected answer book, the teacher throws away the old desk copy and fetches the new one. That desk drawer is our local cache.
The Code Judge is a service that "judges" your code submission. When you submit a solution for a DSA problem on Scaler, it takes your code and runs it through hundreds of test cases to decide whether it's correct. Let's design its cache with the 5-step process.
Step 1 β Establish the need
What does the app server need to evaluate a submission?
- User info β auth, whether they've already solved this, whether they're part of the course, β¦
- Problem info β topic, total score, memory limit, time limit, number of test cases, β¦
- The code submission made by the user
- Test case data β the input file and the expected output file
How large can this data be?
| Data | Size (avg/worst) | Where it lives |
|---|---|---|
| User info / Problem info | a few KBs | SQL database (Amazon RDS) |
| Test case data | 1 to 2 GB | File storage (Amazon S3) |
For one problem we run your code through 100 test cases. Imagine the problem is "sort the array." Each test case is a large array (N = 10βΆ integers), and each expected output is also a large array (N = 10βΆ).
100 testcases Γ (10^6 integers / testcase) Γ (8 bytes / integer) = 100 Γ 10^6 Γ 8 bytes = 800 MB β 1 GB (for the input file; the output file is similar)
And the total test-case storage across Scaler's catalogue:
Scaler has ~3,000 problems 3000 problems Γ (1 GB testcase data / problem) = 3 TB of testcase data in S3
Is 3 TB too large? Not really β storage is cheap. It's too large to fit entirely in RAM, but it fits easily on an HDD.
How many problems are solved on a given day? Around 100β200 different problems β we run multiple batches in parallel, each solving the current (or last) class's problems, and the batches overlap in timeline.

No! Backend services never access data from the CDN. CDNs are client-facing β they reduce latency for the user by serving from an edge server close to them. Our app servers fetching test cases is a backend-to-backend concern, not a CDN use case.
The need: do we want to transfer 1 GB of test-case data over the network for every submission request? Absolutely not. Hence β we need a cache.
Step 2 β Type of cache: Local vs Global
A global cache would be available to all app servers (keeping them stateless), and the data is large so "global" feels right. But it doesn't help at all: we'd still have to transfer 1 GB of test-case data for each request β just from the cache server instead of S3. We swapped an expensive S3βapp-server transfer for an equally expensive cacheβapp-server transfer. No win.

Local cache wins. Each app server caches some test cases on its own local HDD:
- The request comes to the app server.
- It hits SQL to fetch user/problem info (small data).
- If the problem's test cases are already on this server's HDD, use them β no network overhead.
- If not cached, download from S3, save locally on the HDD, then use them.
- Any subsequent request for the same problem now reads from the HDD.

POST /evaluate-submission
fn check_solution(request):
pid = request.solution.problem_id
uid = request.auth_token.uid
user_info, problem_info = fetch_from_SQL(user_id, pid)
if not file_exists(`{pid}_in.txt`): // caching logic
download_from_s3(url='s3.hld-bucket-com/testcases/{pid}_in.txt',
save_to='/users/scaler/testcases/{pid}_in.txt')
download_from_s3(url='s3.hld-bucket-com/testcases/{pid}_out.txt',
save_to='/users/scaler/testcases/{pid}_out.txt')
// saves the files on the local HDD
// these reads are from the local HDD
inputs = read_file(`/users/scaler/testcases/{pid}_in.txt`)
expected_outputs = read_file(`/users/scaler/testcases/{pid}_out.txt`)
return evaluate(solution.code, inputs, expected_outputs)
Single vs Distributed?
N/A β single vs distributed only applies to global caches. A local cache is automatically "distributed," because there are multiple app servers each holding their own slice.
Step 4 β Invalidation algorithm
(We'll cover eviction in Step 3 below; invalidation is the meaty part here.) Invalidation is only relevant when the data changes. So: can test cases change?
- Adding a new problem is not a data update.
- But test-case data for an existing problem can absolutely be updated β our problem setters aren't gods. The editorial solution might have been wrong (fix the solution + the expected output), or the test cases weren't "tight enough" (e.g. the problem needed O(n log n) but the tests let O(nΒ²) pass), so we fix both the input and expected-output files.
How often? Extremely rarely β say one update per problem per year on average. So can we just ignore this edge case?
If we discover bad test cases during a live contest, we can't shrug and say "I thought it'd be so rare I didn't implement it." The logic and infrastructure must be in place. Rare β ignorable when the stakes are high.
What consistency do we need?
Imagine that mid-contest we realise some test cases are wrong. The problem setters create new test cases, upload them to S3, and we announce to all participants: "test cases for P3 are updated β please resubmit."
| Consistency | What it means here | Acceptable? |
|---|---|---|
| Eventual | After the update + announcement, for some time (next ~10 min) resubmissions to P3 still use the old test cases. | β Not good enough |
| Immediate | After the update, any submission to P3 uses the new test cases right away. | β Required |
So we need immediate consistency, which points straight at write-through. But let's explore the other options first β just for learning.
Why TTL fails here
With TTL, each downloaded file gets an expiry. If a request comes before expiry, use the cached copy; otherwise delete it and re-fetch from S3.
fn check_solution(solution):
pid = solution['problem_id']
user_info, problem_info = fetch_from_rds(pid)
if file_exists(`{pid}_in.txt`):
if read_last_updated_at(`{pid}_in.txt`) < now() - (1 hour):
// downloaded more than 1 hour ago β stale!
delete_file(`{pid}_in.txt`) // TTL invalidation
if not file_exists(`{pid}_in.txt`): // caching logic
download_from_s3([`{pid}_in.txt`, `{pid}_out.txt`])
// saves on the local HDD
inputs = read_file(`{pid}_in.txt`) // reads from local HDD
expected_outputs = read_file(`{pid}_out.txt`)
return evaluate(solution.code, inputs, expected_outputs)
What's the ideal TTL? Walk the options:
- 1 week / 1 day / 10 hours β by the time the TTL expires the contest is over; the updated test cases never took effect.
- 1 hour / 10 min / 1 min β for the few minutes after the announcement the old test cases are still in use. Bad user experience.
- 10 s / 1 s β every minute the TTL expires and each app server re-downloads 5β10 GB from S3. With 1,000 code-judge app servers that's 5 TB of data transfer per minute. The miss rate is too high β it defeats the entire purpose of caching.
Surely there's a sweet spot? No β there is absolutely no sweet spot. Every value is a bad experience in some way.
Why write-around and write-back also fail
- Write-around: same problem as TTL β you'd have to decide how often the CRON job runs, and again there's no ideal value.
- Write-back: stupid here β it leads to data loss, and with a local cache you'd have to write to all 1,000 app servers on every write.
Write-through⦠but cleverly (versioned filenames)
Can we update 1 GB files across hundreds of app servers + S3 atomically? Absolutely not. Maintaining atomicity across 2 servers is already insanely hard and slow; across 100s it's impossible β at least one will fail and you'd have to roll back the other 99, and you'd be limited by the slowest server (brutally slow writes). So we do something smarter:

- Version the test-case files by putting a timestamp in the filename:
p1_input.txtβp1_input_2025-03-16_08:00.txt. - The normal cache works as before, with one change: when a request arrives, the SQL DB (queried for user + problem info anyway) also returns the current input/output filenames for this problem. If those files are on the HDD, use them; if not, fetch from S3 and use them.
- For invalidation, the problem setter uploads the new files (new timestamp) to S3, then updates the filename in the SQL DB. These two writes don't have to be atomic β upload to S3 first, and only if that succeeds update the filename in SQL. (If the new file is in S3 but SQL isn't updated yet, no harm β the new file simply isn't used yet.)
- Any request after the SQL entry is updated gets the new filename, the app server sees it lacks those files on its HDD, fetches them from S3, and uses them. Immediate consistency, achieved.

fn check_solution(request):
pid = request.solution.problem_id
uid = request.uid
user_info, problem_info = fetch_from_rds(uid, pid)
input_file_name = problem_info.input_file_name // version comes from SQL
output_file_name = problem_info.output_file_name
if not file_exists(input_file_name): // caching logic
download_from_s3([input_file_name, output_file_name])
// saves on the local HDD
inputs = read_file(input_file_name) // reads from local HDD
expected_outputs = read_file(output_file_name)
return evaluate(solution.code, inputs, expected_outputs)
We cache the test cases, but we do not cache the version id β the version id is fetched from the DB every time, so it can never be stale. Because we never modify existing files (we upload a new version instead), the test-case files are immutable. Immutable data never changes, so it never needs invalidation!

The app server is only doing eviction, not invalidation. The problem setter does invalidation β by "invalidating" the old test-case filenames and replacing them with the new filenames in the SQL database.
Step 3 β Eviction algorithm
LRU eviction, on each app server. The operating system already maintains read/write timestamps for every file, so we just use those: find all files in the folder, and when we're out of space, delete the least-recently-used one.
fn check_solution(solution):
pid = solution['problem_id']
user_info, problem_info = fetch_from_rds(pid)
file_name = problem_info['file_name'] // version from the SQL DB
if not file_exists(`{file_name}_in.txt`): // caching logic
if get_folder_size('.') > 100GB:
delete(get_oldest_accessed_at_file('.')) // LRU eviction
download_from_s3([`{file_name}_in.txt`, `{file_name}_out.txt`])
// saves on the local HDD
inputs = read_file(`{file_name}_in.txt`) // reads from local HDD
expected_outputs = read_file(`{file_name}_out.txt`)
return evaluate(solution.code, inputs, expected_outputs)
Step 5 β Load balancer
Recall the mental model: why do we have more than one server? Here it's because evaluation is heavy compute, not because the working set must be sharded β so round robin is the answer, with each app server acting independently. But let's prove the alternatives are worse.

| LB strategy | What happens |
|---|---|
| Round robin | Any request β any app server. Every server ends up caching all ~100 problems being solved today. Looks wasteful, but isn't. |
| Consistent hashing by user_id | Same outcome β any user can solve any problem and users are spread across servers, so every server still caches all of today's problems. |
| Consistent hashing by problem_id | P1βserver 1, P2βserver 2, β¦ so only server 1 caches P1's tests. But this is a bad design. |
During a contest, 100k people are solving just 5 problems. Out of 100 app servers, only 5 would receive all the load β terribly uneven distribution. And in our case a single server can't even handle multiple requests at the same time.
Why we can't just multi-thread the code judge
Normally you handle concurrent requests with multi-threading. Is that a good idea here? No β we cannot multi-thread the code judge, because code evaluation is a CPU-bound task.
Most programs are I/O-bound β they spend their time waiting for I/O (a keystroke, a network download, a file read, the printerβ¦). CPUs are millions of times faster than disks/networks, so for ~99.99% of the time the CPU is idle. Multi-threading lets you do several things "at once" by context switching: start task 1, and when it blocks on I/O, switch to task 2; when task 2 blocks, switch back; and so on.
But for CPU-bound tasks (video processing, heavy computation, analytics, ML, SHA calculation, β¦) context switching worsens performance β the CPU is already 100% busy, so breaking its loop to multi-task just slows everything down. This is thrashing.
Moral: only multi-thread / async-IO your I/O-bound processes. Never multi-thread CPU-bound processes.
In the code judge, evaluating a single request takes ~5 seconds, during which the app server is fully occupied and can't take another request. Therefore we want each request to go to the next available server β which is exactly round robin routing.
4 Case study: the Scaler Contest Leaderboard
Imagine a class race where everyone checks the scoreboard constantly to see who's winning. Working out the full ranking β adding up every student's points and sorting everyone β takes the teacher a few minutes. If the teacher recomputed it from scratch every single time a kid asked "what's my rank?", she'd never finish. So instead she recomputes the whole board once every half hour and pins it to the wall; everyone just reads the pinned copy. It's a tiny bit out of date, and that's totally fine.
The leaderboard is a paginated rank list. For each rank it shows the user's details and, for each contest problem, the problem details plus the user's score and submission details. Users ask two things: "show me page 23" and "show me my rank." Let's run the 5-step process.

The situation & the numbers
A 3-hour contest, 100,000 participants, 5 problems.
Assume on average 1 submission per participant per problem.
Total submissions = 1 submission / (participant Γ problem) Γ 5 problems Γ 100,000 participants = 500,000 submissions during the entire contest Average submissions per second = 500,000 / 3 hours = 500,000 / (3 Γ 3600 s) = 500,000 / 10,000 s = 50 submissions / second Peak load (start & end of contest run hotter) = 2Γ average = 100 submissions / second
Those 100 submissions/sec are evaluated by the code judge (previous case study). For each one, the code judge writes the final verdict/score to SQL. From that collective data we must compute and show the leaderboard.

Step 1 β Establish the need
How do we compute the rank list?
We gather, by joining ~10 tables:
- What contest is running (
conteststable) - Which users participate (join of
users,contests,contest_participants) - Which problems are in the contest (join of
problems,contests,contest_problems) - What submissions users made (join of
users,user_submissions,problems,contest_problems)
Then we GROUP BY user_id, compute each user's final score (from individual
submission scores, number of incorrect attempts, time taken, β¦), and sort by final score. The sorted list is
our rank list.
Do the LLD for this and figure out the basic DB schema for these tables β columns, indexes, foreign-key constraints, not-null constraints, etc.
How much data, and how slow?
500,000 submissions, each ~100 bytes (problem id, score, time taken, verdict, user id, contest id, β¦) total = 100 bytes Γ 500,000 = 50 MB
50 MB isn't "large" as storage, but it's a lot to pull in a single query β and we're joining 10s of tables. Even with indexes, that fetch takes 1β2 seconds. Since this is heavy compute, say it takes ~5 seconds to fetch + compute the rank list once.
Assume each user views the rank list ~20 times during the contest (once every 10 min).
Requests / second = (20 views / user / 3 hours) Γ 100,000 users = 2,000,000 views / 3 hours = 2,000,000 / 10,000 s = 200 views / second
Can we compute the rank list 200 times/second when it takes 5 seconds to compute once? That's stupid. The request rate is high and the computation is heavy β so we cache it.
Step 2 β Type of cache
Local vs Global
What does a result look like? A simple JSON page:
[
{rank: 1, user_id: ..., user_name: ..., problems: [{1: ...}, {...}, ...] },
{rank: 2, user_id: ..., user_name: ..., problems: [{1: ...}, {...}, ...] },
{rank: 3, user_id: ..., user_name: ..., problems: [{1: ...}, {...}, ...] },
{rank: 4, user_id: ..., user_name: ..., problems: [{1: ...}, {...}, ...] }
]
How large is one page? (100 bytes / entry) Γ (10 entries / page) = 1 KB / page.
Since each request only needs to fetch ~1 KB, we can happily use a global cache (unlike the
code judge, where each request needed 1 GB).
Single vs Distributed
Total rank-list data = (100 bytes / entry) Γ 100,000 users = 10 MB β tiny!
10 MB fits on a single cache server. 200 queries/sec is nothing β a single Redis server can handle up to 100,000 read requests/sec. The per-request network transfer is just 1 KB (β 200 Kbps of bandwidth total). So the ideal here is a single global cache server (e.g. Redis).

Low scale: data that fits on 1 server (β€ ~10 GB in RAM, β€ ~1 TB on disk). High scale: data that can't fit on 1 server.
Step 4 β Invalidation algorithm
What consistency do we need?
Immediate consistency is always nice to have, but is it critical here? When a user submits, their "true" rank changes. Eventual consistency would mean the leaderboard shows old ranks for some time (say 10 min) even though the true ranks moved. Is that the end of the world? No. It won't cause a bad user experience. So eventual consistency is good enough.
How often does the true ranking change? With every submission β at peak, 100 times/second. But computing the rank list takes ~5 seconds. By the time you've finished computing it, it has already changed ~500 times. Chasing immediate consistency is futile.
How long is "eventual"?
How much delay can we afford β 10 hours? 1 hour? 10 min? 1 min? 10 s? 100 ms? In practice, Scaler invalidates the rank list every 30 minutes, and nobody has ever complained.
Both TTL and write-around give eventual consistency. Which to use?
| Strategy | Verdict for the leaderboard |
|---|---|
| TTL | Easy to implement (Redis supports TTL natively), but on a miss the user's request must wait ~5 s for the heavy recompute. TTL is best when re-fetching from the DB is cheap β it isn't here. |
| Write-around | β A separate app server runs periodically (every 10 min): fetch all submission scores β compute the rank list β store it in Redis. No user request ever pays the 5 s compute. |

Before the CRON job has run, the cache has no rank list. Two options: (1) return an error β "rank list not available yet, it'll appear after 10 minutes"; or (2) warm up the cache before the contest starts so everyone shows rank 1 (or random ranks) at kickoff.
What exactly does Redis store? (the schema)
Two query types: "show me page 23" and "show me my rank." So we store the data twice β keyed by page and keyed by user:
| Key (string) | Value |
|---|---|
contest:3:page:1 | JSON array of 10 entries (ranks 1β10) |
contest:3:page:2 | JSON array of 10 entries (ranks 11β20) |
| β¦ | β¦ |
contest:3:page:10000 | JSON array of 10 entries |
contest:3:user:377 | {rank: 1, user_id: 377, β¦} |
contest:3:user:123 | {rank: 50, user_id: 123, β¦} |
contest:3:user:58 | {rank: 2, user_id: 58, β¦} |
| β¦ (100,000 user entries) | one per participant |
contest:3:page:1 β "[
{rank: 1, user_id: ..., user_name: ..., problems: [{1: ...}, {...}, ...] },
{rank: 2, user_id: ..., user_name: ..., problems: [{1: ...}, {...}, ...] },
... 10 entries
]"
contest:3:user:377 β {rank: 1, user_id: 377, user_name: ..., problems: [...] }
Total data in Redis = 10 MB (pages) + 10 MB (per-user) = 20 MB β each user's entry is stored twice. Serving a request for user 1234 on page 23:
pageEntries = redisClient.get("contest:3:page:23")
myRank = redisClient.get("contest:3:user:1234")
return MakeLeaderboardTable(pageEntries, myRank)
To support "show me the IIT-B leaderboard," add another key family:
contest:3:institution:IIT-B β {institute_name: ..., users: [{rank: 1, user_id, ...}, {...}, ...]}
Step 3 β Eviction algorithm
20 MB of data per contest β no eviction needed. We store this for every live contest. How many run in parallel at once? Max ~10 (usually 1β2). That's ~200 MB total β still no eviction.
Once a contest ends the rank list can't change (no new submissions), so we just dump it into the SQL DB and stop caching it (post-contest views drop and nothing needs recomputing). Eviction rule: once the contest ends (after ~24 hours), dump the leaderboard data into SQL.
Step 5 β Load balancer (for the cache)
There's no LB for the cache β it's a single Redis server. And if it crashes, there's no data loss, because the cache holds no "real" data. We just restart it; the CRON job runs automatically after 10 minutes (or we force it to run once the cache is back up).
5 Deep dive: Redis & naming technologies in interviews
Redis is like a super-fast notebook you keep on your desk (in your head, really) instead of a filing cabinet in the basement. Looking something up is instant because it's all right there in front of you β but if you spill coffee on the desk, the notes are gone, so anything important also lives in the basement.
What is Redis?
Redis is by far the most popular solution for caches (and key-value databases). It is a very fast, in-memory, key-value database.
- Fast because: it's written in C; it's single-threaded (uses async-IO instead of threads β threads need locks, which are extremely slow, and threads have high overhead because context switching is slow); and it has lots of low-level optimizations.
- In-memory: uses RAM; by default stores nothing on disk. RAM is ~100,000Γ faster than disk for random access. (Optional, off by default: disk persistence β which reduces write speed.)
- Key-value: no complex joins, no indexes, no complex queries, no search. Think of it as a hashmap in memory.
- Powerful primitives: complex datatypes (sorted sets, bloom filters, custom types) and powerful atomic operations (e.g. increment).
- Database: technically a database, but mostly used as a global cache (single or distributed).
Even though we said "use Redis" above, you should never name a specific technology in an interview. Don't say Kafka, Redis, or Postgres β say persistent message queue, in-memory key-value cache, or relational database.
Why? Naming a tech opens the door for the interviewer to deep-dive their way: "Why Kafka and not RabbitMQ or SQS?" Now you must justify the choice and know several technologies cold. Deep-diving is good β but you want to choose where you deep-dive (to your strengths), not let the interviewer pick.
So what should you actually say? Something like: "We'll use a single global cache server here β a fast, in-memory key-value cache, something like Redis or Memcached or similar." And if pushed to "choose one / how would you choose":
- "I've worked with Redis before, so I might prefer that."
- "But I'd check what other teams/projects use β if my team already uses X extensively, I'd use X to benefit from their expertise and existing infra."
- "I'd weigh cost (maybe Memcached is ~2Γ cheaper than Redis), the load each can handle, and the features each offers."
- "I'd research before choosing one."
Mandatory (everyone): try Redis online at onecompiler.com/redis; the Redis quick-start; the Redis cheatsheet. Optional (mandatory for SDE-2+): the Redis University tutorial, the Redis docs, and the eviction-policies & cluster-mode doc.
Bonus: a leaderboard with Redis sorted sets
Beyond the CRON/write-around design above, Redis's sorted set (type
ZSET) is a classic alternative for "always-sorted" rankings β it keeps members ordered
by a numeric score with O(log N) inserts and rank lookups. The commands all start with Z:
# Add or update a player's score ZADD leaderboard 980 user:42 ZINCRBY leaderboard 20 user:42 # bump user 42 by 20 # Top 10, highest first, with scores ZREVRANGE leaderboard 0 9 WITHSCORES # What is user 42's rank? (0-based; REV = highest is rank 0) ZREVRANK leaderboard user:42 # One player's score ZSCORE leaderboard user:42 # Page 2 (ranks 11-20) ZREVRANGE leaderboard 10 19 WITHSCORES
This turns "give me the top N" and "what's my rank?" into single O(log N) commands instead of an O(N) DB scan β handy when you want immediate updates rather than a 30-minute CRON recompute.
ZADD/ZREVRANGE/ZREVRANK)
give an O(log N) always-sorted leaderboard when you want one.
6 Case study: the Social Media Newsfeed
Imagine a school where everyone has a mailbox. When your friend draws a picture, there are two ways you could see it. Way one: your friend runs around and drops a copy into every friend's mailbox right away β so when you open your box, the picture is already there. Way two: nobody copies anything; instead, when you want to look, you run to all your friends' desks and gather their latest drawings yourself. Both work β but which is better depends on how many friends are drawing and how many mailboxes need a copy.
A newsfeed (Twitter/X timeline, Instagram feed) shows a user the recent posts of everyone they follow, newest first. The central design question is when do we assemble each user's feed β at the moment a post is created, or at the moment a user opens the app? These are the two fan-out strategies.
Fan-out on write (push model)
Fan-out on write means: the instant a user posts, we push a copy of that post into the precomputed feed (a cached timeline) of every follower. When a follower opens the app, their feed is already built β just read it.
- Reads are blazing fast β the feed is precomputed and cached, so opening the app is one cheap lookup. This matters because reads vastly outnumber writes (people scroll far more than they post).
- Writes are expensive β one post triggers many cache writes (one per follower).
Fan-out on read (pull model)
Fan-out on read means: do nothing special when posting. When a user opens the app, pull the latest posts from everyone they follow and merge them on the fly.
- Writes are cheap β a post is saved once, full stop.
- Reads are expensive β every feed open must gather and merge posts from possibly hundreds of followees, every time.
| Fan-out on write (push) | Fan-out on read (pull) | |
|---|---|---|
| Work happens at⦠| post time | feed-open time |
| Read speed | β‘ instant (precomputed) | π’ slow (assemble live) |
| Write cost | high (one write per follower) | low (save once) |
| Storage | more (a copy in every follower's feed) | less (single source post) |
| Great for⦠| normal users with modest follower counts | users who follow many but post rarely; inactive users |
The celebrity / hot-key problem
A celebrity has 50 million followers. With pure fan-out on write, one tweet means 50 million cache writes β a single post causes a write storm that can take minutes and hammer the system. This is the hot key problem from Session 4 wearing a costume: one super-popular producer whose every action triggers enormous fan-out work.
The reverse is also bad: if we used pure fan-out on read for everyone, every single feed-open would have to pull from the celebrity (and everyone else) live β slow reads for the entire platform. Neither pure strategy wins alone.
The hybrid approach (what real systems do)
Production systems (this is famously roughly how Twitter's timeline works) combine both:
- Normal users β fan-out on write. A user with a few hundred followers can afford to push their post into all those cached feeds cheaply.
- Celebrities / hot accounts β fan-out on read. We do not push their posts. We mark them as "celebrity" once their follower count crosses a threshold.
- At read time, merge. When you open the app, you read your precomputed feed (full of normal-user posts pushed to you) and pull-in the recent posts of the few celebrities you follow, merging them by time.
This gives us the best of both: cheap reads for the common case (no 50-million-write storms when a celebrity posts), and fast feeds because only a handful of celebrity pulls are merged in at read time, not hundreds of ordinary followees.
We store each user's precomputed feed as a capped, ordered cache entry β a Redis list or sorted set holding just the most recent ~800 post IDs (we cap it; nobody scrolls back forever, and capping bounds memory).
# Fan-out on write: a normal user "alice" posts (id 9001)
for follower in followers(alice): # skip if alice is a celebrity
LPUSH feed:{follower} 9001 # prepend the new post id
LTRIM feed:{follower} 0 799 # cap the cached feed at 800
# Read time: build Bob's timeline (hybrid merge)
ids = LRANGE feed:bob 0 49 # precomputed page (normal followees)
celeb = recent_posts_of(celebrities_followed_by(bob)) # pulled live
feed = merge_by_time(ids, celeb)[:50] # combine & sort newest-first
The post bodies (text, images) are cached separately by post ID with cache-aside, so storing IDs in each feed keeps feeds tiny and avoids duplicating post content 50 million times. We hydrate the IDs into full posts only for the page the user actually views.
There is no single "right" fan-out strategy β the right answer is to split your users by their fan-out cost. Push for the many cheap producers, pull for the few expensive ones, and merge at read time. That hybrid is the standard cure for the celebrity hot-key problem.
β Putting it all together
Three very different products, one toolkit and one 5-step process. Here's the one-paragraph story tying them together:
Caching is never about memorising one trick β it's about asking "what do I drop when full?" (eviction) and "how do I stay honest when data changes?" (writes + invalidation), then walking the 5 steps: establish the need, choose the type, pick eviction (LRU), pick invalidation (from your consistency needs), and choose the load balancer. The code judge needs a local cache (1 GB test cases on each server's HDD), achieves immediate consistency with a write-through-via-versioned-filenames trick (immutable files, version id read fresh from SQL), evicts with LRU, and routes round robin because evaluation is CPU-bound. The leaderboard is heavy to compute but tiny to store, so it uses a single global Redis cache, write-around via a CRON recompute (eventual consistency is fine, immediate is impossible), and no LB. The newsfeed trades read vs write cost via fan-out, going hybrid to dodge the celebrity hot-key problem. Same toolkit, three answers.
Quick self-check
Why does a global cache fail to help the code judge, when a local cache works?
Each request needs ~1 GB of test-case data. A global cache would still force a 1 GB transfer over the network (cache β app server) per request β just as expensive as fetching from S3. A local cache on each app server's HDD avoids the network transfer entirely after the first download.
How does the code judge get immediate consistency without atomic writes across 1,000 servers?
Version the test-case files by timestamp in the filename and store the current filename in SQL. On upload, write the new file to S3 first, then update the filename in SQL (non-atomic is fine). The version id is always read fresh from SQL (never cached β never stale), and the files themselves are immutable, so they need no invalidation β the app server only does LRU eviction.
Why does the code judge use round-robin and refuse to multi-thread?
Code evaluation is CPU-bound, and multi-threading only helps I/O-bound work β context switching a busy CPU causes thrashing. Each evaluation occupies a server for ~5 s, so requests should go to the next available server: round robin. Routing by problem_id would overload the few servers holding a contest's 5 problems.
Why is immediate consistency impossible for the leaderboard, and what do we use instead?
The true ranking changes ~100 times/second (every submission), but computing the rank list takes ~5 seconds β by the time you finish it's changed ~500 times. So eventual consistency is the only option (and it's fine). We use write-around: a CRON job recomputes the rank list every ~10β30 min and stores it in Redis.
Why store the leaderboard twice in Redis (by page and by user)?
There are two query types β "show page 23" and "show my rank." Keying by
contest:3:page:N answers paging in one GET, and contest:3:user:ID
answers "my rank" in one GET. It doubles storage (10 MB β 20 MB), which is trivial.
In an interview, why shouldn't you say "I'll use Redis"?
Naming a specific tech lets the interviewer deep-dive where they choose ("why Redis not Memcached?"), forcing you to defend it. Instead describe the category β "an in-memory key-value cache" β so you control where the conversation goes deep.
What is the celebrity problem, and which earlier concept is it really an instance of?
With fan-out on write, one celebrity post means a copy must be pushed to tens of millions of follower feeds β a write storm. It's the hot-key problem from Session 4: one super-popular key/producer overwhelming the system. The hybrid (push for normal users, pull for celebrities) fixes it.
π References & Further Reading
Class material
- π Original class notes / handout (Google Doc) β open the shared class material for this session.
- Class handout: "[SST-2028] Caching Case Studies".
Redis β mandatory reading (everyone)
- Try Redis online (onecompiler.com/redis) β run Redis commands in the browser.
- Redis quick start.
- Redis cheatsheet.
Redis β optional (mandatory for SDE-2+)
Papers, docs & deep dives
- Redis Sorted Sets documentation β the data structure for an always-sorted leaderboard (ranked, range queries by score).
- System Design Primer β Facebook newsfeed / fan-out β push vs pull fan-out patterns behind the social media newsfeed.
- Kafka: a Distributed Messaging System for Log Processing (original paper) β the log/queue model underpinning newsfeed fan-out pipelines.
- Redis key eviction policies (LRU/LFU) β how the caching layer decides what to drop, tying back to the toolkit recap.