redux-toolkit icon indicating copy to clipboard operation
redux-toolkit copied to clipboard

RTK Query does not trigger fetch in background tabs until document gains focus at least once

Open suryateja-7 opened this issue 4 months ago • 10 comments

When using RTK Query in a React + Redux Toolkit application, queries are not executed immediately if the page is opened in a background tab (e.g., via Ctrl + Click / Cmd + Click). The fetch is deferred until the document receives focus for the first time.

This behavior breaks expected UX for users who open multiple links in background tabs, since no network requests are made until they manually switch to each tab. This can cause noticeable delays in rendering data when they finally open the tab.

Steps to reproduce:

  1. Open any page that triggers an RTK Query fetch in a background tab using Ctrl + Click / Cmd + Click.
  2. Do not switch focus to the new tab.
  3. Observe that no network request is sent until the tab gains focus.

How I reproduced - Created a Query which will fetch an array of posts & I added a logic to set the document.title to posts.count. Until I attained tab focus, the title is undefined only, as soon as focus is attained the api is fetched and title got populated correctly.

Expected behavior: Queries should execute immediately on mount, even in background tabs, without requiring the document to gain focus.

Environment: Redux Toolkit version: "@reduxjs/toolkit": "^2.6.1",

suryateja-7 avatar Aug 11 '25 14:08 suryateja-7

Can you show your store setup? Do you have the setupListeners() method being used to handle offline / focus behavior?

markerikson avatar Aug 11 '25 15:08 markerikson

import baseService

const sentryReduxEnhancer = Sentry.createReduxEnhancer({});

export const store = configureStore({
  reducer: {
    // ... Many reducers
    settings: settingsReducers,
    [baseService.reducerPath]: baseService.reducer,
  },
  middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(baseService.middleware),
  enhancers: (getDefaultEnhancers) => getDefaultEnhancers().concat(sentryReduxEnhancer),
  devTools: getEnv('MODE') !== 'production',
});

setupListeners(store.dispatch);

// Infer the `RootState` and `AppDispatch` types from the store itself
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

Here is the Store Setup @markerikson , Yes I did add setupListeners(store.dispatch);

Do let me know if you want any more info, Ill share all relevant details. Thanks

suryateja-7 avatar Aug 11 '25 16:08 suryateja-7

@suryateja-7 what happens if you remove the setupListeners() line?

markerikson avatar Aug 11 '25 16:08 markerikson

Its the same behaviour, I tried to do that, and there was no change in behaviour. The createApi also has no customization apart from the baseQuery part where we added custom auth logic.

suryateja-7 avatar Aug 11 '25 17:08 suryateja-7

Wihout the setupListeners part, RTKQ has no idea if it's in the foreground or background. If you see behaviour differences then, they are introduced by the browser that either delays JS execution or network requests.

phryneas avatar Aug 11 '25 18:08 phryneas

Hi, I created a POC of the behaviour in this Github repo: https://github.com/suryateja-7/redux-fetch-poc

It clearly demonstrates the differences when using 1. RTK Query 2. Fetch API 3. RTKQ & Fetch Together. We are noticing inconsistent behaviors in both cases.

I am attaching some resources that reproduce the issue:

  1. Repo where the replication code is present: https://github.com/suryateja-7/redux-fetch-poc (Refer readme for more info)
  2. Screen Recording which shows the inconsistent behaviour.

https://github.com/user-attachments/assets/d1204c54-7e57-40f2-90a2-c5aa9d793b2d

suryateja-7 avatar Aug 12 '25 08:08 suryateja-7

One idea, could you change the autoBatchEnhancer type on store creation?

  const store = configureStore({
    // ...
    enhancers: (gde) =>
      gde({
        // defaults to `"raf"` for `requestAnimationFrame`, try `"tick"`, `"timer"` or `false`
        autoBatch: false,
      }),
  })

phryneas avatar Aug 12 '25 19:08 phryneas

Thanks @phryneas ! Disabling autoBatch indeed resolved the issue, and the tick worked as well.

Could you help clarify the role of autoBatch in this specific scenario? I'd like to understand why disabling it was necessary for the enhancer to function properly with the store configuration.

Also, given that others might encounter similar issues, should we enhance the documentation around this? I'm thinking we could add a troubleshooting section or examples showing when autoBatch: false might be required. I'm happy to contribute to the documentation if that would be helpful.

suryateja-7 avatar Aug 12 '25 19:08 suryateja-7

The point of autoBatch is to batch many Redux store updates in quick succession together before they arrive in React, to prevent unneccessary rerenders.

It has different strategies you can choose from - the default is to limit the amount of rerenders by waiting for the next requestAnimationFrame call before triggering a React rerender. And it seems like requestAnimationFrame doesn't trigger when the tab is in the background.

So when you manually triggered a React component rerender, it would read the Redux store and immediately display the values. Data fetching 100% works in the background, it's just not triggering a React component rerender afterwards because it waits for the next animation frame for that.

Alternative values are e.g. tick, which uses queueMicrotask or timer, which will just call setTimeout. I'd recommend both of these over false, which would potentially lead to a lot more rerenders.

Tbh., I wasn't aware of the behaviour of requestAnimationFrame not triggering while a tab is in the background, but it kinda makes sense. Not sure what the best solution here is - maybe something like triggering both a requestAnimationFrame and a setTimeout with a long timeout of 100 as a fallback in case the requestAnimationFrame doesn't trigger. 🤔

phryneas avatar Aug 12 '25 21:08 phryneas

Yes, This did work out, I tried multiple variations and settled with using raf + tick together. Attaching my sample. Do let me know if you have any suggestions.

export const opinionatedAutoBatch: AutoBatchOptions = {
  type: 'callback',
  queueNotification(notify) {
    let executed = false;
    let rafId: number;

    const executor = () => {
      if (executed) return;

      executed = true;
      cancelAnimationFrame(rafId);
      notify();
    };

    rafId = requestAnimationFrame(executor);
    queueMicrotask(executor);
  },
};

Also, as previously mentioned, should we add any relevant info to docs ? Thanks for you time.

suryateja-7 avatar Aug 13 '25 11:08 suryateja-7