Skip to content
Huseyin Babal
Go back

A Redis cache in Node.js after the first request: five checks you can run

This post is the short version of my video. Watch it here: Redis Crash Course: Zero to Caching in 8 Minutes

The video builds a cache in front of a slow query and stops at the good news: the second request is fast. This page starts there. It takes the same small app and asks five questions the video only mentions in one sentence each, and answers them with commands you can repeat.

Everything below ran on Redis 8.10.2 in Docker, Node.js 26.10, Express 5.2 and the redis client 6.3, on an M4 Max laptop. It is one machine and one run, so your timings will differ.

The five checks, in one table

QuestionWhat I measured
How much faster is a hit?1.018 s on a miss, 1.4 ms on a hit
What happens when 20 requests miss together?20 database queries. With one shared promise: 1
What does a reader see after an update?The old price, until the TTL runs out
Is INCR then EXPIRE safe?If the second command never arrives, the counter lives forever
What happens when memory is full?By default, writes fail. With allkeys-lru, old keys go

The setup

docker run -d --name redis -p 6379:6379 redis

The app is the one from the video with three small additions: it counts the database queries, it has a PUT route, and two environment variables turn the two fixes on.

import express from 'express';
import { createClient } from 'redis';

const redis = await createClient({ url: 'redis://localhost:6379' }).connect();
const app = express();
app.use(express.json());

const db = { 1: { id: '1', name: 'Mechanical keyboard', price: 89 } };
let queries = 0;

// pretend this is a slow database query
async function getProductFromDb(id) {
  console.log(`db query #${++queries}`);
  await new Promise(r => setTimeout(r, 1000));
  return db[id];
}

const inFlight = new Map();   // key -> promise of the query that is already running

async function getProduct(id) {
  const key = `product:${id}`;
  const cached = await redis.get(key);
  if (cached) return { ...JSON.parse(cached), source: 'cache' };

  if (process.env.SINGLE_FLIGHT && inFlight.has(key)) return { ...(await inFlight.get(key)), source: 'shared' };
  const query = getProductFromDb(id);
  inFlight.set(key, query);
  try {
    const product = await query;
    await redis.set(key, JSON.stringify(product), { EX: 10 });
    return { ...product, source: 'database' };
  } finally {
    inFlight.delete(key);
  }
}

app.get('/products/:id', async (req, res) => res.json(await getProduct(req.params.id)));

app.put('/products/:id', async (req, res) => {
  db[req.params.id] = { ...db[req.params.id], ...req.body };
  if (process.env.INVALIDATE) await redis.del(`product:${req.params.id}`);
  res.json(db[req.params.id]);
});

app.listen(8080, () => console.log('API on :8080'));server.js

Check 1: the miss and the hit

node server.js &
curl -s -w '  %{time_total}s\n' localhost:8080/products/1
curl -s -w '  %{time_total}s\n' localhost:8080/products/1
curl -s -w '  %{time_total}s\n' localhost:8080/products/1
{"id":"1","name":"Mechanical keyboard","price":89,"source":"database"}  1.018019s
{"id":"1","name":"Mechanical keyboard","price":89,"source":"cache"}  0.002854s
{"id":"1","name":"Mechanical keyboard","price":89,"source":"cache"}  0.001364soutput

The first hit was slower than the second one, here and in the run for the video (1.7 ms, then 1.2 ms). I take the later line as the number: about 1.4 milliseconds against one second.

Check 2: twenty requests miss at the same moment

The demo sends one request at a time. A real key expires while many clients are asking for it. So I deleted the key and sent twenty requests in parallel.

redis-cli DEL product:1
seq 20 | xargs -P 20 -I{} curl -s localhost:8080/products/1 | grep -o '"source":"[a-z]*"' | sort | uniq -c
  20 "source":"database"output

The app log shows twenty db query lines. Every request looked in Redis, found nothing, and went to the database on its own, because the first one needs a full second before it can fill the cache. People call this a cache stampede. With a ten second TTL and steady traffic, it happens every ten seconds.

The smallest fix lives inside the process: remember the query that is already running and let the others wait for it. That is the inFlight map in the code above.

redis-cli DEL product:1
SINGLE_FLIGHT=1 node server.js &
seq 20 | xargs -P 20 -I{} curl -s localhost:8080/products/1 | grep -o '"source":"[a-z]*"' | sort | uniq -c
   1 "source":"database"
  19 "source":"shared"output

One query, twenty answers, and the slowest request still took about one second. The limit of this fix is the process boundary. Three copies of the app behind a load balancer would run three queries. To get down to one across processes, the lock has to live in Redis itself, for example a short-lived key written with SET ... NX EX.

Check 3: what a reader sees after an update

curl -s localhost:8080/products/1
curl -s -X PUT localhost:8080/products/1 -H 'Content-Type: application/json' -d '{"price":79}'
curl -s localhost:8080/products/1
{"id":"1","name":"Mechanical keyboard","price":89,"source":"database"}
{"id":"1","name":"Mechanical keyboard","price":79}
{"id":"1","name":"Mechanical keyboard","price":89,"source":"cache"}output

The database says 79 and the API still says 89. It will keep saying 89 until the key expires, so the TTL is also the longest time a reader can see old data. Ten seconds is fine for a demo. One hour is a support ticket.

With INVALIDATE=1 the PUT route deletes the key after it writes:

{"id":"1","name":"Mechanical keyboard","price":89,"source":"database"}
{"id":"1","name":"Mechanical keyboard","price":79}
{"id":"1","name":"Mechanical keyboard","price":79,"source":"database"}output

The next read is a miss, pays the one second, and caches the new price. I delete the key on update and leave the writing to the next read. A delete is safe to repeat, and the only code that fills the cache stays in one place.

Check 4: the rate limiter in two commands

The video counts requests per IP address with INCR and gives the counter a lifetime with EXPIRE. Those are two commands, and the app can stop between them: a crash, a deploy, a timeout. This is what is left behind when only the first one arrives.

redis-cli INCR rate:203.0.113.7
redis-cli TTL rate:203.0.113.7
1
-1output

-1 means the key has no expiry. That counter will only go up. Once it passes the limit, that address is blocked until somebody deletes the key by hand.

Redis runs a Lua script as one unit, so both steps can travel together:

redis-cli EVAL "local n = redis.call('INCR', KEYS[1]) if n == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end return n" 1 rate:203.0.113.7 60
redis-cli TTL rate:203.0.113.7
1
60output

A second call returned 2 and left the TTL counting down. In the Node.js client the same script goes through redis.eval.

Check 5: when the memory is full

A fresh Redis has no memory limit and no eviction:

redis-cli CONFIG GET maxmemory
redis-cli CONFIG GET maxmemory-policy
maxmemory
0
maxmemory-policy
noevictionoutput

One cached product costs more than its text. The JSON string is 50 characters long:

redis-cli MEMORY USAGE product:1
84output

84 bytes for the key, the value and the bookkeeping around them. With only that key in it the server reported 1.65 MB used, so I set the limit to 3 MB and tried to store 30,000 products.

redis-cli CONFIG SET maxmemory 3mb
for i in $(seq 1 30000); do
  printf 'SET product:%s "{\\"id\\":\\"%s\\",\\"name\\":\\"Mechanical keyboard\\",\\"price\\":89}"\n' $i $i
done | redis-cli --pipe
redis-cli DBSIZE
errors: 17428, replies: 30000
12572output

12,572 keys fit. The other 17,428 writes were refused, and every later write gets this answer:

OOM command not allowed when used memory > 'maxmemory'.output

Reads still work, a GET right after that error returned its value. For a cache this is the wrong failure: the app keeps running, but nothing new is ever cached. So I changed the policy and ran the same 30,000 writes again.

redis-cli CONFIG SET maxmemory-policy allkeys-lru
# the same 30,000 SET commands again
redis-cli DBSIZE
redis-cli INFO stats | grep evicted_keys
redis-cli INFO memory | grep '^used_memory_human'
errors: 0, replies: 30000
11653
evicted_keys:19744
used_memory_human:3.00Moutput

No errors this time. Redis removed 19,744 keys to make room and reported 3.00M used. The Redis documentation describes its LRU as an approximation that samples a few keys on each eviction, so “least recently used” is close to true and not exact.

CONFIG SET is gone after a restart. For a real server both settings belong in redis.conf.

Two more things you can look at

How Redis stores a value depends on its size and shape:

redis-cli OBJECT ENCODING user:1        # the hash from the video
redis-cli OBJECT ENCODING page:views    # the counter
redis-cli OBJECT ENCODING greeting      # "Hello, Redis"
redis-cli OBJECT ENCODING leaderboard   # the sorted set
listpack
int
embstr
listpackoutput

The small hash and the small sorted set are both a listpack, one compact block of memory. The counter is a real integer. Redis switches to bigger structures on its own when they grow.

And the default persistence of the Docker image:

redis-cli CONFIG GET save
redis-cli CONFIG GET appendonly
save
3600 1 300 100 60 10000
appendonly
nooutput

Snapshots are on, the append-only file is off. Read the save line in pairs: after 3,600 seconds if at least 1 key changed, after 300 seconds if 100 changed, after 60 seconds if 10,000 changed. With these defaults a crash can lose up to the writes since the last snapshot. For a cache that is acceptable, as long as the app works with an empty Redis.

What I would take from this

Sources


Share this post:

Previous Post
Java garbage collection in its own log: eight checks you can run