TTL stands for time to live: how long a stored value stays valid, usually given in milliseconds or seconds. A TTL cache gives each entry a TTL when it's stored. Until that much time has passed, reading the entry returns it. From then on the entry has expired, and reading it gives back nothing, as if it had never been stored.
It's for data that goes out of date. An API response, a user's profile, or an auth token can be reused for a while instead of being fetched again every time it's needed, but not forever. The TTL says how old a value may get before you'd rather fetch a fresh one: a minute for a list of products, say.
Here's an entry stored with a TTL of 60 seconds, then read at a few moments:
| Time | What happens | Result |
|---|---|---|
| 0 s | Store user with a TTL of 60 s. | Stored |
| 10 s | Read user. | Found: it's 10 s old. |
| 59.999 s | Read user. | Found: it's 1 ms short of 60 s old. |
| 60 s | Read user. | Not found: it's 60 s old, so it has expired. |
| 75 s | Store user again. | Stored, with a fresh 60 s |
| 100 s | Read user. | Found: the new value is 25 s old. |
A TTL cache and an LRU (least recently used) cache throw entries out for different reasons. An LRU cache goes by use: when it's full, the entry that has gone untouched the longest is evicted, however new it is. A TTL cache goes by age: an entry expires when its time is up, however often it's read, and reading it doesn't make it live any longer. Some caches do both.
TTLs are all over the web. Cache-Control: max-age=3600 on a response tells the browser it's fresh for an hour, so it can be reused without asking the server. In DNS, the system that turns domain names into IP addresses, every record comes with a TTL, in seconds, that says how long it may be cached. A cookie's Max-Age attribute is the number of seconds until it expires. And apps keep API responses for a set time to save requests.
A request is in flight from the moment it's sent until its response arrives or it fails. Deduplicating, or deduping, in-flight requests means that when a caller asks for something while a request for that same thing is still in flight, no new request is sent. The caller shares the request already on its way, and gets the same result when it arrives.
It matters because the parts of a page don't know about each other. The header, the profile card, and the comment box may each ask for user 7 as the page loads. Without deduping, that's three identical requests; with it, one. Requests count as the same when they have the same key, such as the URL or the user ID:
| Time | Event | What happens |
|---|---|---|
| 0 ms | getUser(7) is called. | Nothing is in flight for 7, so a request is sent. |
| 10 ms | getUser(7) is called. | A request for 7 is in flight, so this caller shares it. |
| 20 ms | getUser(8) is called. | A different key, so a second request is sent. |
| 200 ms | The response for 7 arrives. | Both callers for 7 get the same user, and 7 is forgotten. |
| 300 ms | getUser(7) is called. | Nothing is in flight for 7 any more, so a new request is sent. |
If a shared request fails, everyone sharing it gets the failure, and the key is forgotten all the same, so the next call tries again.
Deduping isn't caching. It only merges requests that overlap in time: once the response arrives, the key is forgotten, as at 300 ms above, so later calls get fresh data. A cache keeps the result and answers later calls from it without a request, which is faster but can hand out data that's out of date. A memoized async function, one that keeps its result for each argument it has seen, does both: callers share a request while it's in flight, and its result is kept afterwards.
Data-fetching libraries such as TanStack Query and SWR dedupe requests for the same key, and cache the results as well, which is why components can each ask for the data they need without coordinating with each other.
Stale-while-revalidate is a caching strategy: a rule for when a cache answers from what it has stored, and when it fetches again. A stored value is fresh for a set time after it arrives, its max age. After that it's stale: it may be out of date, but it's still there. When someone asks for a stale value, the cache answers with it at once and, at the same time, starts fetching a new one in the background, which is called revalidating. When the new value arrives, it replaces the old one and is fresh again.
The point is that once something is stored, nobody waits. The user sees data immediately, and up-to-date data soon after. A weather app that works this way shows the last forecast it had the moment you open it, then swaps in the new one when it arrives.
Here's one key with a max age of 60 s, where every fetch takes 1 s, next to a plain TTL (time to live) cache that keeps values for the same 60 s and then throws them away:
| A read at | Stale-while-revalidate | Plain TTL cache |
|---|---|---|
| 0 s | Nothing is stored, so the read waits for a fetch and gets the value at 1 s. It's fresh until 61 s. | The same. |
| 30 s | Fresh: the stored value at once, with no fetch. | The same. |
| 100 s | Stale: the stored value at once, and a refresh starts in the background. | Expired: there's nothing to return, so the read waits for a fetch and gets the value at 101 s. |
| 101 s | The refresh arrives and replaces the stored value. It's fresh until 161 s. | The fetched value is stored, fresh until 161 s. |
| 110 s | Fresh: the new value at once. | The same. |
A plain TTL cache treats an expired value as gone, so the first read after it expires waits for the network, the slowest path there is. Stale-while-revalidate trades a little freshness for speed: for a moment, a reader may get a value that's out of date. That suits data where an old copy for a second or two does no harm, like a list of articles or a profile picture, and not data where it could mislead, like how many tickets are left.
Only one refresh per key should be in flight at a time. Three components on a page may each read the same stale key within a few milliseconds. Each should get the stale value at once, but they should share one refresh rather than send three identical requests. Several refreshes of one key also race each other: their responses can arrive in any order, so the answer to the first request could arrive last and replace a newer value.
The name comes from HTTP. A response sent with Cache-Control: max-age=60, stale-while-revalidate=30 is fresh for 60 seconds. For the 30 seconds after that, a cache may hand it out stale while it revalidates in the background; after that, it has to be checked with the server before it's used again. Data-fetching libraries for UIs took up the idea: SWR, whose name is short for stale-while-revalidate, and TanStack Query both show cached data straight away, refetch it in the background, and update the screen when the new data arrives.
Write createSwrCache(fetcher, { maxAge }). fetcher(key) returns a promise of the data for key, like a function that fetches a user by ID. createSwrCache returns an object with one method, get(key), which always returns a promise of the value for key.
The first get for a key calls fetcher(key) and fulfills with its value once it arrives. The value is stored, and it's fresh for maxAge milliseconds, counted from when it arrived: while it's less than maxAge ms old, get fulfills with it without calling fetcher.
Once the stored value is maxAge ms old or more, it's stale. get still fulfills with it straight away, and also refreshes it in the background: it calls fetcher(key) during that get call, without waiting for the result. When the new value arrives, it replaces the old one and is fresh again.
Call fetcher at most once per key at a time. While a fetch for a key is in flight, a get for that key waits for it if there's no stored value yet, and gets the stale value without starting another fetch if there is.
If a background fetch fails, keep the stale value; the next get tries again. If the first fetch for a key fails, get rejects with its error, nothing is stored, and the next get calls fetcher again.
Keys are compared the way Map compares them, so 1 and "1" are different keys.
const users = createSwrCache((id) => fetchJson("/api/users/" + id), { maxAge: 60_000 });
await users.get(7); // nothing stored: fetches user 7, then fulfills with it
await users.get(7); // under a minute old: fulfills from the cache, no request
// a minute later:
await users.get(7); // stale: fulfills with the stored user at once, and refetches user 7 in the background
// once that request is done:
await users.get(7); // the refreshed user, fresh for another minute
const clock = useFakeTimers();
let version = 0;
const fetcher = spy((key) => new Promise((resolve) => setTimeout(resolve, 100, key + " v" + ++version)));
const cache = createSwrCache(fetcher, { maxAge: 1000 });
let result;
cache.get("a").then((value) => {
result = value;
});
await clock.tickAsync(99);
expect(result).toBeUndefined();
await clock.tickAsync(1);
expect(result).toBe("a v1");
expect(fetcher).toHaveBeenCalledTimes(1);
expect(fetcher).toHaveBeenCalledWith("a");
const clock = useFakeTimers();
let version = 0;
const fetcher = spy((key) => new Promise((resolve) => setTimeout(resolve, 100, key + " v" + ++version)));
const cache = createSwrCache(fetcher, { maxAge: 1000 });
cache.get("a");
await clock.tickAsync(100); // "a v1" arrives
await clock.tickAsync(999); // it's 999 ms old: still fresh
const promise = cache.get("a");
expect(promise).toBeInstanceOf(Promise);
await expect(promise).resolves.toBe("a v1");
expect(fetcher).toHaveBeenCalledTimes(1);
const clock = useFakeTimers();
let version = 0;
const fetcher = spy((key) => new Promise((resolve) => setTimeout(resolve, 100, key + " v" + ++version)));
const cache = createSwrCache(fetcher, { maxAge: 1000 });
cache.get("a");
await clock.tickAsync(100); // "a v1" arrives
await clock.tickAsync(1000); // it's 1000 ms old: stale
let result;
cache.get("a").then((value) => {
result = value;
});
await flush();
expect(result).toBe("a v1");
expect(fetcher).toHaveBeenCalledTimes(2);
const clock = useFakeTimers();
let version = 0;
const fetcher = spy((key) => new Promise((resolve) => setTimeout(resolve, 100, key + " v" + ++version)));
const cache = createSwrCache(fetcher, { maxAge: 1000 });
cache.get("a");
await clock.tickAsync(1100); // "a v1" arrived at 100 ms and is stale now
cache.get("a"); // serves "a v1" and fetches "a v2"
await clock.tickAsync(100);
await expect(cache.get("a")).resolves.toBe("a v2");
expect(fetcher).toHaveBeenCalledTimes(2);
const clock = useFakeTimers();
let version = 0;
const fetcher = spy((key) => new Promise((resolve) => setTimeout(resolve, 100, key + " v" + ++version)));
const cache = createSwrCache(fetcher, { maxAge: 1000 });
cache.get("a");
await clock.tickAsync(1100); // "a v1" is stale
const results = [];
cache.get("a").then((value) => results.push(value));
cache.get("a").then((value) => results.push(value));
cache.get("a").then((value) => results.push(value));
await flush();
expect(fetcher).toHaveBeenCalledTimes(2);
expect(results).toEqual(["a v1", "a v1", "a v1"]);
const clock = useFakeTimers();
let version = 0;
const fetcher = spy((key) => new Promise((resolve) => setTimeout(resolve, 100, key + " v" + ++version)));
const cache = createSwrCache(fetcher, { maxAge: 1000 });
const results = [];
cache.get("a").then((value) => results.push(value));
cache.get("a").then((value) => results.push(value));
await clock.tickAsync(100);
expect(fetcher).toHaveBeenCalledTimes(1);
expect(results).toEqual(["a v1", "a v1"]);
const clock = useFakeTimers();
let online = true;
const fetcher = spy(async (key) => {
if (!online) throw new Error("offline");
return key + " v1";
});
const cache = createSwrCache(fetcher, { maxAge: 1000 });
await cache.get("a");
await clock.tickAsync(1000);
online = false;
await expect(cache.get("a")).resolves.toBe("a v1"); // stale; the background fetch fails
await flush();
await expect(cache.get("a")).resolves.toBe("a v1"); // still stale, so it fetches again
await flush();
expect(fetcher).toHaveBeenCalledTimes(3);
const error = new Error("offline");
let online = false;
const fetcher = spy(async (key) => {
if (!online) throw error;
return key + " v1";
});
const cache = createSwrCache(fetcher, { maxAge: 1000 });
await expect(cache.get("a")).rejects.toBe(error);
online = true;
await expect(cache.get("a")).resolves.toBe("a v1");
expect(fetcher).toHaveBeenCalledTimes(2);
const fetcher = spy(async (key) => typeof key + " " + key);
const cache = createSwrCache(fetcher, { maxAge: 1000 });
await expect(cache.get(1)).resolves.toBe("number 1");
await expect(cache.get("1")).resolves.toBe("string 1");
expect(fetcher).toHaveBeenCalledTimes(2);
Run tests runs every test on your code, here in your browser. The problem is solved when they all pass.
Ctrl + Enter runs the tests (⌘ + Enter on a Mac).
I'm here to help with Stale-while-revalidate cache. I give hints, never the solution, so you get to solve it yourself. Ask for a hint, select code to ask about it, or press Ask Tuto why on a failing test.
Your first 3 answers need no account. for free to keep asking.