Electing a Leader Tab for Refetching
refetchInterval is a timer, and every tab runs its own. A user with a dashboard open in six tabs generates six times the background traffic of a user with one — for data none of them is necessarily looking at. Broadcasting invalidations, as in Syncing Query Cache Across Tabs With BroadcastChannel, does nothing about this: it deduplicates work triggered by mutations, not work triggered by a clock. This page covers electing exactly one tab to poll, under Cross-Tab Cache Synchronization. Leadership is also what makes a shared persisted cache safe, as noted in Throttling Cache Writes to Storage.
Prerequisites
- Cross-tab invalidation already working, so followers learn about changes.
- Queries whose polling you can gate —
refetchIntervalaccepts a value per query. - The Web Locks API, or a willingness to degrade where it is missing.
Step 1 — Hold a Lock for the Tab’s Lifetime
A lock whose callback never resolves is held until the tab goes away. That is the whole election protocol.
import { useEffect, useState } from 'react';
export function useIsLeader(lockName = 'cache-leader'): boolean {
const [isLeader, setIsLeader] = useState(false);
useEffect(() => {
if (!('locks' in navigator)) {
// No Web Locks: every tab acts as leader. Correct but not deduplicated,
// which is strictly better than nobody polling.
setIsLeader(true);
return;
}
const controller = new AbortController();
navigator.locks
.request(lockName, { signal: controller.signal }, () => {
setIsLeader(true);
// Never resolves: the lock is held until this tab closes, crashes or
// navigates, at which point the browser releases it for us.
return new Promise<never>(() => {});
})
.catch((error) => {
// AbortError on unmount is expected, not a failure.
if ((error as Error).name !== 'AbortError') setIsLeader(true);
});
return () => {
controller.abort();
setIsLeader(false);
};
}, [lockName]);
return isLeader;
}
Cache Behavior Analysis: The browser owns the lock’s lifetime, so a tab that crashes, is force-killed or loses its process releases it immediately and the longest-waiting tab is promoted — there is no timeout to tune and no interval during which nobody is leader. Aborting on unmount releases leadership deliberately, which matters in development where strict mode mounts twice: without the abort, the first mount’s request would hold the lock and the second would wait forever. Falling back to setIsLeader(true) on an unexpected error is the safe direction: duplicated polling is a cost, while nobody polling is a correctness failure.
Step 2 — Gate Polling and Persistence on Leadership
Leadership is only useful if something reads it. Two things should: intervals, and the persister.
export function useDashboardStats() {
const isLeader = useIsLeader();
return useQuery({
queryKey: ['dashboard', 'stats'],
queryFn: fetchDashboardStats,
// Only the leader polls. Followers keep reading their own cache.
refetchInterval: isLeader ? 30_000 : false,
// Everyone refetches on focus, so a follower the user switches to is fresh.
refetchOnWindowFocus: true,
// Just under the interval, so the leader's scheduled refetch always finds
// the entry stale and actually runs.
staleTime: 25_000,
});
}
export function useLeaderPersistence(client: QueryClient, persister: Persister) {
const isLeader = useIsLeader('cache-persist-leader');
useEffect(() => {
if (!isLeader) return;
// One writer. Two tabs persisting to one key means the last write wins,
// silently discarding the other's snapshot.
return persistQueryClient({ queryClient: client, persister, buster: BUILD_BUSTER });
}, [isLeader, client, persister]);
}
Cache Behavior Analysis: Setting refetchInterval: false disables only the timer — the follower’s cache, its observers and its focus refetching are untouched, so switching to it shows data that is at most one focus-refetch old. Keeping staleTime just below the interval is a detail worth getting right: if staleTime exceeds the interval, the scheduled refetch fires, finds the entry fresh, and skips, so polling appears configured but never runs. Using a separate lock name for persistence means the polling leader and the persistence writer can be different tabs, which avoids one tab carrying both costs.
Step 3 — Keep Followers Self-Sufficient
A follower must never look stale to the person looking at it. Three behaviours make that true without polling.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// 1. Refetch when this tab becomes visible — the moment it matters.
refetchOnWindowFocus: true,
// 2. Refetch after a reconnect: a follower offline for ten minutes has
// missed every broadcast the leader sent.
refetchOnReconnect: true,
// 3. A short staleTime means those refetches actually fire.
staleTime: 25_000,
},
},
});
// 4. Followers still react to broadcasts, they just do not fetch on receipt.
channel.onmessage = (event: MessageEvent<SyncMessage>) => {
if (event.data.origin === TAB_ID) return;
queryClient.invalidateQueries({ queryKey: event.data.prefix, refetchType: 'none' });
};
Cache Behavior Analysis: The combination is what makes leadership invisible to the user: a broadcast marks the follower’s entries stale, and refetchOnWindowFocus converts that mark into a request at the exact moment the tab is looked at, so the first frame after switching is at most one round trip behind. refetchOnReconnect covers the gap that broadcasts cannot: a tab that was offline received no messages at all, and its entries are stale in ways nothing recorded. Because all three respect staleTime, a follower switched to twice within the stale window issues one request, not two.
Step 4 — Degrade Where Locks Are Unavailable
Web Locks is missing in some embedded webviews. The fallback must fail toward correctness.
export function createLeadership(lockName: string, onChange: (isLeader: boolean) => void) {
if ('locks' in navigator) {
const controller = new AbortController();
void navigator.locks
.request(lockName, { signal: controller.signal }, () => {
onChange(true);
return new Promise<never>(() => {});
})
.catch(() => onChange(true));
return () => controller.abort();
}
// Fallback: a claim in localStorage with a heartbeat. Weaker — a crashed
// leader leaves a claim that must time out — but better than nothing.
const CLAIM_KEY = `${lockName}:claim`;
const TTL = 6_000;
const id = crypto.randomUUID();
const tick = () => {
const raw = localStorage.getItem(CLAIM_KEY);
const claim = raw ? (JSON.parse(raw) as { id: string; at: number }) : null;
const expired = !claim || Date.now() - claim.at > TTL;
if (expired || claim.id === id) {
localStorage.setItem(CLAIM_KEY, JSON.stringify({ id, at: Date.now() }));
onChange(true);
} else {
onChange(false);
}
};
tick();
const timer = window.setInterval(tick, TTL / 3);
return () => window.clearInterval(timer);
}
Cache Behavior Analysis: The heartbeat fallback reproduces, badly, what the lock gives for free — a crashed leader leaves a claim that must expire, so there is a window of up to TTL in which nobody polls, and a race in which two tabs can both see an expired claim and both write. Refreshing at a third of the TTL keeps a healthy leader’s claim well inside its window despite background-tab timer throttling, which is the most common cause of a leader spuriously losing its claim. The fallback is worth having, but the honest framing is that it is a degraded mode, not an equivalent implementation.
Edge Cases & Gotchas
A hidden leader is throttled. Browsers heavily throttle timers in background tabs, so a leader the user has not looked at in ten minutes may poll far less often than configured. Either release leadership when the document is hidden — accepting some churn — or accept slower polling when every tab is backgrounded.
Locks are per origin, not per application. Two applications on the same origin with the same lock name will elect one leader between them. Namespace the lock name.
Development double-mount. Strict mode’s second effect run requests the same lock the first is holding, so without the abort on cleanup the tab waits on itself and never becomes leader.
Common Pitfalls & Resolutions
| Observable Issue | Root Cause | Diagnostic Resolution |
|---|---|---|
| Background traffic still scales with tab count | Polling is not gated on leadership | Set refetchInterval to false in followers |
| Nobody polls after the leader closes | A heartbeat fallback’s claim has not expired yet | Prefer Web Locks; shorten the TTL only as a last resort |
| A follower shows stale data when switched to | refetchOnWindowFocus is disabled or staleTime is too long |
Keep focus refetching on and staleTime under the interval |
| Polling appears configured but never fires | staleTime exceeds refetchInterval, so the entry is always fresh |
Set staleTime just below the interval |
| The tab never becomes leader in development | Strict mode’s first mount holds the lock | Abort the lock request in the effect cleanup |
Frequently Asked Questions
Why Web Locks rather than a heartbeat in localStorage?
Because the browser releases a lock automatically when the holding context goes away — closed, crashed, navigated, killed by the OS — and grants it to the longest-waiting requester within a frame. A heartbeat protocol has to detect that absence itself, which means a TTL, which means a window in which nobody is leader and a race in which two tabs both decide the claim expired. It also has to survive background-tab timer throttling, which is exactly when a leader’s heartbeat is most likely to lapse. Web Locks has none of those failure modes because the failure detection is the browser’s job.
Should the leader be the visible tab?
It is tempting, and it causes more problems than it solves. Tying leadership to visibility means it transfers on every tab switch, so a user alt-tabbing between two tabs triggers repeated handover, and each handover restarts timers and can produce a burst of catch-up requests. What actually matters is that exactly one tab polls, not which one — and since followers refetch on focus anyway, the visible tab is already fresh regardless of whether it holds the lock. The one case worth special handling is when every tab is hidden and the leader’s timers are throttled.
What happens to followers if the leader is throttled?
They are unaffected in anything the user can see, because their freshness comes from focus refetching rather than from the leader. What suffers is the shared benefit: if the leader is a background tab, its refetchInterval may fire once a minute instead of every thirty seconds, so the data everyone shares is older than configured. Two reasonable responses: release the lock when the document becomes hidden so a visible tab takes over, or accept the slowdown on the grounds that if every tab is hidden, nobody is looking at the data anyway.
Related
- Cross-Tab Cache Synchronization — the parent guide on coordinating several tabs.
- Syncing Query Cache Across Tabs With BroadcastChannel — the messaging half, which leadership complements.
- Throttling Cache Writes to Storage — why a persisted cache needs exactly one writer.
- Refetch on Window Focus and Reconnect — the triggers that keep followers fresh without polling.