# How to share an API response cache across many workers with Redis

> Move a per-process response cache into Redis so forty workers pay one miss per key, with a lock on the miss and a policy that keeps cookies out of the shared store.

Source: https://voxgig.com/howto/share-a-response-cache-across-workers-with-redis

- Audience: platform-team
- Level: intermediate
- Languages: typescript, python
- Verified: 2026-09-25
- Published: 2026-09-25

## Short answer

Key each response on a namespace, the tenant or `public`, the method, and the URL, and store the status, headers, and body bytes under one key with `SET` and `EX`. Claim the miss with `SET NX EX` so one worker fetches while the rest wait for its entry. Store only 2xx responses that carry no `Set-Cookie`, no `private`, no `Vary` other than `Accept-Encoding`, and no per-caller request header under a shared key.

---
## You will need

Node 22 or later, Python 3 for the second worker, and a Redis you can reach, or the stand-in in
the code directory. Verified 2026-09-25 against Node 22.22.2, `got` 16.0.0, and `undici` 8.11.2,
with the Python worker on Python 3.11. No Redis ran here. `redis-standin.mjs` is a TCP server
that implements the six commands the cache and its demo use. It gives them the arguments and answers Redis
documents, and the page says which claims are about the stand-in. The
[SET command](https://redis.io/docs/latest/commands/set/) defines the `EX` and `NX` options the
cache relies on.[^1]

## Approaches compared

| Approach | When it fits | What it costs you | When to pick something else |
| --- | --- | --- | --- |
| [got with `keyv` and `@keyv/redis`](https://raw.githubusercontent.com/sindresorhus/got/main/documentation/cache.md) | You call through got and want HTTP caching semantics with a Redis adapter one line away | The cache obeys the origin's headers, so a response with no freshness header is fetched every time | Your client is fetch or undici, or the origin sends no cache headers |
| [Hand-rolled Redis keys](https://redis.io/docs/latest/commands/set/) | Any client, a key you design, and a policy you can read in one file | The lock, the `storable` policy, and the stored frame are yours to get right and to test | An HTTP-aware cache already does what the origin's headers ask for |
| [In-process `lru-cache` or `cachetools`](https://isaacs.github.io/node-lru-cache/) | One process, or workers that need not agree with each other | One miss per worker per key, an empty cache on every restart, and no way to invalidate across workers | More than one worker reads the same key |
| [Memcached](https://docs.memcached.org/) | A shared cache of raw values with LRU eviction and an expiry per item | One value type, eviction when memory runs out, and servers that know nothing of each other | You want the data structures or the persistence options Redis offers |
| [requests-cache with its Redis backend](https://requests-cache.readthedocs.io/en/stable/user_guide/backends.html) | Python, the requests library, and a cache name that becomes a Redis namespace | Pickled responses under the library's own keys, which only Python reads back | Workers in more than one language share the store |
| [undici `CacheStore`](https://undici.nodejs.org/#/docs/api/CacheStore.md) | fetch on Node through the global dispatcher, with entries selected by `Vary` | A store interface of three methods to write over Redis, since the two that ship are memory and SQLite | The store must be read by a worker that is not Node |

The in-process caches are the fastest option and the incoherent one: each worker pays its own
miss and forgets everything on restart. Every shared option adds a network hop and a memory
budget that all workers draw on. The HTTP-aware caches, got and undici, do the storing decision
for you from the origin's headers, and refuse to store what the headers do not allow. The
hand-rolled key does whatever your policy says, which is the whole of its cost. Memcached is the
plainer of the two shared stores.[^2]

## Key on the tenant, the method, and the URL

The key is the contract between the workers, so it is built by one function they all call.
Redis has no namespaces, and its
[documentation on keys](https://redis.io/docs/latest/develop/using-commands/keyspace/) names
the colon as the convention for splitting a key into sections, so the key is a colon-separated
path.

```ts title="cache.mjs"
export function cacheKey({ method = 'GET', url, tenant = null }) {
  const u = new URL(url)
  return `${NAMESPACE}:${tenant ?? 'public'}:${method.toUpperCase()}:${u.host}${u.pathname}${u.search}`
}

// Why a response must not be stored where other callers will read it. Returns
// the reason, or null when the response may be shared.
export function storable(request, response) {
  if (response.status < 200 || response.status > 299) return `status ${response.status}`
  if (response.headers.has('set-cookie')) return 'set-cookie'
  const directives = (response.headers.get('cache-control') ?? '').toLowerCase()
  if (/\bno-store\b/.test(directives)) return 'cache-control: no-store'
  if (/\bprivate\b/.test(directives)) return 'cache-control: private'
  // Vary names request headers that pick the representation, and the key holds
  // none of them. Accept-Encoding is safe: the body is stored decoded.
  const vary = (response.headers.get('vary') ?? '').toLowerCase().split(',').map((name) => name.trim())
  const unkeyed = vary.filter((name) => name && name !== 'accept-encoding')
  if (unkeyed.length) return `vary: ${unkeyed.join(', ')}`
  const sent = new Headers(request.headers ?? {})
  const perCaller = sent.has('authorization') || sent.has('cookie')
  if (perCaller && !request.tenant) return 'per-caller request under a shared key'
  return null
}
```

`resp:v1` is the namespace, and `v1` is there for the day the frame changes: bump it and the
old entries expire on their own. `public` stands where a tenant would, so a per-caller entry and
a shared one can never collide. The policy returns a reason rather than a boolean, because the
reason is what an operator reads in a log.

Three rules in `storable` are conditions
[RFC 9111](https://www.rfc-editor.org/rfc/rfc9111.html#section-3) sets on what a shared cache may store.
A response marked `no-store` or `private` is refused, and so is the answer to a request that
carried `Authorization`, unless the key is scoped to that caller. Then the store acts as that
caller's private cache. The `Set-Cookie` rule is stricter than the RFC, which does not
forbid caching such a response and says so.[^3] The 2xx rule is stricter too, and it keeps
the one status code the RFCs forbid a cache to store out by construction.[^4]

The `Vary` rule is about reuse, not storage.
[Section 4.1](https://www.rfc-editor.org/rfc/rfc9111.html#section-4.1) forbids answering a
request from a stored response, without revalidation, when the headers its `Vary` names differ.
The key holds none of them and the cache does no revalidation, so `storable` refuses a response
with `Vary`. `Accept-Encoding` is the exception: it only chooses a content coding, and the body
is stored without one.

## Pay one miss per key, not one per worker

Forty workers that miss the same key at the same moment send forty requests. A shared store on
its own does not stop that, because all forty see the miss before any of them stores. The fix
is a claim on the key, made with the `NX` option, which sets only when the key is absent.

```ts title="cache.mjs"
  const lock = `${key}:lock`
  const mine = `${process.pid}-${Math.random().toString(16).slice(2)}`
  const acquired = await store.set(lock, mine, 'EX', lockSeconds, 'NX')
  if (acquired !== 'OK') {
    const until = Date.now() + lockSeconds * 1000
    while (Date.now() < until) {
      await sleep(waitMs)
      const entry = await store.getBuffer(key)
      if (entry) return unpack(entry)
      // The lock is gone. The winner stores before it unlocks, so read once more:
      // no entry now means it finished without storing, and the fetch is ours.
      if ((await store.ttl(lock)) === -2) {
        const stored = await store.getBuffer(key)
        if (stored) return unpack(stored)
        break
      }
    }
  }
```

`EX` on the lock is what makes a crashed worker harmless: its claim expires and the next
worker takes over. The winner fetches, stores the entry with its own `EX`, and deletes the lock.
The others poll for the entry and read it, so the origin sees one request. The demo runs four
Node workers and one Python worker as separate processes against one stand-in, started at once.

```bash
node demo.mjs
```

```text output
node 22.22.2, five workers against one stand-in for Redis

shared store, all workers at once
  w1         /rates 2c527902a1b8acdf   /me user=w1 stored=false (set-cookie)
  w2         /rates 2c527902a1b8acdf   /me user=w2 stored=false (set-cookie)
  w3         /rates 2c527902a1b8acdf   /me user=w3 stored=false (set-cookie)
  w4         /rates 2c527902a1b8acdf   /me user=w4 stored=false (set-cookie)
  w5-python  /rates 2c527902a1b8acdf   /me user=w5-python stored=false (set-cookie)
  /rates bodies distinct: 1, served from cache: 4 of 5
  origin requests: /rates 1, /me 5
  keys in the store:
    resp:v1:public:GET:127.0.0.1:44501/rates  ttl 60s

per-process store, all workers at once
  w1         /rates 2c527902a1b8acdf   /me user=w1 stored=false (set-cookie)
  w2         /rates 2c527902a1b8acdf   /me user=w2 stored=false (set-cookie)
  w3         /rates 2c527902a1b8acdf   /me user=w3 stored=false (set-cookie)
  w4         /rates 2c527902a1b8acdf   /me user=w4 stored=false (set-cookie)
  /rates bodies distinct: 1, served from cache: 0 of 4
  origin requests: /rates 4, /me 4
```

One origin request for five workers, one body hash across two languages, and one key in the
store with a 60 second TTL. The same four Node workers with a `Map` each pay four misses, which
is where the page started. The Python worker reads and writes the same frame
as the Node ones because the frame is bytes. It is a line of JSON for the status and headers,
then the body without its content coding, since fetch decodes a compressed body and the Python
worker asks for none. Both drop the headers that described the connection, the coding, and the length. Parse
the body before storing it and the next language in the fleet inherits your parser's opinions.

## Keep Set-Cookie out of the shared store

The `/me` route answers with `Set-Cookie` and `Cache-Control: private`, and both fleets refused
to store it, five times over. The last two blocks of the demo show what the refusal prevents.

```bash
node demo.mjs
```

```text output
the pitfall: store any 2xx under a key with no tenant
  acme  from origin: user=acme set-cookie=session=acme; HttpOnly
  birch from cache: user=acme set-cookie=session=acme; HttpOnly  LEAK

the fix: refuse the response, and key the ones you keep by tenant
  acme  from origin: user=acme stored=false (set-cookie)
  birch from origin: user=birch stored=false (set-cookie)
  keys in the store: 0
```

Under a policy that stores any 2xx, birch's first request is answered from the store with
acme's profile and acme's session cookie. The origin's response was not wrong: it said
`private`, and the policy did not read it. The fix has two parts. The response is refused because it carries `Set-Cookie`, and the responses
you do keep for a caller go under that caller's tenant in the key, as the
[cache keys page](/howto/cache-keys-that-include-the-caller) works through.

## What the HTTP-aware caches do with the same responses

Both library rows make the storing decision from the origin's headers, and both run here against
the same origin, offline.

```bash
node libraries.mjs
```

```text output
got 16.0.0 | undici 8.11.2

got, cache: new Map()
  /rates  two calls, origin requests 1, second isFromCache true
  /me     two calls, origin requests 2, second isFromCache false
undici, interceptors.cache with a MemoryCacheStore
  /rates  two calls, origin requests 1
  /me     two calls, origin requests 2
```

Both stored the public rate table but not the private profile, which is what
`storable` decides from the headers alone. The
[cache documentation](https://raw.githubusercontent.com/sindresorhus/got/main/documentation/cache.md)
for got accepts anything with the `Map` interface, and its Redis path is `@keyv/redis`, whose
[adapter page](https://keyv.org/docs/storage-adapters/redis/) documents a namespace and a
separator. undici's
[cache store](https://undici.nodejs.org/#/docs/api/CacheStore.md) selects an entry by the stored
`Vary` map, and a Redis-backed store is three methods away. The Python row,
[requests-cache](https://requests-cache.readthedocs.io/en/stable/user_guide/backends.html), takes
`backend='redis'` and uses the cache name as the namespace. Each stores a frame of its own, which
is the case for the hand-rolled key once languages mix.

## Check it worked

Fifteen tests run, most of them against the stand-in, and the first is the one the page
promises: six callers at once, one origin request, and bytes that equal a direct fetch.

```bash
node --test cache.test.mjs
```

```text output
1..15
# tests 15
# suites 0
# pass 15
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 2770.097014
```

One asserts a failure on purpose. It stores any 2xx under a tenant-free key and asserts that
birch receives acme, so the leak cannot leave the demo by accident. Another runs the Python
worker and checks that its stored frame has the same header names and body bytes as a Node
worker's. A third checks that the lock key is gone once the entry is stored, because a lock that outlives its
miss serializes every reader for `lockSeconds`. Two more check that both languages refuse a
response that varies on `Accept-Language` or on `*`.

## When it goes wrong

Two workers both fetch the same key at the same moment. The claim is made with `SET` and `NX`
in one command, and a client that does `GET` then `SET` in two has a gap between them. Keep the
claim atomic, and keep `EX` on it.

Every request waits `lockSeconds` and then fetches anyway. The winner's response could not be
stored, so the waiters polled until the lock expired. The waiting loop breaks as soon as the lock
is gone, which is why the winner deletes it in `finally` rather than leaving it to expire. The
winner stores before it unlocks, so a waiter that finds the lock gone reads the key once more
before it fetches.

A stored entry outlives the data it describes. The TTL is the only invalidation this page has.
Set it from the origin's `max-age` when there is one, and delete the key on the write path when
you own both sides.

## When not to do this

Do not share a cache between workers that need to disagree. A canary worker that reads the
rate table from the same key as the fleet reads the fleet's version, and the namespace in the
key is where that distinction belongs.

Do not put a Redis hop in front of a response that costs less than the hop. A per-process
`lru-cache` or a `TTLCache` from `cachetools`, as its
[documentation](https://cachetools.readthedocs.io/en/latest/) describes it, answers
in microseconds, and a shared store answers in the time it takes to cross the network.

Do not store a response because it was a 200. The status says whether the request worked, not
whether the answer belongs to the next caller. Read the headers, and when the headers are
silent, treat a per-caller request as private.

Do not treat the stand-in as Redis. It implements six commands and one process. It has no
persistence, no eviction, and no replication, and the
[persistence options](https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/)
Redis documents are exactly what a cache that survives a restart depends on.

## Related how-tos

- [Build cache keys that include the caller and Vary headers](/howto/cache-keys-that-include-the-caller)

- [Retry fetch calls with exponential backoff in Node.js](/howto/retry-fetch-with-backoff-in-node)

## Last verified

Verified 2026-09-25 against Node 22.22.2, `got` 16.0.0, and `undici` 8.11.2, with the Python
worker on Python 3.11. Every output block is what the command preceding it printed. No Redis
ran: every command went to `redis-standin.mjs`, which implements `SET` with `EX` and `NX`, `GET`,
`DEL`, `TTL`, `KEYS`, and `FLUSHALL` over TCP with the answers Redis documents for them.

[^1]: The [SET page](https://redis.io/docs/latest/commands/set/) lists the options that replaced
four older commands and allows that those commands may one day be deprecated and removed. The
[SETEX page](https://redis.io/docs/latest/commands/setex/) says the command has been regarded as
deprecated since Redis 2.6.12, and points at `SET` with `EX` for anyone migrating or writing
fresh code. The documentation takes both positions: one page holds the door open, and another has
already closed it.

[^2]: [memcached.org](https://memcached.org/about) says the project was developed by Brad
Fitzpatrick for LiveJournal in 2003 and offers one image for it: a short-term memory for your
applications. The [documentation](https://docs.memcached.org/) has a section titled Forgetting
is a Feature. Under it, the server is by default an LRU cache, and items expire after a
specified time. Twenty-three years on, forgetting remains the feature.

[^3]: [Section 7.3](https://www.rfc-editor.org/rfc/rfc9111.html#section-7.3) of RFC 9111, on
caching sensitive information, notes that `Set-Cookie` does not inhibit caching. A cacheable
response carrying it, the section adds, can be, and often is, used to satisfy later requests.
Servers that mind are encouraged to emit appropriate `Cache-Control` fields. The parenthesis is
the standard's own, and it carries the weight of a warning.

[^4]: [RFC 6585](https://www.rfc-editor.org/rfc/rfc6585.html#section-4) defined the 429 status
code and closed its definition with one sentence of cache policy: responses with the 429 status
code must not be stored by a cache. A store that keeps only 2xx responses obeys that rule
without knowing it exists, which is the usual way a rule about caches gets obeyed.