A Chrome/Edge DevTools panel that makes a React Query (TanStack Query) cache visible — live.
React Query is brilliant precisely because it hides a lot: staleness, background refetches, observer
reference counting, garbage collection. The trade-off is that when something goes wrong — a screen
refetches too often, an invalidation cascades further than you meant, a query is mysteriously stuck
"loading" — the cache is a black box. query-cache-inspector adds a Query Cache panel to DevTools
that lists every query, its live status (fresh / stale / fetching / inactive), observer count,
last-updated time, and a streaming log of cache events as they happen.
Deep-dive guides on the cache patterns this tool helps you debug live at frontendcache.com.
The cache is the one part of your data layer with no UI. You can console.log(queryClient) but you get a
snapshot of an object graph full of class instances, not a picture of what's happening over time. So the
questions that actually matter in a bug report are hard to answer:
- Is this query stale right now, or fetching, or just inactive (cached but unmounted)?
- How many observers are mounted on
["todos","list"]? Is a strayuseQuerykeeping it alive? - When did
["user",42]last update — and did thatPATCHmutation actually invalidate it, or did the invalidation quietly miss because the key shape didn't match? - Which queries refetched when the window regained focus, and in what order?
- Why did one mutation trigger a cascade of four refetches?
Those are all questions about state over time, and that's what a panel — a table plus an event log — answers well and a REPL does not.
This is not published to the Chrome Web Store or any registry. You build it from source and load it unpacked — takes about a minute:
git clone https://github.com/frontendcache/query-cache-inspector.git
cd query-cache-inspector
npm install
npm run build # compiles src/*.ts → extension/js/*.js via tscThen in the browser:
- Open
chrome://extensions(oredge://extensions). - Toggle Developer mode on (top-right).
- Click Load unpacked and select the
extension/folder in this repo. - Open DevTools (F12) on any page and find the new Query Cache tab.
Requires Chrome/Edge 111+ (for MAIN-world content scripts). Re-run npm run build and hit the reload
icon on the extension card after pulling changes.
To reliably expose your cache to the panel, assign your QueryClient to a well-known global — a single
line, ideally behind a dev-only guard:
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient();
if (import.meta.env.DEV) {
// The one-line opt-in the inspector looks for.
(window as any).__QUERY_CACHE_INSPECTOR__ = queryClient;
}That's the reliable path and it works with any framework (React, Solid, Svelte, Vue) since it only needs
the QueryClient instance. The panel also makes a best-effort attempt to auto-detect a client on
window.__TANSTACK_QUERY_CLIENT__ (set by some TanStack devtools setups), but the explicit assignment
above is what we recommend — it is unambiguous and version-proof.
The inspector is read-only and never serializes raw cache objects across the boundary — only a
JSON-safe snapshot (query key, status, fetch status, timestamps, isStale(), observer count, and a
truncated data preview with circular refs and functions stripped). See
src/snapshot.ts.
Designing keys that invalidate predictably is half the battle here — see designing stable query keys for React Query.
A DevTools panel can't read the inspected page's JavaScript directly — it's sandboxed, and so are content scripts. Getting a live view of the cache into the panel takes four hops, and the extension is built around that path:
┌─────────────────────────────────────────────────────────────────────────────┐
│ Inspected page (MAIN world) │
│ page-script.js │
│ • finds window.__QUERY_CACHE_INSPECTOR__ (the QueryClient) │
│ • queryClient.getQueryCache().subscribe(…) │
│ • serializeCache() / serializeEvent() → a JSON-safe snapshot │
└───────────────┬───────────────────────────────────────────────────────────────┘
│ window.postMessage({ source: "qci-page", … })
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ Content script (isolated world) content-script.js │
│ • injects page-script.js into the MAIN world │
│ • relays page messages up via chrome.runtime.sendMessage │
└───────────────┬───────────────────────────────────────────────────────────────┘
│ chrome.runtime.sendMessage
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ Background service worker background.js │
│ • routes messages by tabId to the DevTools panel watching that tab │
└───────────────┬───────────────────────────────────────────────────────────────┘
│ port.postMessage (long-lived port "qci-panel:<tabId>")
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ DevTools panel panel.js │
│ • feeds snapshots/events into InspectorStore │
│ • mountInspector() renders the table, detail pane, and event log │
└─────────────────────────────────────────────────────────────────────────────┘
The reverse direction carries one message: a Refresh ping from the panel back down to the page, which
replies with a fresh snapshot. That's the whole protocol — see src/types.ts for the
message envelope.
Permissions are deliberately minimal: devtools_page, a content_scripts entry (needed to reach the
page's JavaScript at all), a background service worker, and no host permissions, no tabs, no
scripting, no activeTab.
You don't need to build the extension to see the panel work. There's a self-contained demo that drives a
mock TanStack-style QueryClient through the exact same serialization, store, and renderer modules
the real panel uses:
npm install
npm run build
npm run demo # serves the repo at http://localhost:4180/demo/Open http://localhost:4180/demo/ and watch queries flip between fetching / fresh / stale / inactive,
new queries get added and garbage-collected, and events stream into the log — all live. (A local server
is used because browsers block ES-module imports over file://.) The demo is the best proof that the
serialization and rendering logic — the parts that are unit-tested — actually work.
- Live query table — status badge, query key, observer count, and last-updated for every query.
- Color-coded status —
fetching(blue, with a spinner),stale(amber),fresh(green),inactive(grey), derived with a clear priority insrc/status.ts. - Detail pane — full query key and hash, raw
status/fetchStatus, staleness, observer count, and a truncated JSON preview of the cached data. - Live event log —
added/updated/removed/ observer events as they fire, newest first, color-coded and bounded to the last 200. - Header summary — counts by status at a glance, plus a "no cache detected" hint when nothing's wired.
- Read-only and safe — non-serializable data (functions, circular refs,
bigint,Date) is guarded, never thrown on, and never shipped raw across the message boundary. - Dependency-light — no runtime dependencies and no bundler;
npm run buildis justtsc.
Designed but not built yet (PRs welcome — each is self-contained):
- SWR support — subscribe to SWR's cache and map its keys/states into the same snapshot shape. The UI and store are already source-agnostic; this is a new page-side adapter.
- Apollo Client — read the
InMemoryCacheand normalized entities into the table. - Invalidation graph — a timeline/graph view that links a mutation to the queries it invalidated, to make cascades visible.
- Query actions — invalidate / refetch / remove a selected query from the panel (opt-in, since it mutates app state).
- Persisted event log — export the event stream as an artifact to attach to bug reports.
Why a MAIN-world content script instead of chrome.scripting? Subscribing to a live cache needs code
running in the page, with access to page globals. A declared MAIN-world module content script does that
with no scripting permission and no activeTab prompt, and it can import the shared snapshot.js —
so the same serializer runs in the page, the demo, and the tests.
Why serialize aggressively? Everything that crosses postMessage/chrome.runtime is structured-cloned
or JSON, and a real cache is full of things that don't survive that (functions, class instances, circular
graphs). serializeCache produces a flat, bounded, JSON-safe view up front, so no message can ever fail
to serialize and the panel never holds references that keep page memory alive.
Why derive one status? TanStack exposes status and fetchStatus as orthogonal axes, which is
correct but hard to scan in a table. The panel collapses them (plus staleness and observer count) into a
single badge with an explicit priority — fetching > inactive > stale > fresh — because that's the
order in which those facts matter when you're debugging.
Why a background worker at all? A DevTools panel and a content script have no direct channel; the worker is a minimal router that maps a tab id to the panel watching it. It holds no state beyond that map.
Testability. The logic that's worth trusting — serialization, status derivation, event-log reduction —
lives in pure, DOM-free modules under src/ and is covered by node:test. The DOM renderer and
the messaging glue are thin wrappers over those.
npm install
npm run build # tsc → extension/js
npm run typecheck # tsc --noEmit across src + test
npm test # node:test via tsx
npm run dev # tsc --watch
npm run demo # static server for demo/index.htmlProject layout:
extension/ # the unpacked extension (load this folder)
manifest.json # MV3 manifest
devtools.html # registers the panel
panel.html/.css # panel document + shared styles
icons/ # generated PNG icons
js/ # tsc output (gitignored, created by `npm run build`)
src/ # TypeScript sources
types.ts # shared, JSON-safe types + message envelope
status.ts # status derivation (pure)
format.ts # formatting helpers (pure)
snapshot.ts # cache → JSON-safe snapshot (pure, reused everywhere)
store.ts # inspector state + event-log reduction (pure)
render.ts # the one DOM renderer (panel + demo)
page-script.ts # MAIN-world: subscribes to the QueryClient
content-script.ts # isolated world: injects + relays
background.ts # service worker: routes by tabId
devtools.ts # creates the panel
panel.ts # panel entry: port ↔ store ↔ renderer
demo/index.html # standalone demo (mock cache, real modules)
test/ # node:test suites for the pure modules
Deep-dive articles on the cache mechanics this panel helps you observe:
- State architecture & cache fundamentals
- Cache layer architecture
- Cache invalidation & server synchronization
- Tag-based invalidation systems
- Mutation sync & rollback
MIT — see LICENSE. Copyright (c) 2026 frontendcache.