Some bugs are embarrassing not because they are hard to fix, but because anyone can spot them without opening DevTools. My client spotted this one from a chair, with the naked eye, and sent a screenshot. Then another one. Then another one.
Always the same content: one page, two numbers contradicting each other.
The symptom: one coin, two prices, one screen
The project was a crypto news and market data site built on Next.js 16, pulling price data from a third party API and caching it in Upstash Redis. A scrolling ticker sat at the top of every page. The /markets route rendered a full table of coins with price, 24 hour change, and volume.
The ticker said BTC was around $77k. The table on the very same page said around $76k. Same coin, same second, same screen.
A thousand dollar gap on a market data site is not a cosmetic detail. It instantly poisons every other number on the page. If two components cannot agree on the price of Bitcoin, why would a visitor trust the volume column, or the Top Movers list on the homepage?
I burned two days on this, April 28th to the 30th. And the most maddening part: across those two days there was not a single error. No red logs, no failed requests, nothing but 200s. Every component was doing exactly what it was written to do.
Two days of looking in the wrong place
With no error to follow, all I had were theories. And my first theories were all wrong in the same way: I assumed this was a rendering bug.
First guess, rounding. Maybe the ticker formatted with different precision than the table. I diffed the formatters. Identical.
Second guess, currency. Maybe one component requested USD and the other picked up a conversion somewhere. I checked the parameters. Both USD.
Third guess, CDN caching. Maybe part of the page was being served from an older response. I chased headers, forced hard reloads, opened private windows. The gap kept showing up, and more confusingly, it kept changing size. Sometimes a thousand dollars, sometimes two hundred, and occasionally the two numbers matched perfectly and I briefly thought I had fixed it.
That "occasionally correct" behaviour should have been the clue from hour one. Formatting bugs are never accidentally right. Timing bugs are.
The root cause: three sources of truth wearing one costume
When I finally stopped staring at the components and started tracing where each one got its data, the picture resolved immediately. Every surface that displayed a price had its own fetch path.
The ticker called a lightweight price endpoint for the handful of coins it needed. The /markets table called the markets endpoint with per_page=50. The first page of the coin listing called the same markets endpoint but with page=1&per_page=100. Three different calls, and crucially, three different cache keys in Redis.
// Three surfaces, three genuinely separate cache entries
cache:simple-price:bitcoin,ethereum,... // ticker
cache:coins-markets:per_page=50 // /markets table
cache:coins-markets:page=1&per_page=100 // coin listingThis was not a typo and not a config someone forgot to align. Every key carried exactly the same TTL. Reading the constants side by side, everything looked tidy and consistent.
The problem is that the same TTL does not mean the same data.
A TTL controls how long an entry lives, not when it was born. The ticker key might get refilled at second 0, the table key at second 9, the listing key at second 13, depending on who happened to arrive first after each entry expired. From then on they each run on their own phase and never line back up.
So at any given moment of observation, the ticker number could come from a fetch that just landed while the table number came from a fetch about to expire. Because each key drifts on its own phase, depending on when the next visitor happens to arrive, the age gap between surfaces can widen to nearly a full TTL window, a dozen seconds and change. In a quiet market, a gap that wide is invisible. In a moving market, a gap that wide is enough to read as a thousand dollar difference, rendered on one screen, with two numbers calling each other liars.
For two days I hunted for the component that was wrong. None of them was wrong. What was wrong was my assumption that they were looking at the same data, when architecturally they never had been.
The fix: one key, many consumers
The fix was not aligning the TTLs, because the TTLs were already aligned. The fix was deleting two redundant sources of truth.
I built one shared fetcher that pulls the top 100 coins by market cap and stores them under a single Redis key:
const KEY = "coingecko:top-coins:v1";
const TTL_SECONDS = 15;
export async function fetchTopCoinsTier1() {
const cached = await redis.get(KEY);
if (cached) return cached;
const fresh = await fetchMarkets({ perPage: 100, order: "market_cap_desc" });
await redis.set(KEY, fresh, { ex: TTL_SECONDS });
return fresh;
}Then every surface that used to own a fetch path was pointed at that fetcher, taking the slice it needed out of the same result:
const coins = await fetchTopCoinsTier1();
// ticker: a handful of headline coins
const tickerCoins = coins.slice(0, 10);
// /markets table: top 50
const marketRows = coins.slice(0, 50);
// homepage Top Movers: re-sort the same array
const topMovers = [...coins]
.sort((a, b) => b.price_change_percentage_24h - a.price_change_percentage_24h)
.slice(0, 5);The important word there is slice. Slicing and sorting moved to the consumer side instead of living in request parameters. Once per_page and page stopped contributing to the cache key, there was no longer any mechanism by which two surfaces could end up holding data from different fetches. They read the exact same object, from the exact same Redis entry, at the exact same age.
The gap disappeared completely rather than getting smaller. That is the difference between fixing a cause and sanding down a symptom.
I applied the same pattern to the NFT collection data through fetchTopNftCollections(), since the setup there was identical and the same drift was only a matter of time.
An unplanned bonus: collapsing three fetch paths into one also cut third party API calls sharply. But that was a side effect, not the goal. The goal was getting the page to stop arguing with itself.
What I took away
- A cache key is a consistency boundary. Two surfaces reading different keys are never guaranteed to agree, no matter how neatly their TTLs match.
- TTL governs age, not phase. Entries born at different moments will keep expiring at different moments, forever.
- When a discrepancy changes size and sometimes vanishes on its own, stop hunting for a formatting bug. That is a timing bug.
- Request parameters like
pageandper_pagequietly become part of your cache key. If the underlying data is the same, fetch the superset once and slice on the consumer side. - A bug the client can see without opening DevTools always costs more than it looks. One screenshot is enough to put every other number on the page under suspicion.