Most Node.js services hit the same upstream endpoints repeatedly and discard every response the moment they're done with it. No caching. No reuse. The same DNS lookup, TCP handshake, TLS negotiation, and server processing — repeated on every single request. Browsers solved this problem 30 years ago. Node.js, until recently, left you to reinvent it yourself.
Undici v7 ships a standards-compliant HTTP cache interceptor that changes this. It respects Cache-Control, ETag, Last-Modified, Vary — all of it. You get browser-grade caching semantics inside your server-side HTTP client with about four lines of setup.
Undici is the HTTP/1.1 client that powers Node.js's built-in fetch(). When you call fetch() in Node.js 18+, you're already using Undici under the hood. The cache interceptor plugs into the same dispatcher.
The Problem: Why Node.js HTTP Caching Was Hard Before
Browsers handle caching automatically. CDNs cache at the edge. But your Node.js service sitting between the two — calling third-party APIs, internal microservices, or even your own database-backed endpoints — gets nothing for free.
The common workarounds:
- Roll your own in-memory
Mapkeyed by URL - Add Redis as a manual caching layer with hand-written TTL logic
- Hope the upstream has a CDN and let it absorb the load
All of these miss the Cache-Control semantics you actually want. A response with max-age=60 should be cached for 60 seconds. A 304 Not Modified should return the cached body without re-parsing. Vary: Accept-Encoding should cache different variants for different encodings. You're not going to implement all of that yourself.
How the Undici Cache Interceptor Works
The interceptor sits between your code and the network. Every outbound request passes through it. On the way back, every response is evaluated against RFC 9111 (the HTTP caching spec) and stored or skipped accordingly.
Setup: Four Lines
import {
getGlobalDispatcher,
setGlobalDispatcher,
interceptors,
request,
} from "undici";
setGlobalDispatcher(
getGlobalDispatcher().compose(interceptors.cache())
);That's the entire setup for in-memory caching with default settings. Every subsequent request() or fetch() call in that process will now cache responses that include Cache-Control: public, max-age=N or similar headers.
The cache is opt-in for a reason: it only makes sense for idempotent requests (GET, HEAD) against servers that send correct Cache-Control headers. Enabling it against an API that returns stale-unsafe responses and omits cache headers will do nothing — Undici won't cache what it can't safely cache.
Cache Stores: Memory vs SQLite
Undici ships two stores. The choice matters.
SQLite store uses Node.js's built-in node:sqlite module, which is still experimental as of Node.js 22. It's fast enough for most workloads, but flag it in your deployment docs so no one is surprised if the API changes in a minor release.
Configuration Reference
The interceptor accepts an options object:
interceptors.cache({
store: new cacheStores.MemoryCacheStore(), // default
methods: ["GET", "HEAD"], // which methods to cache
cacheByDefault: 0, // TTL for responses with no cache header (0 = don't cache)
type: "shared", // "shared" (CDN behavior) or "private" (browser behavior)
})The type option is subtle but important:
"shared"— treats the cache as a shared proxy cache. RespectsCache-Control: publicands-maxage. This is what you want for a backend service calling upstream APIs."private"— treats the cache as a per-user browser cache. CachesCache-Control: privateresponses. Use this if you're building a user-agent type client.
Testing It: Proof the Cache Works
Here's a minimal server and client pair you can run locally to verify caching behavior:
// server.ts — run with: node server.ts
import { createServer } from "node:http";
let requestCount = 0;
const server = createServer((req, res) => {
requestCount++;
console.log(`Request #${requestCount} — ${req.method} ${req.url}`);
res.setHeader("Cache-Control", "public, max-age=30");
res.setHeader("Content-Type", "text/plain");
res.end(`Response at ${new Date().toISOString()}`);
});
server.listen(3000, () => console.log("Listening on :3000"));// client.ts — run with: node client.ts
import {
getGlobalDispatcher,
setGlobalDispatcher,
interceptors,
request,
} from "undici";
setGlobalDispatcher(
getGlobalDispatcher().compose(interceptors.cache())
);
// Make 3 requests to the same URL
for (let i = 0; i < 3; i++) {
const { statusCode, headers } = await request("http://localhost:3000/data");
console.log(`Request ${i + 1}: status=${statusCode}, age=${headers["age"] ?? "fresh"}`);
}Run both. The server should log exactly one request. The client will log three responses — the second and third served from cache, with an age header showing how stale the cached response is.
Three client requests → one server hit. The age response header on cached responses tells you exactly how old the cached copy is in seconds.
Conditional Requests: ETags and 304s
When a cached response expires, Undici doesn't blindly discard it. If the original response included an ETag or Last-Modified header, Undici sends a conditional GET:
GET /data HTTP/1.1
If-None-Match: "abc123"
If nothing changed, the server responds with 304 Not Modified — no body, minimal bandwidth. Undici updates the cached response's expiry and returns the original body to your code.
This is standard HTTP behavior that most hand-rolled caches skip entirely.
When This Actually Helps
This is not a silver bullet. It helps specifically when:
- 1You're calling upstream APIs with slow or expensive responses — weather APIs, payment processors, product catalogs. Anything with
max-age > 0in the response. - 2You have high request volume to the same endpoints — a service that fans out 100 requests to the same price API on every user action becomes 1 request per cache TTL.
- 3You want 304 round-trips instead of full responses — saves bandwidth on large payloads that rarely change.
It does nothing for:
- APIs that return
Cache-Control: no-storeorno-cache - POST/PUT/DELETE requests (intentionally excluded)
- Responses without any cache directives (unless you set
cacheByDefault)
If you're calling an API that omits Cache-Control headers entirely but you know responses are safe to cache, use cacheByDefault: 60 to cache them for 60 seconds by default. You're taking responsibility for the correctness of that decision.
The Bigger Picture
HTTP caching in fetch() was always part of the spec. Browsers have had it since 1994. The fact that server-side JavaScript didn't have a standards-compliant implementation until 2024 is a gap that should have been closed earlier — but it's closed now.
If you're building Node.js services that make outbound HTTP calls, you should at minimum be aware this exists. For services hitting slow or rate-limited upstreams, enabling it is one of the lowest-effort performance improvements available.
// The entire change to unlock HTTP caching in your Node.js app:
setGlobalDispatcher(
getGlobalDispatcher().compose(interceptors.cache())
);That's it. The cache respects the server's Cache-Control headers. It handles ETags. It handles Vary. It doesn't cache what it shouldn't. Four lines, standards-compliant behavior, no Redis required.