Anatomy of a poisoned cache
A twelve-line in-flight de-duplication cache with a bug that only fires when the network blips. A walk through how Ferret catches it.
In-flight de-duplication is one of those patterns everyone writes eventually. Two parts of your app ask for the same user at the same time, and you would rather make one request than two. So you keep a map of pending promises and hand the same one to both callers. Twelve lines. What could go wrong.
const inflight = new Map<string, Promise<User>>(); function get(id: string): Promise<User> { const pending = inflight.get(id); if (pending) return pending; const p = fetchUser(id); inflight.set(id, p); return p.then((user) => { inflight.delete(id); return user; });}Read it the way a reviewer reads it. Get the pending promise, return it if present. Otherwise start a fetch, store it, and delete it once it resolves. Clean. It ships. It works in every test you wrote, because every test you wrote had a network that answered.
The blip
Now the network blips. fetchUser rejects. Look at where delete lives: inside the then callback, which only runs on success. A rejected promise skips it entirely. So the rejected promise stays in the map.
Every future call for that id finds the pending entry and returns it. The same rejected promise. Forever, or until the process restarts. One transient failure and that user is permanently broken in your cache, replaying an error that has nothing to do with the current state of the network.
It is not a bug in the happy path or the sad path. It is a bug in the transition between them.
How Ferret corners it
Ferret does not pattern-match on the shape of the code. It reasons about the lifetime of the map entry: added on line 8, removed only on the resolve branch. It notices there is no path that removes it on rejection, and that the entry is read again on the next call. That is a leak with an observable consequence, so it writes a test to observe it.
test("a failed fetch does not poison the cache", async () => { let attempts = 0; const fetchUser = vi.fn(async (id: string) => { attempts++; if (attempts === 1) throw new Error("network blip"); return { id, name: "Ada" }; }); const cache = createUserCache(fetchUser); await expect(cache.get("u1")).rejects.toThrow("network blip"); await expect(cache.get("u1")).resolves.toEqual({ id: "u1", name: "Ada" }); expect(fetchUser).toHaveBeenCalledTimes(2);});The test fails on the current code exactly the way production does: the second get replays the cached rejection instead of retrying, and fetchUser is called once instead of twice. Then Ferret proposes the fix, which is to clear the entry whether the fetch resolves or rejects.
const p = fetchUser(id).finally(() => { inflight.delete(id);});inflight.set(id, p);return p;finally runs on both branches. The entry is cleared, the next call retries, the test goes green. The whole exchange took the time it took you to read this paragraph, and the poisoned cache never made it to a pager.
This is the shape of most real defects. Not exotic, not clever, just a transition nobody executed. There are more of them in your codebase than you would like. Ferret is good at finding exactly this kind.
Point Ferret at your repo.
The bug in this post is the kind Ferret finds all day. Free on one repo, with the failing test and the fix included.
Start hunting