Every earlier audit on this project had ended with the word "safe", and I had stopped believing that conclusion. Not because I held proof to the contrary at the time, but because those passes only checked the things that are easy to tick off, while that same morning the client reported over WhatsApp that all the API keys for the CMS and the price data provider had run out. A check that stops at the surface was never going to reach a cause like that, so I asked for a real business-logic audit instead.
The sweep covered eight categories and went through the whole API client layer, every route handler, the admin area, every page.tsx, and every component doing a fetch. Three findings came back CRITICAL and had already been fixed earlier the same day: a 15-second TTL on the price data provider projecting 1.7 million calls a month against a 500K ceiling, and a provider status recorder writing on every fetcher call, projecting 860K CMS writes a month against a 200K limit.
The one that stayed with me longest, though, was the finding that only rated MEDIUM.
The component that dialed out on its own
A countdown component on the page needed the latest block height from a public blockchain API. The way it fetched that number looked roughly like this:
// From every visitor's own browser, every 60 seconds, uncached
useEffect(() => {
const load = () =>
fetch("https://public-chain-api.example/blocks/tip/height", {
cache: "no-store",
})
.then((r) => r.json())
.then(setHeight);
load();
const id = setInterval(load, 60_000);
return () => clearInterval(id);
}, []);Nothing in there is broken. The component showed the correct number, and on my own screen during development it felt weightless, because one tab really is just one call per minute.
The root cause was not code quality but wiring: client-side polling connected straight to the upstream instead of going through a server cache. And once a tab is open, it stops caring whether anyone is actually looking at it. A tab left open keeps polling 24 hours a day.
The arithmetic of a single tab
The first number that made me stop reading code and start counting: one tab left open for 8 hours produces about 480 polls. The derivation is trivial, 8 hours is 480 minutes, one poll per minute, so 480 calls from a single person who may have left for lunch back in hour two.
The countdown was also not the only thing polling. A live price ticker hit an internal endpoint every 15 seconds, which is four times a minute, or about 1,920 polls across that same 8-hour tab. Added together, one tab sends around 2,400 requests. A tab left running for days means thousands of polls per browser.
Then multiply by visitors. The number the audit recorded for the countdown: 100 concurrent visitors times one call per minute equals 100 calls per minute against a free third-party API. What matters in that sentence is not the figure but the destination. Not my server, but somebody else's, from a hundred different IP addresses, with no layer in between able to hold or merge any of them.
The audit spec framed the consequence as a suspicion rather than something already observed: a rate-limit or an IP ban that silently breaks the component. I have no evidence that any ban actually happened. All I had was the shape of the usage, and that shape was reason enough to fix it.
Two leaks with the same shape and different bills
The ticker took a different path, since it called an internal endpoint rather than an outside API. But that endpoint carried Cache-Control: no-store, so no layer was allowed to hold the response at all. With a 15-second poll from every visitor, 100 concurrent visitors work out to roughly 400 origin requests per minute for the ticker alone.
Here is the interesting part: the ticker's problem was not the price provider's quota. The Redis cache absorbed that quota fine. What was unnecessary was the origin compute load, because each of those 400 requests a minute woke up a server function to rebuild a response identical to the one it had returned seconds earlier.
The fix: one shared call instead of one per visitor
For the countdown, the fix was a new server endpoint that proxies the third-party API, caches the result in Redis with a 5-minute TTL, and adds an edge cache with s-maxage=300. The component now polls that internal endpoint instead of the outside domain.
// app/api/chain-height/route.ts
export async function GET() {
let height = await redis.get(KEY);
if (!height) {
height = await fetchTipHeight();
await redis.set(KEY, height, { ex: 300 });
}
return Response.json(
{ height },
{ headers: { "Cache-Control": "public, s-maxage=300" } },
);
}After that, upstream traffic became one shared call per 5 minutes regardless of visitor count. The ceiling is easy to check yourself: 5 minutes means 12 calls an hour, 288 a day, whether one person opens the site or a thousand do.
For the ticker, the fix was a single header:
Cache-Control: public, max-age=10, s-maxage=10, stale-while-revalidate=30
The CDN now absorbs the polls instead of the origin. The price paid is freshness: worst-case staleness of 70 seconds, from 10 seconds at the edge plus 60 seconds in the layer underneath. For a price strip scrolling across the top of the page, a gap that wide is invisible.
Not every finding at that same tier got fixed. There was one more, an audit-log writer that writes to the CMS on every admin Server Action, the exact same anti-pattern as the provider status recorder above. That one was deliberately accepted as is, with no fix shipped, because those actions are auth-gated and low volume.
The audit's own conclusion was reassuring enough: the app is fundamentally sound, with no more silent quota leaks lurking.
Lessons
- Client-side polls belong behind a server-cached endpoint with a CDN edge cache. A tab left open is background polling that runs 24 hours a day with nobody watching it.
Cache-Control: no-storeon an API route is only justified when the response is genuinely per-request unique, such as auth or personalization. Otherwise let the edge cache absorb background polls cheaply.- Quota is not the only bill. On the ticker, the provider quota was already safe thanks to Redis, yet origin compute kept climbing for nothing.
- A MEDIUM rating does not mean the arithmetic is small. This finding was only MEDIUM, and its math touched every tab anyone ever opened.