Skip to content

frontendcache/query-cache-inspector

Repository files navigation

query-cache-inspector

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 problem

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 stray useQuery keeping it alive?
  • When did ["user",42] last update — and did that PATCH mutation 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.


Install (load unpacked)

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 tsc

Then in the browser:

  1. Open chrome://extensions (or edge://extensions).
  2. Toggle Developer mode on (top-right).
  3. Click Load unpacked and select the extension/ folder in this repo.
  4. 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.


Connect your app

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.


How the panel works

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.


Try the UI without installing

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.


Features

  • Live query table — status badge, query key, observer count, and last-updated for every query.
  • Color-coded statusfetching (blue, with a spinner), stale (amber), fresh (green), inactive (grey), derived with a clear priority in src/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 logadded / 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 build is just tsc.

Roadmap

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 InMemoryCache and 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.

Design notes

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.


Development

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.html

Project 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

Related reading

Deep-dive articles on the cache mechanics this panel helps you observe:


License

MIT — see LICENSE. Copyright (c) 2026 frontendcache.

About

A DevTools panel that visualizes a live React Query (TanStack Query) cache — queries, fresh/stale/fetching state, observers, and cache events. MV3, no bundler, with a standalone demo.

Topics

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors