Capstone โ€” Building a Rate-Limited, Cached API

Redis
Chapter 10 ยท Capstone: Building a Rate-Limited, Cached API

๐Ÿ Capstone: Building a Rate-Limited, Cached API

This capstone builds a small, real Product API combining caching (Ch.5), an atomic rate limiter built on Chapter 4's sliding-window pattern, and pub/sub-based cache invalidation (Ch.6) โ€” end to end, in one application.

The Application: A Rate-Limited Product API

Requirements: GET /products/:id should be cached and rate-limited per client; when a product is updated elsewhere (an admin panel), every running API instance should invalidate its own cached copy immediately, without polling.

Step 1: Caching the Product Endpoint (Chapter 5)

The cache-aside pattern, unchanged from Chapter 5:

async function getProduct(id) { const cached = await redis.get(`product:${id}`); if (cached) return JSON.parse(cached); const product = await db.query('SELECT * FROM products WHERE id = ?', [id]); await redis.set(`product:${id}`, JSON.stringify(product), { EX: 300 }); return product; }

Step 2: Rate Limiting With a Lua Script

Chapter 4's sliding-window pattern (prune old entries, then count) has a subtle problem under real concurrency: checking the count and recording the new request are two separate commands โ€” two concurrent requests could both check "under the limit" before either one's new entry is recorded, both slipping through when only one should have. The fix is an atomic Lua script, run with EVAL: Redis executes the entire script as one indivisible unit โ€” no other command, from any other client, can interleave partway through, turning Chapter 2's single-threaded architecture from a caveat into exactly the guarantee this needs.

-- rate_limit.lua local key = KEYS[1] local now = tonumber(ARGV[1]) local window = tonumber(ARGV[2]) local limit = tonumber(ARGV[3]) redis.call('ZREMRANGEBYSCORE', key, '-inf', now - window) local count = redis.call('ZCARD', key) if count >= limit then return 0 -- over the limit end redis.call('ZADD', key, now, now) redis.call('EXPIRE', key, window) return 1 -- allowed
// calling it from Node.js const allowed = await redis.eval(rateLimitScript, { keys: [`ratelimit:${clientId}`], arguments: [String(Date.now()), '60000', '100'] }); if (!allowed) return res.status(429).send('Too Many Requests');

Prune, count-check, and record now all happen inside one atomic EVAL call โ€” no race window exists between checking the limit and recording the request, regardless of how many requests arrive at the same instant.

Step 3: Invalidating the Cache via Pub/Sub (Chapter 6)

When a product is updated, the updating instance publishes the product's ID to a shared channel; every API instance โ€” including ones that never touched the update request โ€” subscribes and deletes its own local cache entry.

// on update async function updateProduct(id, changes) { await db.query('UPDATE products SET ... WHERE id = ?', [id]); await redis.publish('product-updates', id); } // on every instance, at startup subscriber.subscribe('product-updates', async (id) => { await redis.del(`product:${id}`); });

The next getProduct(id) call on any instance after this point sees a genuine cache miss and re-populates from the database โ€” every instance stays consistent with no polling and no shared coordination beyond the channel itself.

Course ConceptWhere It's Used in This App
Cache-aside pattern (Ch.5)getProduct() โ€” cache first, database on miss
Sliding-window rate limiting (Ch.4)The pattern the Lua script implements atomically
Atomic scripting / single-threaded execution (Ch.2)The Lua script's check-then-record race condition fix
Pub/Sub (Ch.6)Broadcasting cache invalidation to every running instance

๐Ÿ’ป Coding Challenges

Challenge 1: Explain the Race Condition

Explain, concretely, how two concurrent requests could both bypass Chapter 4's non-atomic sliding-window rate limiter, even though each one individually checked the count correctly.

Goal: Practice articulating a check-then-act race condition in terms of the specific commands involved.

โ†’ Solution

Challenge 2: Add a Cache-Warming Step

Modify Step 3's updateProduct function so that, instead of just invalidating the cache, it immediately re-caches the fresh product data โ€” avoiding a cache miss on the very next read.

Goal: Practice combining the cache-aside write path with the pub/sub invalidation flow.

โ†’ Solution

Challenge 3: Deploying to Cluster

This app is deployed onto Redis Cluster (Chapter 9). Explain what could break in the rate-limiting Lua script if a client's rate-limit key and cache key don't share a hash tag, and how to fix it.

Goal: Practice applying Chapter 9's hash-tag requirement to a script that only ever touches one key per call versus one that might touch several.

โ†’ Solution

โš ๏ธ Gotcha: Lua Scripts Have the Same Cross-Slot Restriction as Multi-Key Commands

Chapter 9's hash-tag gotcha applies just as much to Lua scripts as to plain multi-key commands: if a script's KEYS table ever includes keys that hash to different slots, Redis Cluster rejects the script entirely. This capstone's rate-limit script only ever touches one key per call, so it's safe as written โ€” but extending it (say, to also update a shared global counter) would need every key involved to share a hash tag, exactly the same discipline Chapter 9 required for any multi-key operation.

๐ŸŽ‰ Course Complete

That's the full Redis course โ€” from the in-memory data model through every core data structure, expiration and caching patterns, pub/sub, queues and streams, persistence, replication/failover/sharding, and finally a real rate-limited, cached, pub/sub-synchronized API combining all of it. Together with MySQL and MongoDB, this completes the site's three-engine database coverage โ€” relational, document, and in-memory.