React Query Not Refetching? Debugging a Stale Wallet Balance
Why React Query refetchQueries did nothing in my Chrome extension wallet: a stale filter, disabled queries and lost MV3 messages, and how I fixed each.
CarnielOn this page13 sections
A user swaps tokens in the wallet. The transaction confirms, a green toast says "Success", and the balance on screen doesn't move. Close the popup, open it again, and the right number appears.
That was the bug in Decane Wallet, a multi-chain crypto wallet I built as a Chrome extension. The fix looked like one line of React Query. It turned out to be three separate problems stacked on each other: a filter that matched nothing, a circuit breaker that kept queries switched off, and messages that never reached the popup.
I found it during my own test swaps on a testnet. The toast said success, the explorer showed the transaction confirmed, and the popup still showed the old balance. I spent longer than I'd like to admit convinced the RPC was slow before I looked at my own code.
Key takeaways
With the default
staleTimeof 0, every query is stale as soon as it loads, so astale: falsefilter onrefetchQueriesmatches nothing and fails silently.
refetchQueriesnever refetches disabled queries, whatevertypeyou pass. Re-enable them first.In a Manifest V3 extension, messages from the background service worker to the popup can be lost. A counter in
chrome.storageplusstorage.onChangedgives you a delivery path that doesn't depend on timing.Test your filters with
queryClient.getQueryCache().findAll(filters)before trusting them.
How balances flow through the extension
A Manifest V3 extension has two halves that matter here. The background service worker sends transactions and watches for confirmations. The popup is a React app that shows balances, cached with React Query under keys like ["balances", chain, address] and ["pocketBalances", ...] for the Pockets savings feature.
When a transaction confirms, the background tells the popup, and the popup refetches. On paper, that's all.
The symptom: a log line and no request
The first clue was the console. The popup printed Refetching balances every time a transaction confirmed, so the message was arriving and the handler was running. But the Network tab showed no balance request at all. React Query was being asked to refetch and was quietly choosing not to.
This was the call:
await queryClient.refetchQueries({
queryKey: ['balances'],
exact: false,
type: 'all',
stale: false,
fetchStatus: 'idle',
})I'd added stale: false and fetchStatus: 'idle' to avoid duplicate requests. The balance hook already fetched on mount, and I didn't want a confirmation to fire a second request on top. In my head, the filters meant "only refetch what needs it". They actually meant "only refetch what doesn't".
Problem 1: a filter that matched nothing
Every option in that object is a filter. A query has to pass all of them to be refetched (TanStack Query filters guide). The queryKey and type: 'all' filters were fine. The stale: false filter was the problem.
stale: false means "only queries that are still fresh". React Query's default staleTime is 0, so data is stale the moment it arrives (Important Defaults). Every balance query in the cache was stale, so every one failed the filter. Zero matches, zero requests, and no error, because matching nothing is a valid result.
fetchStatus: 'idle' made it worse in a smaller way. Any balance query that happened to be loading at that moment was skipped too.
The fastest way to catch this kind of bug is to run your filters against the cache yourself. In the popup's devtools console, or a temporary log:
const matches = queryClient.getQueryCache().findAll({
queryKey: ['balances'],
stale: false,
})
console.log(matches.length) // 0If that number is 0, React Query isn't broken. Your filter is.
Problem 2: the circuit breaker kept queries off
Removing the two filters fixed some refetches but not all. The rest came from my own code.
The wallet talks to public RPC endpoints, and those rate-limit hard. To stop a broken endpoint from being hammered, I'd added a circuit breaker: when a balance query got a 429 or a 500, its key went on a "dead" list and the query was set to enabled: false.
That's reasonable on its own. The trouble is that refetchQueries never refetches disabled queries, no matter what type you pass. The TanStack Query source says so in its own doc comment for the method (queryClient.ts). type: 'all' only decides between active and inactive queries. A disabled query is skipped either way.
So the fix was to clear the dead list before refetching. A confirmed transaction proves the chain is responding, which makes it a safe moment to reset the breaker:
const triggerBalanceRefresh = () => {
// A confirmed tx means the chain is up, so re-enable
// any queries the circuit breaker switched off
clearDeadQueryKeys()
refetchBalances()
}I'd built the circuit breaker weeks earlier, after a public RPC rate-limited the popup into a wall of 429 errors. By the time this bug showed up, I'd forgotten it existed. It was the last place I looked.
Problem 3: messages that never arrived
With the filters fixed, the bug became intermittent, which is worse. Most swaps updated the balance. Some didn't.
The cause was the Manifest V3 service worker. Chrome terminates an extension's service worker after 30 seconds of inactivity and starts it again when an event arrives (Chrome service worker lifecycle). A runtime.sendMessage from the background only reaches the popup if the popup is open and its listener is registered at that moment. Messages sent at the wrong time were dropped.
The fix was a second delivery path that doesn't depend on timing. Instead of only sending a message, the background also increments a counter in extension storage:
// background, after a transaction confirms
const { balance_nonce = 0 } = await browser.storage.local.get('balance_nonce')
await browser.storage.local.set({ balance_nonce: balance_nonce + 1 })The popup listens for changes to that key. storage.onChanged fires in every open extension page when a value changes (chrome.storage API), so if the popup is open, it hears about it. If the popup is closed, nothing is lost: the next time it opens, the queries mount, find stale data and fetch fresh balances anyway.
Both paths can fire for the same transaction. That's fine. React Query dedupes requests for the same key, so the second trigger costs nothing.
One more delay: the indexer
Even with everything above, a balance could still come back unchanged. On EVM chains, the indexer behind a balance API can lag a few seconds behind the confirmation. The refetch ran, but the data source hadn't caught up.
A second refetch five seconds later covers that gap:
refetchBalances()
setTimeout(refetchBalances, 5_000) // indexers can lag behind confirmationThe final listener
Put together, the popup's listener looks like this:
const GlobalToastListener = () => {
const queryClient = useQueryClient()
useEffect(() => {
const refetchBalances = () => {
queryClient.refetchQueries({ queryKey: ['balances'], type: 'all' })
queryClient.refetchQueries({ queryKey: ['pocketBalances'], type: 'all' })
}
const triggerBalanceRefresh = () => {
clearDeadQueryKeys()
refetchBalances()
setTimeout(refetchBalances, 5_000)
}
// Fast path: direct message from the background
const handleMessage = (message: { type: string }) => {
if (message.type === 'BALANCE_REFRESH') triggerBalanceRefresh()
}
// Reliable path: the background bumps balance_nonce in storage
const handleStorage = (changes: Record<string, unknown>, area: string) => {
if (area === 'local' && 'balance_nonce' in changes) triggerBalanceRefresh()
}
browser.runtime.onMessage.addListener(handleMessage)
browser.storage.onChanged.addListener(handleStorage)
return () => {
browser.runtime.onMessage.removeListener(handleMessage)
browser.storage.onChanged.removeListener(handleStorage)
}
}, [queryClient])
return null
}I tested every case I could think of: the popup open during the swap, closed and reopened straight away, and closed for over a minute so the service worker went idle. The balance updated in all of them.
What I took away from it
Filters fail silently. A filter that matches nothing returns nothing. Check the match count with findAll before you assume React Query is ignoring you.
Know which queries are disabled. Anything that sets enabled: false at runtime, like a circuit breaker or an auth check, also opts those queries out of refetchQueries. Undo it on purpose before you refetch.
Don't trust one message in MV3. If something must reach the UI, write it to storage too. Storage changes reach every open page, and closed pages read fresh state when they open.
Comments can be wrong. My own comment in this file once said type: 'all' got past the enabled check. It doesn't. Clearing the breaker is what made it work. Reading the library source settled it in a few minutes.
FAQ
Should I use invalidateQueries or refetchQueries?
For "the server data changed", invalidateQueries is usually the better default. It marks matching queries stale and refetches the active ones, and inactive queries refetch when they're next used. Use refetchQueries when you need a request right now, for every match. Neither one refetches disabled queries.
Why didn't React Query show an error?
Because nothing went wrong from its side. Your filters matched zero queries, so there was nothing to refetch. That's why findAll is the first thing to check.
Does chrome.storage.onChanged fire in the popup?
Yes, as long as the popup is open and has registered the listener. When it's closed, there's nothing to notify, so make sure the popup fetches fresh data when it opens.
Conclusion
A balance that doesn't update looks like one bug, but here it was three: a filter, a disabled query and a lost message. Each one alone would have been quick to fix. Together, they made the problem look random.
If you're building with React Query and want fewer refetches to manage in the first place, my guide to use cache in Next.js 16 covers caching on the server side.