When I interview for remote roles or sketch an architecture for a client, the same three building blocks appear every time: a cache, a queue and a rate limiter. Know them properly and you can reason about most systems, from a WordPress site surviving a traffic spike to an AI pipeline processing thousands of documents. This post is the version of those fundamentals I wish someone had given me at the start, with code instead of hand-waving.

The shape of a scalable request

┌─────────────┐ client ──▶ CDN ──▶ │ rate limiter│──▶ app ──▶ cache ──miss──▶ database └─────────────┘ │ (hit ▲) │ slow or side-effect work ▼ queue ──▶ workers ──▶ email / search index / reports Reads: served from cache when possible. Writes: acknowledged fast, completed async. Abuse: rejected before it costs anything.

1. Caching

Patterns

PatternHowUse whenCost
Cache-aside (lazy)App checks cache; on miss reads DB and writes cacheDefault. Read-heavy, tolerates brief stalenessFirst read is slow; stale until TTL or invalidation
Write-throughEvery write updates DB and cache togetherReads must see writes immediatelySlower writes; caches data that may never be read
Write-behindWrite to cache, flush to DB asynchronouslyVery high write rates (counters, analytics)Data loss window if cache dies
Read-throughCache itself loads from DB on missYou have a caching library/proxy that supports itSame as cache-aside, less app code

Cache-aside, done properly

The naive version has a bug that only appears under load: when a hot key expires, a thousand concurrent requests all miss, all hit the database, and all write the cache. That is a cache stampede, and it is how a cache makes an outage worse. The fix is a short lock so only one request recomputes while the others wait or serve stale.

// lib/cache.ts  -- cache-aside with stampede protection and stale-while-revalidate
import { redis } from './redis';

export async function cached<T>(key: string, ttlSec: number, loader: () => Promise<T>, staleSec = 30): Promise<T> {
  const raw = await redis.get(key);
  if (raw) {
    const { v, exp } = JSON.parse(raw) as { v: T; exp: number };
    if (Date.now() < exp) return v;                                   // fresh
    // Stale: try to become the single refresher; everyone else gets the stale value immediately.
    const lock = await redis.set(`${key}:lock`, '1', 'NX', 'PX', 5000);
    if (!lock) return v;
    refresh(key, ttlSec, staleSec, loader).catch(() => {});          // background refresh
    return v;
  }
  // Cold miss: one loader runs, others wait briefly then retry.
  const lock = await redis.set(`${key}:lock`, '1', 'NX', 'PX', 5000);
  if (!lock) { await sleep(50); return cached(key, ttlSec, loader, staleSec); }
  return refresh(key, ttlSec, staleSec, loader);
}

async function refresh<T>(key: string, ttlSec: number, staleSec: number, loader: () => Promise<T>): Promise<T> {
  try {
    const v = await loader();
    await redis.set(key, JSON.stringify({ v, exp: Date.now() + ttlSec * 1000 }), 'EX', ttlSec + staleSec);
    return v;
  } finally {
    await redis.del(`${key}:lock`);
  }
}

Invalidation on write is a redis.del(key) (or a tag-based purge if you cache many keys per entity). The WordPress and Node specifics, including full-page and fragment caching, are in Redis Caching Strategies; the framework-level version is Next.js caching.

What to cache, and what not to

  • Yes: rendered pages for anonymous users, product/category listings, config and settings, expensive aggregates, external API responses, query embeddings.
  • No: anything per-user that changes often, anything where a stale read is a correctness bug (stock at checkout, balances), or data cheaper to compute than to serialise.

2. Queues

A queue decouples the moment work is requested from the moment it is done. The HTTP request enqueues a job and returns in 5 ms; a worker does the 3-second email send or 30-second PDF render whenever it gets to it. Three properties make queues powerful and dangerous:

  • Buffering: a spike of 10,000 signups becomes a backlog processed at whatever rate your workers manage. The site stays up.
  • Retries: a failed job runs again with back-off. The consumer must be idempotent, because "at least once" delivery means duplicates.
  • Back-pressure: if producers outrun consumers forever, the queue grows without bound. You need a policy: bounded queue and reject, shed low-priority work, or autoscale workers on queue depth.
// Producer (inside the HTTP handler): fast, durable enqueue
await queue.add('send-welcome-email', { userId }, {
  jobId: `welcome:${userId}`,             // dedupe: same job enqueued twice = one job
  attempts: 5,
  backoff: { type: 'exponential', delay: 2000 },
  removeOnComplete: 1000,
});

// Consumer: idempotent, because retries and duplicates WILL happen
worker.process('send-welcome-email', async (job) => {
  const { userId } = job.data;
  const already = await sql`SELECT 1 FROM email_log WHERE user_id = ${userId} AND kind = 'welcome'`;
  if (already.length) return;                              // duplicate delivery: no-op
  await mailer.send(await buildWelcome(userId));
  await sql`INSERT INTO email_log (user_id, kind) VALUES (${userId}, 'welcome')`;
});

Monitor two numbers: queue depth (how far behind) and oldest job age (how late). Alert on the second; a deep queue that drains fast is fine, a shallow queue with a 40-minute-old job is not. The full implementation with dead-letter queues is in Event-Driven Architecture with Redis and BullMQ.

3. Rate limiting

Rate limiting protects your system from clients that are abusive, buggy or simply more numerous than you planned for. It also protects your wallet: on an LLM-backed endpoint, it is the difference between a bad day and a bad month (see the budgets in my agent architecture post).

Algorithms

AlgorithmBehaviourTrade-off
Fixed windowN requests per calendar minuteSimple; allows 2N at a window boundary
Sliding window logExact count over the last 60 sAccurate; stores every timestamp (memory)
Sliding window counterWeighted blend of current and previous windowGood approximation, two counters
Token bucketTokens refill at rate r; each request takes one; bucket holds bAllows bursts up to b, smooths to r. Usually what you want
Leaky bucketRequests drain at a fixed rateSmooth output; no bursts, adds latency

Token bucket in Redis, atomically

Two round-trips (read, then write) create a race where concurrent requests both see one token. A Lua script runs atomically on the server:

-- token_bucket.lua  KEYS[1]=bucket key  ARGV: rate(tokens/sec), burst, now_ms, cost
local rate, burst, now, cost = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3]), tonumber(ARGV[4])
local data = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(data[1]) or burst
local ts = tonumber(data[2]) or now

local elapsed = math.max(0, now - ts) / 1000
tokens = math.min(burst, tokens + elapsed * rate)          -- refill

local allowed = 0
if tokens >= cost then tokens = tokens - cost; allowed = 1 end

redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', KEYS[1], math.ceil(burst / rate * 1000) + 1000)   -- self-cleaning
return { allowed, tokens }
// middleware/rate-limit.ts
const script = await fs.readFile('token_bucket.lua', 'utf8');

export function rateLimit({ rate, burst, keyFor }: { rate: number; burst: number; keyFor: (req: Request) => string }) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const [allowed, remaining] = await redis.eval(script, 1, `rl:${keyFor(req)}`, rate, burst, Date.now(), 1) as [number, number];
    res.set('RateLimit-Limit', String(burst));
    res.set('RateLimit-Remaining', String(Math.floor(remaining)));
    if (!allowed) { res.set('Retry-After', String(Math.ceil(1 / rate))); return res.status(429).json({ title: 'Too many requests' }); }
    next();
  };
}

// 10 requests/sec sustained, bursts of 30, per authenticated user (fall back to IP)
app.use('/api', rateLimit({ rate: 10, burst: 30, keyFor: req => req.user?.id ?? req.ip }));

Limit by the identity that matters: user ID for authenticated routes, API key for partners, IP only as a fallback (shared IPs behind NAT will punish innocent users). Layer limits: a generous per-user limit, a tighter per-endpoint limit on expensive routes, and a global limit that protects the database no matter what.

Designing for the failure modes you just added

Each block introduces a new dependency, usually Redis. Decide up front what happens when it is unavailable:

  • Cache down: fall through to the database with a circuit breaker so you do not hammer it with timeouts. The site gets slower, not broken.
  • Queue down: either fail the request (if the job is essential) or write the job to a database outbox table and let a worker drain it later. Never silently drop.
  • Rate limiter down: fail open with a lower in-memory limit per process. Failing closed turns a Redis blip into a full outage.
// Fail-open wrapper: if Redis errors or is slow, allow the request but log it
async function safeLimit(fn: () => Promise<boolean>): Promise<boolean> {
  try { return await Promise.race([fn(), sleep(50).then(() => true)]); }
  catch (e) { log.warn({ e }, 'rate limiter unavailable; failing open'); return true; }
}

Putting it together: a traffic spike on a content site

A client's article was picked up by a national news site; traffic went from 2,000 to 180,000 visitors in an hour. What kept it up: Cloudflare cached the HTML for anonymous visitors (cache), the newsletter signups from the article went into a queue and were processed over the next twenty minutes (queue), and a per-IP limit on the comment endpoint absorbed a bot that showed up with the crowd (rate limit). The origin server's CPU never crossed 40%. None of that is exotic; it is these three ideas applied deliberately, and it is the same approach whether the origin is Nginx and PHP or Node.

If you are preparing for system design interviews, this trio plus indexing and service boundaries covers the majority of questions; my interview prep guide has the template I use to structure the answer.