Fixing a Silent Cache Bug in npmx.dev
DEV Community

Fixing a Silent Cache Bug in npmx.dev

This is a submission for DEV's Summer Bug Smash: Clear the Lineup. The bug npmx.dev is a fast browser for the npm registry - you can look up any package or compare two side by side. On the compare page, package version numbers were sometimes just... wrong. Not crashing-wrong, just quietly stale - showing a version that npm had long since replaced. The bug had already been reported (issue #1832): comparing tinyclip and copy-paste showed tinyclip stuck at its very first published version, 0.0.1 , when the real latest was 0.1.8 . The original screenshots in that issue show the mismatch clearly next to what the npm registry itself reports. The kind of bug that's easy to shrug off as "just a display issue" - but on a site whose entire purpose is showing accurate package data, a silently wrong version number undermines the one thing the tool exists to do. Chasing it down My first assumption was that the comparison logic itself was buggy - maybe it was reading the wrong field, or resolving latest incorrectly. That assumption was wrong, and figuring out why it was wrong is most of this story. Tracing the data flow: the compare page calls a composable called usePackageComparison , which for each package calls $npmRegistry(...) to fetch that package's full registry data - including which version is tagged latest . That function is a thin wrapper around another composable, useCachedFetch() . That's where it got interesting. useCachedFetch() takes a ttl (time-to-live) argument - a signal for how long a cached response should be considered fresh. Except on the client, the parameter was named _ttl : _ttl: number = FETCH_CACHE_DEFAULT_TTL, The underscore prefix is a convention for "this parameter is intentionally unused." It was accepted, and then never referenced again in the function body. Whatever caching strategy the ttl value was supposed to control, it wasn't actually controlling anything on the client. Instead, the client branch hardcoded this: const defaultFetchOptions: Parameters [1] = { cache: 'force-cache', } force-cache is a browser fetch option with a specific meaning: if any cached response already exists for a URL, use it - don't check whether it's still fresh. That's different from the browser's normal caching behavior, which checks Cache-Control headers and revalidates once a response goes stale. So once a browser had fetched a package's registry data one time - whenever that first happened, possibly weeks earlier - it would keep serving that exact same response indefinitely. New versions could be published to npm and the app would never know, because the browser was never allowed to ask again. I checked what the npm registry actually sends back, to confirm this wasn't just a theory: cache-control: public, max-age=300 etag: "..." The registry is already telling clients exactly how to cache correctly - treat this as fresh for 5 minutes, then use the ETag to check back efficiently. The app just wasn't listening to that instruction. The fix The fix itself is small - which is sometimes a sign you've found the actual root cause rather than papered over a symptom. In app/composables/useCachedFetch.ts , both places that hardcoded cache: 'force-cache' (the client branch, and a server-side fallback branch with identical logic) now use: const defaultFetchOptions: Parameters [1] = { cache: 'default', } 'default' tells the browser to follow standard HTTP caching rules - respect the registry's max-age , and revalidate via ETag once it expires, rather than assuming a response is correct forever regardless of age. PR: https://github.com/npmx-dev/npmx.dev/pull/3156 Verifying it - Ran the existing test suite - all 1,000+ unit tests across 55+ files passed with zero failures, confirming the change doesn't break anything else relying on this composable - Loaded the compare page against the fix and confirmed it renders cleanly with no console errors - Inspected the request in DevTools and confirmed a full, real network round-trip completes end to end (DNS lookup, connection, waiting for server response, content download) rather than resolving instantly - consistent with the browser actually being allowed to talk to the registry instead of only ever reading a frozen local copy What I'd take from this The most useful signal in this whole investigation wasn't a stack trace or an error message - it was an unused function parameter. _ttl was evidence that someone had designed a caching contract around a real TTL concept, and a later implementation detail (hardcoding force-cache ) quietly bypassed it without anyone updating the signature to match. Dead parameters are worth pausing on - they're often a fossil record of intent the code no longer honors. Fixes npmx-dev/npmx.dev#1832 via PR #3156 Top comments (0)

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.