Checkpoint: Production Backend
Reviewed & published by Brayan K
This checkpoint pulls the advanced track together by having you build one small service that rate-limits requests and serves reads from a cache — the two layers that keep real production backends fast and resilient under load.
Part of the free Node.js course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
First you'll recap the whole section, then take on a multi-step build challenge with a starter and a full solution, and finally test yourself with a short checkpoint quiz before moving on to the capstone.
What You've Learned So Far
- cluster — scaling a server across all CPU cores
- WebSockets — real-time, two-way communication
- GraphQL — query exactly the fields you need
- Sequelize — modeling and querying SQL databases
- Redis — caching reads with TTLs and cache-aside
- Security — Helmet, CORS, rate limiting, validation
- CLI tools & npm publishing — building and shipping packages
- Debugging & profiling — inspector, timers, heap snapshots
🔍 Warm-Up: the Two Layers, Running
Before the full build, here are the two ideas at the heart of the service — a token-bucket rate limiter and a cache-aside lookup — as a small runnable program with real output. Notice the database is touched only once even across multiple reads:
// The two production layers at the heart of the challenge, runnable with no
// server: a cache-aside lookup AND a token-bucket rate limiter.
// --- Rate limiter (token bucket): N requests allowed per client ---
function makeLimiter(limit) {
const buckets = new Map();
return (clientId) => {
if (!buckets.has(clientId)) buckets.set(clientId, limit);
const left = buckets.get(clientId);
if (left <= 0) return false;
buckets.set(clientId, left - 1);
return true;
};
}
// --- Cache-aside lookup over a slow source ---
const cache = new Map();
let dbHits = 0;
function slowDb(id) { dbHits++; return { id, name: 'Ada' }; }
function getUser(id) {
if (cache.has(id)) return { source: 'cache', user: cache.get(id) };
const user = slowDb(id);
cache.set(id, user);
return { source: 'db', user };
}
const allow = makeLimiter(2); // 2 requests per client
// Two allowed requests for the same user; second hits the cache.
console.log(allow('ip1'), getUser(1));
console.log(allow('ip1'), getUser(1));
// Third request from the same client is rate-limited.
console.log(allow('ip1'), '-> blocked, no DB call');
console.log('Total DB hits:', dbHits);Your mission, step by step
- Implement allow(clientId) as a token bucket: a new client gets limit tokens, returns false when empty, else spends one.
- Implement readCache(id): return the cached user if present and not expired, else null.
- Implement handle(clientId, id): rate-limit first (429 when over limit), then serve from cache, then fall back to the slow DB and store it.
- Confirm the demo prints one db, two cache, one 429, and dbHits: 1.
// Build a cached + rate-limited service, runnable without any server.
// Fill in the TODOs so handle() rate-limits first, then serves from cache.
function createService({ limit, ttlMs }) {
const cache = new Map(); // id -> { user, expiresAt }
const buckets = new Map(); // clientId -> tokens left
let dbHits = 0;
function slowDb(id) { dbHits++; return { id, name: 'User' + id }; }
// STEP 1 — token-bucket allow(): give a new client 'limit' tokens,
// return false when empty, else spend one and return true.
function allow(clientId) {
// TODO
}
// STEP 2 — readCache(id): return the cached user if present AND not expired,
// otherwise null.
function readCache(id) {
// TODO
}
// STEP 3 — handle(clientId, id):
// - if !allow(clientId) -> { status: 429, body: 'Too Many Requests' }
// - if readCache(id) -> { status: 200, source: 'cache', body: cached }
// - else hit slowDb, store with ttl, return { status: 200, source: 'db', body: user }
function handle(clientId, id) {
// TODO
}
return { handle, stats: () => ({ dbHits }) };
}
const svc = createService({ limit: 3, ttlMs: 60000 });
console.log(svc.handle('ip1', 7)); // expect db
console.log(svc.handle('ip1', 7)); // expect cache
console.log(svc.handle('ip1', 7)); // expect cache
console.log(svc.handle('ip1', 7)); // expect 429
console.log('Stats:', svc.stats()); // expect dbHits: 1Here's a complete, working implementation. The comment at the bottom shows the exact output it prints — one database read, two cache hits, one rate-limited rejection, and a single total DB hit:
📝 Checkpoint Quiz
Test yourself. Think through each answer first, then expand it to check.
So an over-limit client is rejected with a 429 before doing any work. Checking the cache (or database) first would let blocked clients still consume resources, defeating the purpose of throttling.
Cache-aside: the first read for an id misses and hits the database; the next reads of the same id come from the cache; and the over-limit request never reaches the database at all.
Redis (via ioredis) for the shared cache with TTLs, and express-rate-limit (backed by Redis) for the limiter — so both are consistent across all instances.
With the cluster module (one worker per core) or, more practically, pm2 start app.js -i max — and keep shared state (cache, limits) in Redis so workers agree.
A TTL bounds how long stale data can survive and caps memory. On a write/update you should also explicitly invalidate (delete) the key so the next read repopulates fresh data.
console.time for a quick block measurement, node --prof or clinic.js to profile CPU, and heap snapshots in chrome://inspect (watching process.memoryUsage().heapUsed grow) for leaks.
Pro Tips
- 💡 Guard at the edge: limit and validate as early as possible so abusive or invalid requests cost almost nothing.
- 💡 Share state externally: when you scale to many processes, put the cache and rate-limit counters in Redis so every instance agrees.
- 💡 Measure, then scale: profile to find the real bottleneck before reaching for more cores or more caching.
🎉 Checkpoint Complete!
- ✅ You combined a token-bucket rate limiter and a cache-aside lookup into one service
- ✅ Rate limiting runs first, so over-limit requests get a 429 without touching the DB
- ✅ Caching means repeated reads hit the database only once
- ✅ In production, Redis backs the cache and limiter; cluster/PM2 scales across cores
- ✅ TTLs plus write-time invalidation keep cached data fresh
- ✅ You reviewed cluster, websockets, graphql, sequelize, redis, security, CLI, publishing, and debugging
Practice quiz
In this service, why must rate limiting run BEFORE the cache lookup?
- Because the cache is slower than the limiter
- Because the cache cannot run without a token
- So an over-limit client gets a 429 before doing any work, never touching the cache or DB
- It does not matter which order they run in
Answer: So an over-limit client gets a 429 before doing any work, never touching the cache or DB. Putting the limiter first means a blocked request returns immediately and never exercises the cache or database, which is the whole point of throttling.
Which status code does the handler return when a client is over its limit?
- 429
- 200
- 401
- 503
Answer: 429. 429 Too Many Requests is the standard code for a client that has exceeded its rate limit.
In the demo, three reads of the same id happen but dbHits is only 1. Why?
- The database deduplicates queries
- The rate limiter blocks the extra DB calls
- slowDb() only runs once per process by design
- Cache-aside: the first read misses and hits the DB; later reads of that id come from the cache
Answer: Cache-aside: the first read misses and hits the DB; later reads of that id come from the cache. The first request for an id is a cache miss that calls slowDb (dbHits becomes 1); subsequent reads of the same id are served from the cache.
How does the token-bucket allow() decide to reject a client?
- It checks the system clock against a window
- It gives a new client limit tokens, returns false when the count hits zero, else spends one
- It compares the client IP against a blocklist
- It rejects every other request
Answer: It gives a new client limit tokens, returns false when the count hits zero, else spends one. A new client starts with limit tokens; each allowed request spends one, and once the bucket is empty allow() returns false.
What does readCache(id) return when the entry exists but is past its expiry time?
- null
- The stale cached user
- undefined
- It throws an error
Answer: null. readCache compares Date.now() to expiresAt and returns null for an expired entry, forcing a fresh slowDb read.
In production, which libraries replace the in-memory cache Map and the token bucket?
- localStorage and setInterval
- A plain JSON file and a for-loop
- Redis (e.g. via ioredis) for the cache, and express-rate-limit (backed by Redis) for the limiter
- MongoDB for both
Answer: Redis (e.g. via ioredis) for the cache, and express-rate-limit (backed by Redis) for the limiter. Redis gives a shared cache with TTLs and a shared rate-limit store, so both stay consistent across every instance.
How would you run this service across all CPU cores?
- Call setMaxListeners(0)
- Use the cluster module or pm2 start app.js -i max, keeping shared state in Redis
- Increase the Node heap size
- Run multiple copies on different ports manually
Answer: Use the cluster module or pm2 start app.js -i max, keeping shared state in Redis. cluster (one worker per core) or PM2 cluster mode scales across cores; shared cache and limit state must live in Redis so workers agree.
Cache entries carry a TTL. Besides expiry, what else should invalidate a key?
- Nothing — TTL is enough
- Restarting the whole process
- A second read of the same key
- An explicit delete on write/update so the next read repopulates fresh data
Answer: An explicit delete on write/update so the next read repopulates fresh data. A TTL bounds staleness and caps memory, but on a write you should also delete (invalidate) the key so the next read fetches current data.
Which tool would you reach for to profile a CPU-bound slow endpoint here?
- npm audit
- node --prof or clinic.js
- eslint
- nodemon
Answer: node --prof or clinic.js. node --prof and clinic.js profile CPU usage to locate hot paths; heap snapshots in chrome://inspect are for tracking memory leaks.
On a successful database read, what does handle() do before returning the user?
- Nothing — it returns immediately
- It refills the token bucket
- It stores the result in the cache with an expiresAt of Date.now() + ttlMs
- It logs the user to Redis
Answer: It stores the result in the cache with an expiresAt of Date.now() + ttlMs. After a cache miss, handle stores the fresh user with an expiry so subsequent reads within the TTL are served from the cache.
Continue this course
- Previous: Debugging & Profiling Node
- Next: Capstone: A REST API — Tie everything together by building a complete REST API from scratch
- Quick reference: Node.js cheat sheet
Frequently asked questions
Why rate-limit BEFORE checking the cache?
Order matters for protection. Rate limiting is your outer gate: it should reject abusive clients with a 429 before your service does any work at all — including cache lookups. If you checked the cache first, a flood of requests would still exercise your cache (and, on misses, your database) even for clients you intend to block, which defeats the point of throttling them. By putting the limiter first, an over-limit request costs almost nothing: it never touches the cache, never reaches the database, and returns immediately. In a real Express app this is exactly why express-rate-limit is registered as middleware ahead of your route handlers.
How would this simulated service map to a real production backend?
Each piece swaps in for a real component. The token-bucket allow() becomes express-rate-limit (backed by Redis so the limit holds across all your instances and behind the cluster module). The in-memory cache Map becomes Redis with GET/SET and a TTL, shared by every process. slowDb() becomes a real Mongoose or Sequelize query. handle() becomes an Express route guarded by helmet, cors, the rate limiter, and validation middleware. You'd run it under PM2's cluster mode across all cores, add pino logging, and debug or profile it with node --inspect and clinic.js. The simulation captures the control flow; production replaces each stub with the library you learned in its lesson.
Why does the service only hit the database once in the demo?
Because of cache-aside. The first request for user 7 is a cache miss, so it calls slowDb() (incrementing dbHits to 1) and stores the result. The second and third requests for the same id find it in the cache and return immediately without touching the database, so dbHits stays at 1. The fourth request is rejected by the rate limiter before any lookup happens. That's the whole value of caching in one number: three successful reads, but only a single expensive database call — multiply that ratio across real traffic and you dramatically cut database load and latency.
What would I add to make this service truly production-ready?
Several layers beyond cache and rate limiting. Security: helmet headers, a CORS allowlist, input validation, and secrets in environment variables. Persistence: a real database via Sequelize or Mongoose with migrations. Scale: run under cluster or PM2 across all CPU cores, with Redis as the shared cache and rate-limit store so the limits and cache are consistent across instances. Observability: structured logging (pino), and the ability to debug with node --inspect and profile with clinic.js. Reliability: graceful shutdown, health checks, and error handling that returns proper status codes. Finally, package and ship it — and if part of it is a reusable library, publish it to npm. Each of those is a lesson in this section coming together.