The ops board sits stale while the operator stares at it
Stale does not mean refetch, so a watched dashboard needs an interval on that surface alone rather than a global polling default
What you'll build: an on-call dashboard that updates while someone is actively watching — without turning every idle tab into a polling botnet — by using refetchInterval only for live surfaces, and keeping the default "stale ≠ refetch" model everywhere else.
The scenario
A status board shows open incidents. staleTime: 60_000. At T+61s the data is stale, but the operator never leaves the tab, never blurs the window, never remounts the route. Nothing refetches. A Sev-1 opened 40 seconds ago never appears until they click away and back. Ticket: "the board doesn't update unless I refresh."
Someone "fixes" it with staleTime: 0 + default focus refetch — still idle-tab silent. Someone else sets refetchInterval: 5_000 on the root client. The board works. So do 200 other queries in backgrounded tabs, each polling every 5s → API cost 10× overnight.
Why it escaped QA: demos always remount or switch tabs; focus refetch masks the idle path; load tests don't leave a single tab open for minutes.
Walkthrough
Stage 1 — Name it: stale is a flag, not a timer fire
The staleness lifecycle marks data stale after staleTime. Network work waits for a trigger: focus, remount, reconnect, or refetchInterval. An operator who never triggers those events watches forever-stale UI. That is intentional resource policy — not a broken staleTime.
Stage 2 — Reject the wrong dials
- Shorter
staleTimealone — still no fetch until a trigger. - Global
refetchInterval— every query polls; battery and bill explode. - Zustand +
setInterval— hand-rolled racing cache (zustand-goes-stale); you re-own abort/dedup/visibility.
Stage 3 — Poll only the live query
export function useIncidentBoard() { return useQuery({ queryKey: ["incidents", "board"], queryFn: ({ signal }) => fetchIncidents(signal), staleTime: 10_000, refetchInterval: 15_000, // while this observer is mounted refetchIntervalInBackground: false, // pause when tab hidden refetchOnWindowFocus: true, // catch up immediately on return });}Leave catalogs on defaults (no interval). Prefer push (WebSocket → queryClient.setQueryData) when the backend already streams; interval is the HTTP fallback.
Stage 4 — Harden + verify the loop
- Cap concurrency: one board query, not one interval per widget row.
- Disable interval when
document.visibilityState === 'hidden'viarefetchIntervalInBackground: false(default). - On mutation (ack incident),
invalidateQueriesso you don't wait for the next tick.
Verify the loop. Open the board, leave focus on it, inject a server incident: within ~15s the row appears with no tab switch. Background the tab for a minute: Network shows no board polls. Refocus: one fetch. Product list in another route: still no interval traffic.
Variations
- Conditional interval —
refetchInterval: (q) => (q.state.data?.length ? 30_000 : 5_000). - SSE/WebSocket primary — interval only as heartbeat / reconnect fallback.
refetchIntervalInBackground: true— rare (alarm consoles); justify the cost.- Per-widget intervals — usually wrong; one board query, select in UI.
- Pause while
isMutating— avoid stomping optimistic rows mid-edit.
Trade-offs and common pitfalls
- Expecting
staleTimeto poll — it only expires freshness. - Global interval — cost disaster.
- Background polling defaulted on — ghost tabs hammer prod.
- Interval without
staleTimethought — still fine; interval forces fetch regardless, but focus behavior still uses stale rules for other triggers. - N widgets × interval — N× load; consolidate.
- Ignoring invalidate after write — UI lags up to one interval.
- Polling auth-sensitive PII on shared screens — lock the machine; don't poll forever on a wall display without auth posture.
- Replacing Query with setInterval+useState — lose dedup/cancel.
- Testing only with tab switches — never exercises the idle path.
- 5s interval "just to be safe" — measure; 15–60s often enough for ops boards.
- Push available but still polling hard — prefer push; poll as backup.
When NOT to poll
If users leave and return (email-style apps), default focus/remount refetch is enough — polling wastes money. If the screen is a report opened once, invalidate on navigation instead. Poll when someone is watching and silence is a bug.
See also
- Refetch triggers and dials
- Refetch on mount always spams — commitment freshness without global storms
- Zustand goes stale — why not invent a client-store poller
References
- TanStack Query — Disabling/Pausing Queries
- TanStack Query — Important Defaults
Demo source
demos/data-fetching/dashboard-never-updates-while-watching/— idle tab vs interval vs background pause. (Demo host TBD)