Node.js NotesRohit’s interview study guide
Chapter 12

Caching & Rate Limiting

Doing less work per request and limiting how many requests you accept — cache-aside, invalidation, stampedes, and rate-limit algorithms.

9 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Why cache, and what to cache

A cache is a copy of a result kept somewhere faster than where it came from, so that the next request for the same thing can skip the expensive part. The expensive part is usually I/O you're waiting on: a database query, a call to another service, an external API with per-call pricing — or CPU work like rendering or a heavy aggregation. The cache trades freshness and memory for latency and load.

My notes start with "Read about API caching in nodejs". The core idea fits in one sentence: caching pays off when the same result is read much more often than it changes, and when serving a slightly old copy is acceptable. A product page read 2,000 times a second and edited twice a day is a perfect candidate; a bank balance shown just before a transfer is not.

The numbers show why a good hit ratio matters so much:

hit-ratio.cjs
const requestsPerSec = 2_000;
const dbMs = 20;      // a database read
const cacheMs = 1;    // a Redis GET on the same network

for (const hitRatio of [0, 0.5, 0.9, 0.99]) {
  const dbReadsPerSec = Math.round(requestsPerSec * (1 - hitRatio));
  // a miss pays for the cache lookup AND the database read
  const avgMs = hitRatio * cacheMs + (1 - hitRatio) * (cacheMs + dbMs);
  console.log(`hit ratio ${String(hitRatio * 100).padStart(2)}%  db reads/s ${String(dbReadsPerSec).padStart(4)}  avg latency ${avgMs.toFixed(1)} ms`);
}

// hit ratio  0%  db reads/s 2000  avg latency 21.0 ms
// hit ratio 50%  db reads/s 1000  avg latency 11.0 ms
// hit ratio 90%  db reads/s  200  avg latency 3.0 ms
// hit ratio 99%  db reads/s   20  avg latency 1.2 ms
What’s happening
  1. Every request either hits (served from the cache in 1 ms) or misses (pays the 1 ms lookup, then the 20 ms database read). Only misses reach the database.
  2. At a 0% hit ratio the cache is pure overhead: 21 ms instead of 20, and the full 2,000 reads per second still hit the database.
  3. At 90% the database sees 200 reads per second — a 10× reduction — and average latency drops to 3 ms. At 99% it's 20 reads per second, a 100× reduction.
  4. The jump from 90% to 99% cuts database load by another 10×, which is why cache keys, TTLs and warm-up get tuned so carefully. And notice what the average hides: the 1% of misses still take 21 ms, so p99 latency barely improves (see What to measure).
AdvancedReference: caches at every layer

Caches exist at every layer, and the ones closest to the user save the most:

LayerExampleWhat it saves
BrowserCache-Control: max-age=…, ETagThe whole request, or the body bytes (HTTP caching headers)
CDN / reverse proxyCloudflare, CloudFront, nginxRequests ever reaching your servers
In-process memorya Map, lru-cacheA network hop to Redis and the DB
Shared cacheRedis, MemcachedDatabase and downstream-service load
Databaseits own buffer pool and query plansDisk reads (you don't manage this one)

What to cache, and what not to:

  • Good candidates: read-heavy, slow-changing data (product catalogues, configuration, feature flags, exchange rates refreshed every minute), expensive computed results (aggregations, rendered fragments), and responses from slow or rate-limited third-party APIs.
  • Be careful with: anything per-user or per-tenant (the key must include that scope — see Cache invalidation and cache keys), and data whose staleness has a cost you can name.
  • Don't cache: values used for security or money decisions at the moment of the decision (permissions during an authorisation check, stock during checkout, balances during a transfer), one-off results nobody reads twice, and errors — a cached failure turns a 1-second blip into a 30-second outage.
AdvancedNot the module cache

#Cache-aside with a TTL

Cache-aside (also called lazy loading) is the default pattern: the application owns both the cache and the database, and the database stays the source of truth.

  1. On a read, look in the cache. Hit: return it.
  2. Miss: read the database, store the result in the cache with a TTL, return it.
  3. On a write, update the database, then delete the cached entry (next topic).

TTL (time to live) is how long an entry may be served before it must be re-read. It's your staleness budget: with a 30-second TTL, a reader can see data up to 30 seconds old. Pick it from how often the data changes and how much staleness the feature tolerates, not from a habit. This is the cache from the ChatGPT guide (CG §16), with a fake clock added so you can watch expiry without waiting:

cache.cjs
const TTL_MS = 30_000;
const MAX_ENTRIES = 100;
const cache = new Map(); // key -> { value, expiresAt }

let clock = 0;                 // fake time so we can jump forward
const now = () => clock;
let databaseReads = 0;

async function readDatabase(id) {
  databaseReads++;
  return { id, name: 'Keyboard', loadedAt: now() };
}

async function getProduct(id) {
  const key = `product:${id}`;
  const entry = cache.get(key);

  if (entry && entry.expiresAt > now()) {
    return entry.value;                       // hit
  }
  cache.delete(key);                          // expired (or absent)

  const product = await readDatabase(id);     // miss: go to the source of truth

  if (cache.size >= MAX_ENTRIES) {
    cache.delete(cache.keys().next().value);  // evict the oldest insertion
  }
  cache.set(key, { value: product, expiresAt: now() + TTL_MS });
  return product;
}

(async () => {
  const log = async (label, id) => {
    const p = await getProduct(id);
    console.log(`${label.padEnd(18)} loadedAt=${p.loadedAt} reads=${databaseReads} size=${cache.size}`);
  };
  await log('t=0      id 42', 42);  // miss
  await log('t=0      id 42', 42);  // hit
  clock = 29_999;
  await log('t=29.999 id 42', 42);  // still a hit
  clock = 30_000;
  await log('t=30     id 42', 42);  // expired -> miss
  await log('t=30     id 7', 7);    // different key -> miss
})();

// t=0      id 42     loadedAt=0 reads=1 size=1
// t=0      id 42     loadedAt=0 reads=1 size=1
// t=29.999 id 42     loadedAt=0 reads=1 size=1
// t=30     id 42     loadedAt=30000 reads=2 size=1
// t=30     id 7      loadedAt=30000 reads=3 size=2
What’s happening
  1. First call at t=0: cache.get('product:42') is undefined, so it's a miss. readDatabase runs (databaseReads 0 → 1) and the entry is stored with expiresAt = 0 + 30 000. Size is 1.
  2. Second call at t=0: the entry exists and 30 000 > 0, so it's a hit. The database isn't touched — reads stay at 1 and loadedAt is still 0, proving it's the cached object.
  3. At t=29 999 it's still a hit: 30 000 > 29 999. At t=30 000 the check 30 000 > 30 000 is false, so the entry counts as expired. It's deleted, the database is read again (reads 2), and the new entry has loadedAt=30000 and a new expiry of 60 000.
  4. id 7 is a different key, so it misses regardless of the other entry: reads 3, size 2.
  5. Expired entries are only removed when someone asks for them (lazy expiry). The MAX_ENTRIES check is what stops the Map from growing forever with keys that are never read again: once it's full, the oldest inserted key is evicted (a Map iterates in insertion order). That's FIFO, not LRU — a hot key inserted early gets evicted before a cold one inserted later.
AdvancedPitfall: mutating the cached object
AdvancedEdge case: concurrent misses
AdvancedProduction option: lru-cache

For real code, use a maintained in-memory cache rather than a hand-rolled Map: lru-cache gives you true LRU eviction, per-entry TTLs and a size limit in bytes as well as entries (new LRUCache({ max: 500, ttl: 30_000 })). The JS site walks through building both versions: In-memory cache with a TTL and LRU cache.

#In-memory vs distributed caches

From my notes: the two options are an in-memory cache inside each Node process, or a distributed cache (Redis or Memcached) that every instance talks to over the network.

The mental model: an in-memory cache is a notebook on each employee's desk; Redis is a whiteboard in the hallway. Reading your notebook is instant, but every employee's notebook is different, and when you go home (the process restarts) it's gone. The whiteboard is a short walk away, everyone sees the same thing, and it's still there tomorrow — but if the hallway is blocked, nobody can read it.

In-memory (per process)Distributed (Redis, Memcached)
Lookup costNanoseconds, no I/OA network round trip (often ~0.2–1 ms) plus serialisation
Shared between instancesNo — 10 pods means 10 separate cachesYes
Hit ratio with N instancesEach instance warms its own copyOne warm copy for everyone
Survives a restart or deployNoYes (until evicted or expired)
InvalidationEach process must be told separatelyOne DEL
MemoryCompetes with your app's heap and GCA separate, sized service with an eviction policy
Failure modesFewNetwork errors, timeouts, failover, an outage to handle

Pros and cons of in-memory caching, from my notes: it's the fastest option and needs no infrastructure, but it's limited by the process's memory, isn't shared, is lost on restart, and goes stale independently on each instance. That makes it a good fit for small, hot, rarely changing data (config, feature flags, reference tables) and for a short-TTL first level in front of Redis.

Here's cache-aside against Redis. Illustrative — it needs a Redis server, and uses the node-redis v4 API (npm i redis):

redis-cache.mjs (illustrative, node-redis v4)
import { createClient } from 'redis';
import { db } from './db.mjs'; // your data access layer

const redis = createClient({ url: process.env.REDIS_URL });
redis.on('error', (err) => console.error('redis error', err));
await redis.connect();

const TTL_SECONDS = 30;
const productKey = (id) => `product:${id}:v1`;

export async function getProduct(id) {
  const key = productKey(id);
  try {
    const cached = await redis.get(key);          // a string, or null on a miss
    if (cached !== null) return JSON.parse(cached);
  } catch (err) {
    console.warn('cache read failed, falling back to db', err.message);
  }

  const product = await db.findProduct(id);
  if (product) {
    redis.set(key, JSON.stringify(product), { EX: TTL_SECONDS })
      .catch((err) => console.warn('cache write failed', err.message));
  }
  return product;
}

export async function updateProduct(id, changes) {
  await db.updateProduct(id, changes);              // 1. the source of truth first
  await redis.del(productKey(id));                  // 2. then invalidate
}
What’s happening
  1. createClient + await redis.connect() opens one connection that the whole process shares; node-redis pipelines concurrent commands over it. The 'error' listener matters — like any EventEmitter, an unhandled 'error' would crash the process (see The error event).
  2. redis.get(key) resolves to the stored string or null. Redis stores strings, so objects go in with JSON.stringify and come out with JSON.parse — which also means each caller gets its own fresh object.
  3. On a miss the database is read, then redis.set(key, value, { EX: 30 }) stores it with a 30-second TTL; Redis deletes the key itself when it expires. (With ioredis the same call is redis.set(key, value, 'EX', 30).)
  4. The try/catch around the read and the .catch on the write make the cache optional: if Redis is down, requests fall through to the database instead of failing. That's a deliberate choice, and it has a cost — see the gotcha below.
  5. updateProduct writes the database first and then deletes the key, so the next read reloads fresh data. The :v1 suffix in the key lets you change the cached shape later by bumping it to :v2, without having to flush old entries.
AdvancedOperations: Redis memory and outages
AdvancedTwo-level caching in front of Redis

A common combination is two levels: a small in-memory cache with a very short TTL (1–5 s) in front of Redis. The hottest keys never leave the process, and Redis absorbs the rest. To invalidate the first level across instances, publish the changed key on a Redis Pub/Sub channel and have every instance delete it locally — accepting that Pub/Sub is at-most-once, so the short TTL is the safety net (see Local events vs distributed messaging).

#Cache invalidation and cache keys

Phil Karlton's line that cache invalidation is one of the two hard things in computer science is a joke, but it's accurate. A cache is a copy, and every copy can disagree with the original. Invalidation is how you bound that disagreement; keys are how you make sure the copy is even of the right thing.

The usual cache-aside write is update the database, then delete the cache key. Deleting (rather than writing the new value into the cache) is preferred because the next read reloads exactly what the database has, and two concurrent writers can't leave their values in the cache in the wrong order. But even delete-after-update has a race:

invalidation-race.cjs
const { setTimeout: sleep } = require('node:timers/promises');

const db = { 'product:42': { price: 100 } };
const cache = new Map();
const start = Date.now();
const log = (msg) => console.log(`${String(Date.now() - start).padStart(3)}ms  ${msg}`);

// Reader: cache-aside miss path, but its database reply is slow.
async function reader() {
  if (cache.has('product:42')) return log('reader: hit');
  const row = { ...db['product:42'] };   // reads price 100 ...
  log(`reader: miss, read price ${row.price} from db`);
  await sleep(100);                      // ... GC pause / slow network before storing
  cache.set('product:42', row);
  log(`reader: stored price ${row.price} in cache`);
}

// Writer: update the database, then delete the cache entry.
async function writer() {
  await sleep(30);
  db['product:42'] = { price: 80 };
  log('writer: committed price 80');
  cache.delete('product:42');
  log('writer: deleted cache entry');
}

(async () => {
  await Promise.all([reader(), writer()]);
  log(`final: db=${db['product:42'].price}, cache=${cache.get('product:42').price}  <- stale until TTL`);
})();

//   0ms  reader: miss, read price 100 from db
//  33ms  writer: committed price 80
//  33ms  writer: deleted cache entry
// 103ms  reader: stored price 100 in cache
// 103ms  final: db=80, cache=100  <- stale until TTL
//   (the exact ms vary by a few; the order doesn't)
What’s happening
  1. The reader misses and reads the row while the price is still 100. Then it stalls for 100 ms before storing — in real life a slow network reply, a GC pause, or a busy event loop.
  2. At ~30 ms the writer commits price 80 and deletes the cache key. The delete succeeds, but there's nothing to delete yet: the reader hasn't stored anything.
  3. At ~100 ms the reader wakes up and stores the value it read before the commit: price 100. The invalidation has already happened, so nothing will remove this stale entry except its TTL.
  4. Final state: database 80, cache 100. The window is small, but at thousands of requests per second it happens. TTL is what bounds the damage — it's the backstop for every invalidation bug you didn't think of.
  5. Fixes, roughly from cheapest up: keep TTLs short for data that matters; delete again shortly after the write (a "delayed double delete"); store a version with the value and refuse to overwrite a newer version (in Redis, a Lua script that compares versions atomically); or, for decisions that must be correct, read the database directly.
AdvancedInvalidating every copy across pods

Invalidation also has to reach every copy. cache.delete('product:42') in one process does nothing for the other nine pods' in-memory caches. That's the main operational argument for a shared cache, or for short TTLs on local ones.

AdvancedCache keys: every input that changes the answer

Cache keys must include everything that changes the answer

A key has to distinguish every input that changes the result: the resource id, and also the tenant, the user or permission scope, the locale, query parameters, and the response format version. Get that wrong and you either serve the wrong data or — much worse — someone else's data:

cache-key.cjs
const express = require('express');
const request = require('supertest');

function responseCache(keyOf) {
  const cache = new Map();
  return (req, res, next) => {
    const key = keyOf(req);
    if (cache.has(key)) return res.json(cache.get(key));
    const json = res.json.bind(res);
    res.json = (body) => {                 // capture what the route sends
      if (res.statusCode === 200) cache.set(key, body);
      return json(body);
    };
    next();
  };
}

function buildApp(keyOf) {
  const app = express();
  app.use((req, res, next) => {            // stand-in for real authentication
    req.user = { id: req.get('x-user'), tenant: req.get('x-tenant') };
    next();
  });
  app.use(responseCache(keyOf));
  app.get('/me/orders', (req, res) => res.json({ owner: req.user.id, orders: [`order-of-${req.user.id}`] }));
  return app;
}

(async () => {
  for (const [label, keyOf] of [
    ['key = URL only', (req) => `GET ${req.originalUrl}`],
    ['key = tenant + user + URL', (req) => `t:${req.user.tenant}:u:${req.user.id}:GET ${req.originalUrl}`],
  ]) {
    const app = buildApp(keyOf);
    const a = await request(app).get('/me/orders').set('x-user', 'rohit').set('x-tenant', 'acme');
    const b = await request(app).get('/me/orders').set('x-user', 'neha').set('x-tenant', 'acme');
    console.log(label);
    console.log('  rohit sees', JSON.stringify(a.body));
    console.log('  neha  sees', JSON.stringify(b.body));
  }
})();

// key = URL only
//   rohit sees {"owner":"rohit","orders":["order-of-rohit"]}
//   neha  sees {"owner":"rohit","orders":["order-of-rohit"]}
// key = tenant + user + URL
//   rohit sees {"owner":"rohit","orders":["order-of-rohit"]}
//   neha  sees {"owner":"neha","orders":["order-of-neha"]}
What’s happening
  1. responseCache is middleware. On a hit it answers straight from the Map. On a miss it wraps res.json so that when the route later sends a 200, the body is saved under the key before being sent. Then it calls next() so the route runs.
  2. With the URL-only key, Rohit's request misses, the route builds his orders, and they're stored under GET /me/orders.
  3. Neha requests the same URL. The key is identical — the URL doesn't mention the user — so it's a hit, and she is served Rohit's orders. Nothing errored; the logs look normal. This is a data breach.
  4. With t:acme:u:neha:GET /me/orders as the key, her request misses, the route runs with her identity, and each user gets their own entry.
  5. The same reasoning covers query strings (?page=2), Accept-Language, and API versions. And caching never replaces authorisation: check that the user may see the resource before you return a cached copy of it.

#Cache stampedes

A cache stampede (also called thundering herd or dog-piling) happens when a hot key expires — or the cache restarts empty — and many requests miss at the same moment. Each one sees "not in cache" and each one goes to the database for the same row. The database, which the cache was protecting, suddenly receives the full traffic, gets slower, which keeps the key missing for longer, which lets even more requests pile in.

My notes flag this as important. The fix within one process is request coalescing (sometimes called single-flight): the first request that misses starts the database read and stores its promise in a map; every other request for the same key awaits that same promise instead of starting its own read. A promise is perfect for this because it can be awaited any number of times and delivers the same result to everyone.

stampede.cjs
const { setTimeout: sleep } = require('node:timers/promises');

let databaseReads = 0;
async function readDatabase(id) {
  databaseReads++;
  await sleep(50);                          // a 50 ms query
  return { id, name: 'Keyboard' };
}

const TTL_MS = 30_000;
const cache = new Map();     // key -> { value, expiresAt }
const inFlight = new Map();  // key -> Promise of the value

async function getNaive(id) {
  const key = `product:${id}`;
  const entry = cache.get(key);
  if (entry && entry.expiresAt > Date.now()) return entry.value;
  const value = await readDatabase(id);
  cache.set(key, { value, expiresAt: Date.now() + TTL_MS });
  return value;
}

function getCoalesced(id) {
  const key = `product:${id}`;
  const entry = cache.get(key);
  if (entry && entry.expiresAt > Date.now()) return Promise.resolve(entry.value);

  const pending = inFlight.get(key);
  if (pending) return pending;              // someone is already loading it: wait for them

  const load = readDatabase(id)
    .then((value) => {
      cache.set(key, { value, expiresAt: Date.now() + TTL_MS });
      return value;
    })
    .finally(() => inFlight.delete(key));   // runs on success AND failure
  inFlight.set(key, load);
  return load;
}

(async () => {
  const many = (fn) => Promise.all(Array.from({ length: 1000 }, () => fn(42)));

  await many(getNaive);
  console.log('naive:     1000 concurrent misses ->', databaseReads, 'database reads');

  cache.clear();
  databaseReads = 0;
  const results = await many(getCoalesced);
  console.log('coalesced: 1000 concurrent misses ->', databaseReads, 'database read');
  console.log('same object for every caller?', results.every((r) => r === results[0]));
  console.log('inFlight size after settle:', inFlight.size);

  await many(getCoalesced);
  console.log('second wave (cache warm) -> total reads still', databaseReads);
})();

// naive:     1000 concurrent misses -> 1000 database reads
// coalesced: 1000 concurrent misses -> 1 database read
// same object for every caller? true
// inFlight size after settle: 0
// second wave (cache warm) -> total reads still 1
What’s happening
  1. many(getNaive) calls getNaive(42) 1,000 times in one synchronous loop. Each call checks the cache (empty), then calls readDatabase and hits await. The cache can't be filled until the first query finishes 50 ms later, and by then all 1,000 calls have already passed the check: 1,000 database reads for one row.
  2. many(getCoalesced) — the first call misses the cache and finds nothing in inFlight, so it calls readDatabase (reads 0 → 1) and stores the resulting promise as inFlight['product:42']. This all happens synchronously, before the call returns.
  3. Calls 2 to 1,000 run next in the same loop. Each misses the cache, but inFlight.get(key) now returns the stored promise, so they return it and start no new query. Reads stay at 1.
  4. 50 ms later the one query resolves. The .then stores the value in the cache, then .finally removes the in-flight entry, and all 1,000 awaiting callers receive the same object (every(r => r === results[0]) is true).
  5. The second wave of 1,000 finds a warm cache and returns immediately; total reads are still 1. The whole trick depends on JavaScript being single-threaded: there's no gap between "check inFlight" and "set inFlight" in which another request could slip in.
AdvancedRemoving failed in-flight entries with .finally

The .finally is not optional. If the read fails, the in-flight entry must still be removed, otherwise every future caller would get the same old rejected promise forever:

coalesce-fail.cjs
const { setTimeout: sleep } = require('node:timers/promises');

let attempts = 0;
async function flakyRead(id) {
  attempts++;
  await sleep(20);
  if (attempts === 1) throw new Error('db timeout');
  return { id, name: 'Keyboard' };
}

const inFlight = new Map();
function coalesce(key, loader) {
  if (inFlight.has(key)) return inFlight.get(key);
  const p = loader().finally(() => inFlight.delete(key));
  inFlight.set(key, p);
  return p;
}

(async () => {
  const wave = await Promise.allSettled(
    Array.from({ length: 5 }, () => coalesce('product:42', () => flakyRead(42)))
  );
  console.log('wave 1:', wave.map((r) => r.status).join(', '), '| attempts =', attempts, '| inFlight =', inFlight.size);

  const again = await coalesce('product:42', () => flakyRead(42));
  console.log('wave 2:', again.name, '| attempts =', attempts);
})();

// wave 1: rejected, rejected, rejected, rejected, rejected | attempts = 1 | inFlight = 0
// wave 2: Keyboard | attempts = 2
What’s happening
  1. Five concurrent callers share one attempt (attempts = 1). It fails, so all five get the same rejection — coalescing shares failures as well as successes, which is what you want: five retries against a struggling database would make things worse.
  2. .finally ran after the rejection, so inFlight is empty again (size 0).
  3. The next call starts a fresh attempt (attempts = 2), which succeeds. If you removed the .finally, it would return the stored rejected promise and the key would be broken until the process restarted.
AdvancedBeyond one process: stale-while-revalidate and more

Coalescing fixes the herd inside one process. With 20 pods, a hot key expiring still causes up to 20 simultaneous reads. Other tools, often combined:

  • Serve stale while revalidating. Keep the old value past its "fresh" time and, when it's stale, return it immediately while one background refresh runs. Users never wait for the refresh. The example below shows it.
  • TTL jitter. If you warm 10,000 keys at startup with exactly TTL = 300 s, they all expire in the same second. ttl = base * (0.9 + Math.random() * 0.2) spreads expiries across ±10%. Jitter helps with many keys expiring together; it doesn't help with one hot key.
  • A distributed lock for the refresh, so one instance across the fleet reloads while the others serve stale or wait briefly. In Redis that's SET lock:product:42 <token> NX PX 5000 (node-redis v4: redis.set(lockKey, token, { NX: true, PX: 5000 }), which resolves to 'OK' or null), released with a Lua script that deletes the lock only if it still holds your token. The lock needs an expiry, or a crashed holder blocks refreshes forever.
  • Refresh ahead — a background job reloads known-hot keys before they expire, so they never miss at all.
swr.cjs
const { setTimeout: sleep } = require('node:timers/promises');

let version = 1;
let reads = 0;
async function readDatabase() {
  reads++;
  await sleep(50);
  return { price: 100 * version };
}

const FRESH_MS = 100;    // serve without a second thought
const STALE_MS = 1_000;  // after this, too old to serve at all
const cache = new Map();
const refreshing = new Map();

function refresh(key) {
  if (refreshing.has(key)) return refreshing.get(key);
  const p = readDatabase()
    .then((value) => { cache.set(key, { value, storedAt: Date.now() }); return value; })
    .finally(() => refreshing.delete(key));
  refreshing.set(key, p);
  return p;
}

async function get(key) {
  const entry = cache.get(key);
  const age = entry ? Date.now() - entry.storedAt : Infinity;
  if (age < FRESH_MS) return { ...entry.value, how: 'fresh' };
  if (age < STALE_MS) {
    refresh(key).catch(() => {});          // background refresh, errors logged elsewhere
    return { ...entry.value, how: 'stale' };
  }
  return { ...(await refresh(key)), how: 'miss' };
}

(async () => {
  console.log(await get('p42'), 'reads', reads);
  console.log(await get('p42'), 'reads', reads);
  await sleep(150); version = 2;           // entry is now 150 ms old; DB has changed
  console.log(await get('p42'), 'reads', reads);
  console.log(await get('p42'), 'reads', reads);
  await sleep(60);                          // background refresh has landed
  console.log(await get('p42'), 'reads', reads);
})();

// { price: 100, how: 'miss' } reads 1
// { price: 100, how: 'fresh' } reads 1
// { price: 100, how: 'stale' } reads 2
// { price: 100, how: 'stale' } reads 2
// { price: 200, how: 'fresh' } reads 2
What’s happening
  1. First call: nothing cached, so age is Infinity — a real miss. The caller waits for refresh (reads 1) and gets price 100.
  2. Second call: the entry is a few ms old, under FRESH_MS, so it's served as fresh with no read.
  3. After 150 ms the entry is stale but under STALE_MS, and the database now says 200. The caller gets the old price 100 immediately, and refresh starts a background read (reads 2). The .catch(() => {}) matters: nobody awaits that promise, so a failure would otherwise be an unhandled rejection.
  4. The fourth call is also stale, but refreshing already holds a promise for this key, so no second read starts — the background refresh is coalesced too (reads still 2).
  5. 60 ms later the refresh has stored price 200, and the fifth call gets it as fresh. Users saw a slightly old price for ~50 ms, but no request ever waited on the database after the first. This is the same idea as HTTP's stale-while-revalidate directive.

#HTTP caching headers

Added

Everything so far caches inside your system. HTTP caching lets the browser, CDNs and proxies keep copies, so requests are answered before they reach you, or answered with "nothing changed" instead of the full body. Two mechanisms work together:

  • Freshness — Cache-Control: max-age=60 says "this copy is good for 60 seconds; don't even ask me". No request is made while it's fresh.
  • Validation — once a copy is stale, the client asks "has it changed?" by sending the ETag it has in If-None-Match. If it hasn't changed, the server answers 304 Not Modified with no body, and the client reuses its copy.

The Cache-Control directives you'll use most:

DirectiveMeaning
max-age=NFresh for N seconds in any cache
s-maxage=NFresh for N seconds in shared caches (CDNs, proxies); overrides max-age there
public / privateMay / may not be stored by shared caches. Per-user responses must be private
no-cacheMay be stored, but must be revalidated (ETag) before every use
no-storeDon't store at all — for tokens, account pages, anything sensitive
immutableWon't change while fresh; don't revalidate even on reload (fingerprinted assets)
stale-while-revalidate=NMay serve a stale copy for N more seconds while refetching in the background

Express generates weak ETags automatically for res.send and res.json, and answers conditional requests with 304 for you:

etag.cjs
const express = require('express');
const request = require('supertest');

let handlerRuns = 0;
const app = express();
console.log("app.get('etag') =", app.get('etag'));

app.get('/products/42', (req, res) => {
  handlerRuns++;
  res.set('Cache-Control', 'private, max-age=0, must-revalidate');
  res.json({ id: 42, name: 'Keyboard', price: 100 });
});

(async () => {
  const first = await request(app).get('/products/42');
  const etag = first.headers.etag;
  console.log('1st:', first.status, 'ETag', etag, 'Content-Length', first.headers['content-length'], 'handlerRuns', handlerRuns);

  const second = await request(app).get('/products/42').set('If-None-Match', etag);
  console.log('2nd:', second.status, 'body', JSON.stringify(second.text), 'Content-Length', second.headers['content-length'], 'ETag', second.headers.etag, 'handlerRuns', handlerRuns);

  const third = await request(app).get('/products/42').set('If-None-Match', 'W/"stale"');
  console.log('3rd:', third.status, 'handlerRuns', handlerRuns);

  const post = express();
  post.post('/x', (req, res) => res.json({ ok: true }));
  const p1 = await request(post).post('/x');
  const p2 = await request(post).post('/x').set('If-None-Match', p1.headers.etag);
  console.log('POST with matching If-None-Match:', p2.status);

  const strong = express();
  strong.set('etag', 'strong');
  strong.get('/', (req, res) => res.json({ id: 42 }));
  console.log("etag 'strong':", (await request(strong).get('/')).headers.etag);

  const off = express();
  off.set('etag', false);
  off.get('/', (req, res) => res.json({ id: 42 }));
  console.log('etag false:', (await request(off).get('/')).headers.etag);
})();

// app.get('etag') = weak
// 1st: 200 ETag W/"27-lho5pbu6kHLoOtH8z39dEnwP3CU" Content-Length 39 handlerRuns 1
// 2nd: 304 body "" Content-Length undefined ETag W/"27-lho5pbu6kHLoOtH8z39dEnwP3CU" handlerRuns 2
// 3rd: 200 handlerRuns 3
// POST with matching If-None-Match: 200
// etag 'strong': "9-NSscY66S6NKRWG5blkTNB7gbOO8"
// etag false: undefined
What’s happening
  1. The etag setting defaults to 'weak'. On the first request res.json serialises the body (39 bytes) and computes W/"27-…": 27 is the length in hex (0x27 = 39) and the rest is a truncated base64 SHA-1 hash of the body. W/ marks it weak — "semantically the same", rather than byte-for-byte identical.
  2. The second request sends that ETag back in If-None-Match. Express builds the response as usual, sees the ETag matches (req.fresh is true), switches the status to 304, and strips the body and Content-Length. The client downloaded 0 bytes of body instead of 39.
  3. But look at handlerRuns: it's 2. The handler — and any database query in it — still ran, because Express can only compute the ETag from the finished body. Automatic ETags save bandwidth and client parsing, not server work.
  4. A non-matching ETag (W/"stale") gets a normal 200 with the full body. A POST never gets a 304: freshness only applies to GET and HEAD.
  5. app.set('etag', 'strong') produces strong ETags (no W/), and app.set('etag', false) turns them off — for example when a CDN or proxy in front computes its own.
AdvancedCheap ETags checked before loading

To save the server work too, build the ETag from something cheap — a version number or updated_at column — and check it before loading the full resource:

etag-early.cjs
const express = require('express');
const request = require('supertest');

const versions = new Map([['42', 3]]);   // cheap: e.g. an updated_at or version column
let fullLoads = 0;
async function loadFullProduct(id) {     // expensive: joins, pricing, serialisation
  fullLoads++;
  return { id, name: 'Keyboard', price: 100, version: versions.get(id) };
}

const app = express();
app.get('/products/:id', async (req, res) => {
  const version = versions.get(req.params.id);
  if (version === undefined) return res.sendStatus(404);

  res.set('ETag', `"p${req.params.id}-v${version}"`);
  res.set('Cache-Control', 'private, no-cache');
  if (req.fresh) return res.status(304).end();   // client's copy is current: skip the work

  res.json(await loadFullProduct(req.params.id));
});

(async () => {
  const a = await request(app).get('/products/42');
  console.log('1st', a.status, a.headers.etag, 'fullLoads', fullLoads);
  const b = await request(app).get('/products/42').set('If-None-Match', a.headers.etag);
  console.log('2nd', b.status, JSON.stringify(b.text), 'fullLoads', fullLoads);
  versions.set('42', 4);                          // product updated
  const c = await request(app).get('/products/42').set('If-None-Match', a.headers.etag);
  console.log('3rd', c.status, c.headers.etag, 'fullLoads', fullLoads);
})();

// 1st 200 "p42-v3" fullLoads 1
// 2nd 304 "" fullLoads 1
// 3rd 200 "p42-v4" fullLoads 2
What’s happening
  1. The handler first reads only the version (3) — think of a primary-key lookup on one column — and sets ETag: "p42-v3" itself. Because an ETag is already set, Express won't generate its own later.
  2. req.fresh compares the request's If-None-Match with the response's ETag. On the first request there's no If-None-Match, so it's false; the full product is loaded (fullLoads 1) and sent.
  3. The second request sends "p42-v3" back. req.fresh is true, so the handler returns 304 before calling loadFullProduct — fullLoads stays at 1.
  4. The product changes to version 4. The client's old ETag no longer matches, so it gets a 200 with the new body and the new ETag "p42-v4".
  5. private, no-cache means "the browser may keep it but must revalidate every time" — right for per-user data that can change at any moment, where each check is now cheap.
AdvancedStatic files: express.static caching defaults

Static files work differently: express.static sets Cache-Control: public, max-age=0 plus an ETag built from file size and modification time, and a Last-Modified header. For build output with a content hash in the filename (app.3f9a1c.css), serve it with express.static('dist', { maxAge: '1y', immutable: true }), which sends Cache-Control: public, max-age=31536000, immutable — the file at that URL can never change, because a new build gets a new name.

AdvancedPitfall: CDNs caching per-user responses

#Rate limiting

Rate limiting decides how many requests a client may make in a period, and rejects the rest with 429 Too Many Requests and a Retry-After header. It protects you from brute-force logins, scrapers, buggy clients stuck in retry loops, and one noisy tenant starving everyone else, and it puts a ceiling on how much expensive work any single client can trigger.

Three decisions define a rate limiter:

  1. Identity — who is "a client"? An IP address is all you have before authentication, but many users can share one IP (an office, a mobile carrier's NAT) and one attacker can have thousands. After authentication, the user id or API key is much better. Never use a client-supplied header like X-User-Id as an identity; anyone can set it.
  2. Budget and algorithm — e.g. 100 requests per 15 minutes, as a fixed window, a sliding window or a token bucket (Rate-limiting algorithms).
  3. Where the counters live — in process memory (per instance) or in a shared store like Redis (one budget across all instances).

A rate is not a concurrency limit: "100 requests per minute" doesn't stop a client from having 100 slow report exports running at once. Expensive endpoints may need a concurrency cap or a queue as well.

From my notes, using express-rate-limit:

my notes
const rateLimit = require('express-rate-limit');
const limiter = rateLimit({
    windowMs: 15 * 60 * 1000, // 15 minutes
    max: 100 // Limit each IP to 100 requests per windowMs
});
app.use(limiter);
AdvancedCorrecting my notes: limit replaces max
AdvancedModern express-rate-limit setup

The modern version (CG §16), with a tiny limit so the 429 shows up on the fourth request:

erl.cjs
const express = require('express');
const { rateLimit } = require('express-rate-limit');
const request = require('supertest');

const app = express();

const limiter = rateLimit({
  windowMs: 60_000,
  limit: 3,
  standardHeaders: 'draft-8',
  legacyHeaders: false,
  message: { error: 'Too many requests. Try again later.' },
});

app.use('/api', limiter);

let handled = 0;
app.get('/api/products', (req, res) => {
  handled++;
  res.json([{ id: 42, name: 'Keyboard' }]);
});

(async () => {
  for (let i = 1; i <= 4; i++) {
    const r = await request(app).get('/api/products');
    console.log(`#${i}`, r.status, JSON.stringify(r.body), `handled=${handled}`);
    console.log('   RateLimit-Policy:', r.headers['ratelimit-policy']);
    console.log('   RateLimit:       ', r.headers['ratelimit']);
    if (r.headers['retry-after']) console.log('   Retry-After:     ', r.headers['retry-after']);
  }
})();

// #1 200 [{"id":42,"name":"Keyboard"}] handled=1
//    RateLimit-Policy: "3-in-1min"; q=3; w=60; pk=:MTJjYTE3YjQ5YWYy:
//    RateLimit:        "3-in-1min"; r=2; t=60
// #2 200 [{"id":42,"name":"Keyboard"}] handled=2
//    RateLimit-Policy: "3-in-1min"; q=3; w=60; pk=:MTJjYTE3YjQ5YWYy:
//    RateLimit:        "3-in-1min"; r=1; t=60
// #3 200 [{"id":42,"name":"Keyboard"}] handled=3
//    RateLimit-Policy: "3-in-1min"; q=3; w=60; pk=:MTJjYTE3YjQ5YWYy:
//    RateLimit:        "3-in-1min"; r=0; t=60
// #4 429 {"error":"Too many requests. Try again later."} handled=3
//    RateLimit-Policy: "3-in-1min"; q=3; w=60; pk=:MTJjYTE3YjQ5YWYy:
//    RateLimit:        "3-in-1min"; r=0; t=60
//    Retry-After:      60
What’s happening
  1. rateLimit({...}) returns middleware with its own MemoryStore. Mounted with app.use('/api', limiter), it runs for every path under /api, and they all share one budget per client.
  2. The key defaults to req.ip (127.0.0.1 here). Request 1 creates a counter with a window ending 60 s from now; hits go 0 → 1. RateLimit-Policy describes the policy: name 3-in-1min, quota q=3, window w=60 seconds, and pk, a hashed partition key identifying which counter this client is on.
  3. The RateLimit header is the live state: r is remaining requests (2, then 1, then 0) and t is seconds until the window resets. Well-behaved clients can slow down before they ever get a 429.
  4. Request 4 pushes hits to 4, over the limit. The handler sends 429 with the message object as JSON and Retry-After: 60, and doesn't call next(), so the route never runs (handled stays 3).
  5. Under the hood MemoryStore is a per-client fixed window that starts at the client's first hit, plus a setInterval (unref'd) that clears expired entries — the same design as the hand-written one in Writing a rate-limiter middleware.
AdvancedBehind a proxy, req.ip must be right

In production your app usually sits behind a load balancer or ingress. Then the TCP connection comes from the proxy, and the real client's IP is only in the X-Forwarded-For header. Express's trust proxy setting decides whether req.ip believes that header:

erl-proxy.cjs
const express = require('express');
const { rateLimit } = require('express-rate-limit');
const request = require('supertest');

async function run(label, trustProxy) {
  const app = express();
  if (trustProxy !== undefined) app.set('trust proxy', trustProxy);
  app.use(rateLimit({ windowMs: 60_000, limit: 2, standardHeaders: 'draft-8', legacyHeaders: false }));
  app.get('/', (req, res) => res.send(req.ip));
  console.log(`--- ${label}`);
  for (const fake of ['1.1.1.1', '2.2.2.2', '3.3.3.3']) {
    const r = await request(app).get('/').set('X-Forwarded-For', fake);
    console.log(`  XFF=${fake} -> ${r.status} req.ip=${r.status === 200 ? r.text : '-'}`);
  }
}
(async () => {
  await run('trust proxy unset', undefined);
  await run('trust proxy = true', true);
})();

// --- trust proxy unset
// ValidationError: The 'X-Forwarded-For' header is set but the Express 'trust proxy' setting is false (default). …
//   code: 'ERR_ERL_UNEXPECTED_X_FORWARDED_FOR', …
//   XFF=1.1.1.1 -> 200 req.ip=127.0.0.1
//   XFF=2.2.2.2 -> 200 req.ip=127.0.0.1
//   XFF=3.3.3.3 -> 429 req.ip=-
// --- trust proxy = true
// ValidationError: The Express 'trust proxy' setting is true, which allows anyone to trivially bypass IP-based rate limiting. …
//   code: 'ERR_ERL_PERMISSIVE_TRUST_PROXY', …
//   XFF=1.1.1.1 -> 200 req.ip=1.1.1.1
//   XFF=2.2.2.2 -> 200 req.ip=2.2.2.2
//   XFF=3.3.3.3 -> 200 req.ip=3.3.3.3
What’s happening
  1. trust proxy unset (the default false): Express ignores X-Forwarded-For, so req.ip is the socket's address, 127.0.0.1, for all three requests. They share one counter and the third gets 429. Behind a real load balancer this is the "everyone shares one quota" bug: the whole internet looks like the balancer's IP.
  2. express-rate-limit notices the header and the setting disagree and logs ERR_ERL_UNEXPECTED_X_FORWARDED_FOR (once, as a warning — the request isn't failed).
  3. trust proxy = true: Express trusts the header completely and takes the left-most address. Now each "client" gets its own counter — but the client chose that address. An attacker sends a random X-Forwarded-For on every request and is never limited. The library warns with ERR_ERL_PERMISSIVE_TRUST_PROXY.
  4. The right setting is the number of proxies you actually have (or their addresses). With app.set('trust proxy', 1) and a header of 6.6.6.6, 203.0.113.9, req.ip is 203.0.113.9 — the address your one trusted proxy appended — and the forged 6.6.6.6 is ignored. (Checked separately: true returns 6.6.6.6 for the same header.) More in HTTPS, proxies and trust proxy.
AdvancedMultiple instances need a shared store

MemoryStore counts per process. With three instances behind a round-robin balancer, each one gives a client the full budget, so "100 per 15 minutes" becomes up to 300, and every deploy resets everyone's counters. A shared store fixes that — illustrative, assuming the rate-limit-redis package and node-redis v4:

limiter-redis.mjs (illustrative, needs Redis)
import { rateLimit } from 'express-rate-limit';
import { RedisStore } from 'rate-limit-redis';
import { createClient } from 'redis';

const client = createClient({ url: process.env.REDIS_URL });
client.on('error', (err) => console.error('redis error', err));
await client.connect();

export const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  limit: 100,
  standardHeaders: 'draft-8',
  legacyHeaders: false,
  store: new RedisStore({
    sendCommand: (...args) => client.sendCommand(args),
    prefix: 'rl:api:',
  }),
});

export const loginLimiter = rateLimit({         // a much stricter, separate budget
  windowMs: 15 * 60 * 1000,
  limit: 5,
  skipSuccessfulRequests: true,                 // only failed logins count
  store: new RedisStore({
    sendCommand: (...args) => client.sendCommand(args),
    prefix: 'rl:login:',
  }),
});
What’s happening
  1. The store is the only part that changes: RedisStore keeps each client's counter in Redis, so all instances read and increment the same number. sendCommand adapts it to whichever Redis client you use (here node-redis v4).
  2. The store does the increment and expiry atomically in Redis. A hand-written GET, compare, SET count+1 from two instances at once would lose updates and let extra requests through.
  3. Each limiter gets its own prefix, so the API budget and the login budget are separate counters. Login gets 5 attempts per 15 minutes, and skipSuccessfulRequests means only failures count — exactly what you want against password guessing.
  4. Decide what happens when Redis is unreachable. By default express-rate-limit fails the request with an error (passOnStoreError: false); setting it to true lets traffic through unlimited. That's a choice between availability and protection, and it should be a deliberate one.

#Rate-limiting algorithms

The algorithms differ in how they define "too many recently", which decides how bursty traffic can be, how much state you keep per client, and how accurate the limit is. The ChatGPT guide's point bears repeating: these are alternative policies, and setting windowMs doesn't turn a fixed window into a token bucket.

  • Fixed window — one counter per aligned interval ([0 s, 10 s), [10 s, 20 s), …), reset to zero at each boundary. One number per client; cheap and easy to do atomically. Weakness: a client can use the full limit at the end of one window and again at the start of the next.
  • Sliding window log — keep the timestamp of every accepted request and count those in the last W ms. Exact, but stores up to limit timestamps per client.
  • Sliding window counter — keep this window's count and the previous one's, and estimate the moving count as previous × (share of previous window still inside) + current. Two numbers per client and very close to exact.
  • Token bucket — a bucket holds up to capacity tokens and refills at a steady rate; each request takes one token or is rejected. Capacity allows a controlled burst, and the refill rate is the sustained limit.
  • Leaky bucket — requests join a queue that drains at a fixed rate. It smooths output instead of rejecting bursts outright; used more in traffic shaping and job processing than in API limits.

Here's the boundary problem, with a limit of 5 per 10 seconds and 10 requests sent around the 10-second boundary:

windows.cjs
const LIMIT = 5;
const WINDOW = 10_000;

// Fixed window: one counter per aligned window [0,10s), [10s,20s), ...
function fixedWindow() {
  const counts = new Map();
  return (t) => {
    const w = Math.floor(t / WINDOW);
    const n = (counts.get(w) ?? 0) + 1;
    counts.set(w, n);
    return n <= LIMIT;
  };
}

// Sliding log: remember every accepted timestamp in the last WINDOW ms.
function slidingLog() {
  const log = [];
  return (t) => {
    while (log.length && log[0] <= t - WINDOW) log.shift();
    if (log.length >= LIMIT) return false;
    log.push(t);
    return true;
  };
}

// Sliding window counter: current count + previous count weighted by overlap.
function slidingCounter() {
  const counts = new Map();
  return (t) => {
    const w = Math.floor(t / WINDOW);
    const prev = counts.get(w - 1) ?? 0;
    const curr = counts.get(w) ?? 0;
    const overlap = 1 - (t % WINDOW) / WINDOW;   // share of the previous window still "inside"
    const estimate = prev * overlap + curr;
    if (estimate >= LIMIT) return false;
    counts.set(w, curr + 1);
    return true;
  };
}

// 5 requests at t=9.0s..9.4s, then 5 more at t=10.0s..10.4s
const times = [9000, 9100, 9200, 9300, 9400, 10000, 10100, 10200, 10300, 10400];
for (const [name, make] of [['fixed window', fixedWindow], ['sliding log', slidingLog], ['sliding counter', slidingCounter]]) {
  const allow = make();
  const marks = times.map((t) => (allow(t) ? '✓' : '✗')).join(' ');
  console.log(name.padEnd(16), marks, ' accepted:', (marks.match(/✓/g) || []).length);
}

// fixed window     ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓  accepted: 10
// sliding log      ✓ ✓ ✓ ✓ ✓ ✗ ✗ ✗ ✗ ✗  accepted: 5
// sliding counter  ✓ ✓ ✓ ✓ ✓ ✗ ✓ ✗ ✗ ✗  accepted: 6
What’s happening
  1. Fixed window: the first five requests (9.0–9.4 s) fall in window 0 and bring its counter to 5. At 10.0 s a new window starts with a counter of 0, so the next five are accepted too: 10 requests in 1.4 seconds against a "5 per 10 seconds" limit. That's the boundary burst.
  2. Sliding log: at 10.0 s it drops timestamps older than 10 000 − 10 000 = 0 (none), finds 5 in the log, and rejects. Every later request in the batch also sees five timestamps inside its last 10 s. Exactly 5 accepted — correct, but it stored five timestamps to know that.
  3. Sliding counter at 10.0 s: we're 0% into window 1, so overlap is 1.0, and the estimate is 5 × 1.0 + 0 = 5 → reject.
  4. At 10.1 s, overlap is 0.99, so the estimate is 5 × 0.99 + 0 = 4.95, just under 5 → accept (current becomes 1). At 10.2 s: 5 × 0.98 + 1 = 5.9 → reject, and so on. It accepted 6, one more than exact, because it assumes the previous window's requests were spread evenly when they were really bunched at its end. A small error for two numbers of state.
AdvancedToken bucket traced with a fake clock

And a token bucket, traced with a fake clock — capacity 3, refilling 1 token per second:

token-bucket.cjs
class TokenBucket {
  constructor({ capacity, refillPerSec, now = Date.now }) {
    this.capacity = capacity;
    this.refillPerMs = refillPerSec / 1000;
    this.now = now;
    this.tokens = capacity;          // start full: allows an initial burst
    this.last = now();
  }

  #refill() {
    const t = this.now();
    const elapsed = t - this.last;
    this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.refillPerMs);
    this.last = t;
  }

  tryTake() {
    this.#refill();
    if (this.tokens >= 1) {
      this.tokens -= 1;
      return { allowed: true, tokens: this.tokens };
    }
    const retryAfterMs = Math.ceil((1 - this.tokens) / this.refillPerMs);
    return { allowed: false, tokens: this.tokens, retryAfterMs };
  }
}

let clock = 0;
const bucket = new TokenBucket({ capacity: 3, refillPerSec: 1, now: () => clock });

function req(label) {
  const r = bucket.tryTake();
  const extra = r.allowed ? '' : ` retry in ${r.retryAfterMs} ms`;
  console.log(`t=${String(clock).padStart(5)}ms ${label.padEnd(5)} ${r.allowed ? 'ALLOW' : 'DENY '} tokens left ${r.tokens.toFixed(2)}${extra}`);
}

req('#1'); req('#2'); req('#3'); req('#4');   // burst of 4 at t=0
clock = 500;  req('#5');                       // half a token has refilled
clock = 1000; req('#6');                       // one full token
clock = 1000; req('#7');
clock = 10_000; req('#8');                     // long idle: refill caps at capacity

// t=    0ms #1    ALLOW tokens left 2.00
// t=    0ms #2    ALLOW tokens left 1.00
// t=    0ms #3    ALLOW tokens left 0.00
// t=    0ms #4    DENY  tokens left 0.00 retry in 1000 ms
// t=  500ms #5    DENY  tokens left 0.50 retry in 500 ms
// t= 1000ms #6    ALLOW tokens left 0.00
// t= 1000ms #7    DENY  tokens left 0.00 retry in 1000 ms
// t=10000ms #8    ALLOW tokens left 2.00
What’s happening
  1. The bucket starts full (3 tokens). There's no timer refilling it: each call to tryTake first computes how many tokens accumulated since last (elapsed × rate), capped at capacity. That makes the state just two numbers, tokens and last, which is why it's easy to store in Redis.
  2. Burst at t=0: requests #1–#3 take the tokens 3 → 2 → 1 → 0. Request #4 finds 0 tokens and is denied; one token takes 1000 ms to refill, so retryAfterMs is 1000 — exactly what you'd put in Retry-After (rounded up to seconds).
  3. t=500: 500 ms × 0.001 tokens/ms = 0.5 tokens. Not enough for a request, so #5 is denied with "retry in 500 ms". Rejected requests don't consume anything.
  4. t=1000: another 0.5 refilled → 1.0. #6 takes it (back to 0), and #7 in the same millisecond is denied. Sustained traffic gets exactly the refill rate: 1 request per second.
  5. t=10 000: 9 seconds idle would be 9 tokens, but Math.min(capacity, …) caps the bucket at 3. #8 takes one, leaving 2. That cap is the point of capacity: an idle client earns a burst of at most 3, never an unlimited one.
  6. CG §16's example in these terms: capacity 20 and refill 5/s allows an initial burst of 20, then about 5 per second on average.
AdvancedReference: rate-limiting algorithms
AlgorithmState per clientBurstsAccuracyTypical use
Fixed window1 counterUp to 2× at boundariesApproximateSimple quotas; express-rate-limit's MemoryStore
Sliding logUp to limit timestampsNoneExactLow limits where precision matters (logins)
Sliding counter2 countersSmallNear exactGeneral API limits at scale
Token bucketTokens + timestampUp to capacity, by designExact for its modelAPIs that allow short bursts (AWS, Stripe-style)
Leaky bucketA queueSmoothed, not rejectedExact output rateShaping outbound calls or jobs

In a shared store the check-and-update has to be one atomic step, because two instances can handle the same client's requests at the same instant. In Redis that means a single command or a Lua script. A fixed window in Lua — illustrative, node-redis v4:

redis-fixed-window.mjs (illustrative, node-redis v4)
const FIXED_WINDOW = `
local count = redis.call('INCR', KEYS[1])
if count == 1 then
  redis.call('PEXPIRE', KEYS[1], ARGV[1])
end
return { count, redis.call('PTTL', KEYS[1]) }
`;

export async function hit(redis, clientKey, { limit, windowMs }) {
  const [count, ttlMs] = await redis.eval(FIXED_WINDOW, {
    keys: [`rl:${clientKey}`],
    arguments: [String(windowMs)],
  });
  return { allowed: count <= limit, remaining: Math.max(0, limit - count), retryAfterSec: Math.ceil(ttlMs / 1000) };
}
What’s happening
  1. Redis runs a Lua script atomically: no other command touches the key between the INCR and the PEXPIRE. That's what makes it safe when ten instances call it at once.
  2. INCR creates the key at 1 if it doesn't exist, otherwise adds 1, and returns the new count. Only the first hit of a window (count == 1) sets the expiry, so the window starts at the client's first request and Redis deletes the key when it ends — no sweeper needed.
  3. The script returns the count and the remaining TTL in ms, which becomes Retry-After. node-redis v4's eval takes { keys, arguments }, with arguments passed as strings; ioredis's equivalent is redis.eval(script, 1, key, windowMs).
  4. Doing the same thing as GET, then SET from Node would let two instances both read 99, both write 100, and both allow a request — the lost-update race the script avoids.

#Putting caching and rate limiting together

The two solve different problems and sit at different points in the pipeline. Rate limiting decides whether a request gets any work at all; caching makes the work cheaper for the requests you accept. A typical order for a protected API (CG §16):

  1. A broad per-IP limit first — cheap, and it runs before anything expensive like token verification or body parsing.
  2. Authenticate — now you know who the caller is.
  3. A per-user or per-API-key quota — the fair, accurate limit.
  4. Authorise access to the specific resource.
  5. Cache lookup, then on a miss the database, then store and respond.
together.cjs
const express = require('express');
const { rateLimit } = require('express-rate-limit');
const request = require('supertest');
const { setTimeout: sleep } = require('node:timers/promises');

// --- fake database --------------------------------------------------------
let dbReads = 0;
async function findProduct(tenant, id) {
  dbReads++;
  await sleep(20);
  return { tenant, id, name: 'Keyboard', price: 100 };
}

// --- cache-aside with coalescing -------------------------------------------
const cache = new Map();
const inFlight = new Map();
const TTL_MS = 30_000;
function cached(key, load) {
  const hit = cache.get(key);
  if (hit && hit.expiresAt > Date.now()) return Promise.resolve(hit.value);
  if (inFlight.has(key)) return inFlight.get(key);
  const p = load()
    .then((value) => { cache.set(key, { value, expiresAt: Date.now() + TTL_MS }); return value; })
    .finally(() => inFlight.delete(key));
  inFlight.set(key, p);
  return p;
}

// --- app --------------------------------------------------------------------
const app = express();

app.use(rateLimit({ windowMs: 60_000, limit: 100, standardHeaders: 'draft-8', legacyHeaders: false })); // broad, per IP

function authenticate(req, res, next) {      // stand-in: real code verifies a token
  const user = req.get('x-api-key');
  if (!user) return res.status(401).json({ error: 'missing API key' });
  req.user = { id: user, tenant: 'acme' };
  next();
}

const perUser = rateLimit({
  windowMs: 60_000,
  limit: 3,
  keyGenerator: (req) => `user:${req.user.id}`,
  standardHeaders: 'draft-8',
  legacyHeaders: false,
  message: { error: 'quota exceeded' },
});

app.get('/api/products/:id', authenticate, perUser, async (req, res) => {
  const { tenant } = req.user;
  const key = `t:${tenant}:product:${req.params.id}:v1`;
  const product = await cached(key, () => findProduct(tenant, req.params.id));
  res.json(product);
});

(async () => {
  const get = (user) => {
    const t = request(app).get('/api/products/42');
    return user ? t.set('x-api-key', user) : t;
  };
  const show = (label, r) => console.log(label.padEnd(24), r.status, JSON.stringify(r.body).slice(0, 40).padEnd(40), 'dbReads', dbReads);

  show('no key', await get());
  show('rohit #1 (miss)', await get('rohit'));
  show('rohit #2 (hit)', await get('rohit'));
  show('neha  #1 (hit)', await get('neha'));
  show('rohit #3 (hit)', await get('rohit'));
  show('rohit #4 (over quota)', await get('rohit'));
  show('neha  #2 (hit)', await get('neha'));
})();

// no key                   401 {"error":"missing API key"}              dbReads 0
// rohit #1 (miss)          200 {"tenant":"acme","id":"42","name":"Keybo dbReads 1
// rohit #2 (hit)           200 {"tenant":"acme","id":"42","name":"Keybo dbReads 1
// neha  #1 (hit)           200 {"tenant":"acme","id":"42","name":"Keybo dbReads 1
// rohit #3 (hit)           200 {"tenant":"acme","id":"42","name":"Keybo dbReads 1
// rohit #4 (over quota)    429 {"error":"quota exceeded"}               dbReads 1
// neha  #2 (hit)           200 {"tenant":"acme","id":"42","name":"Keybo dbReads 1
What’s happening
  1. no key: the broad IP limiter counts it (1 of 100), then authenticate rejects it with 401. It never reaches the per-user limiter or the cache, and the database is untouched.
  2. rohit #1: IP limit passes, authenticate sets req.user, the per-user limiter keys on user:rohit (1 of 3), and the cache misses. findProduct runs (dbReads 0 → 1) and the result is stored under t:acme:product:42:v1.
  3. rohit #2 and neha #1 are both cache hits — dbReads stays at 1. The key includes the tenant but not the user, because a product's data is the same for every user in that tenant. That's a deliberate decision: per-user data (like /me/orders earlier) would need the user in the key.
  4. rohit #3 is also a hit, and uses his third request of the minute. rohit #4 is rejected by the per-user limiter with 429 even though the answer is sitting in the cache. Cached responses still count against the quota, because the quota is about the client's usage, not your cost.
  5. neha #2 succeeds: her counter (user:neha) is separate from Rohit's. Under the IP limiter, though, all of these requests came from 127.0.0.1 and share one counter (7 of 100) — in production, an office NAT means the same, which is why the per-IP limit is generous and the per-user one is strict.
AdvancedWhat to monitor in production

What to watch once this is in production: cache hit ratio per key prefix, database queries per second, p95/p99 latency for hits and misses separately, cache memory and evictions, the number of 429s per route and per client (a spike can mean an attack — or a buggy client, or a limit that's too low), and what happens on a cache or Redis outage. An outage should look like "slower", not "down".

Built from Rohit’s “Nodejs interview study” and “Nodejs-Interview-Chatgpt” docs. Examples are run on Node 26.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.