Skip to content

RTK Query subscription info can persist when middleware is used in multiple stores #5110

@markerikson

Description

@markerikson

Question: When fetching data with RTKQ on the server side, what is the proper way of making sure that redux state does not persist across requests?

Subquestion: If one creates an RTKQ api slice using the createSlice api (and then uses the injectEndpoints method on that slice), doesn't it make the slice into a singleton that, on the server, persists between requests, along with its state?

Another subquestion Can the resetApiState be used on the server when a new store is created? If an api slice is a singleton, wouldn't this interrupt any other possible fetches that were started during other requests and that may still be in flight?

===========

EXPANDED VERSION OF THE QUESTION

I am using redux-toolkit with RTKQ in a react app with a little bit of server rendering. The app is completely custom-made and does not use any off-the-shelf framework, but perhaps resembles the early Next.js with its pages router. The pages that render on the server export a serverFetch function that fetches data with the help of RTKQ.

I have recently realized that I am seeing a contamination of redux state between routes. To my utter astonishment, that contamination only appears in some routes and not in others (I can't explain this; I would have thought that if I am leaking state between requests, this would be obvious on any request to any route). This state contamination looks like this in the stringified state sent for rehydration:

A fragment of serialized state sent to the client for rehydration
{
"restApi": {
  "queries": {
    "genomeSummaryByGenomeSlug(\"grch38\")": {
      "status": "fulfilled",
      "endpointName": "genomeSummaryByGenomeSlug",
      "requestId": "tOb9Kl-OiDuefN4IojSix",
      "originalArgs": "grch38",
      "startedTimeStamp": 1760628699336,
      "data": {
        "genome_id": "a7335667-93e7-11ec-a39d-005056b38ce3",
        "genome_tag": "grch38",
        "common_name": "Human",
        "scientific_name": "Homo sapiens",
        "species_taxonomy_id": "9606",
        "type": null,
        "is_reference": true,
        "assembly": {
          "accession_id": "GCA_000001405.29",
          "name": "GRCh38.p14"
        },
        "release": {
          "name": "2025-02",
          "type": "integrated"
        },
        "latest_genome": {
          "genome_id": "2b5fb047-5992-4dfb-b2fa-1fb4e18d1abb",
          "genome_tag": null,
          "common_name": "Human",
          "scientific_name": "Homo sapiens",
          "species_taxonomy_id": "9606",
          "type": null,
          "is_reference": true,
          "assembly": {
            "accession_id": "GCA_000001405.29",
            "name": "GRCh38.p14",
            "url": "https://identifiers.org/insdc.gca/GCA_000001405.29"
          },
          "release": {
            "name": "2025-04-28",
            "type": "partial",
            "is_current": false
          }
        }
      },
      "fulfilledTimeStamp": 1760628699339
    }
  },
  "mutations": {},
  "provided": {
    "tags": {},
    "keys": {
      "genomeSummaryByGenomeSlug(\"grch38\")": []
    }
  },
  "subscriptions": {
    "getHelpArticle({\"pathname\":\"/help/articles/gene-annotation\"})": {
      "vi3PjshCKV0iWwqDhUe0M": {},
      "pgxDyb7s8GWZolU42tIUu": {},
      "6bFpEQ2g7lKmu0HMh7Uf-": {},
      "Ai6vVG2-6KNSpDTqKxGjE": {},
      "WryjuOk7AKBNM6WB6qeLJ": {},
      "sYG_tPLny7dJWhXKr5sTj": {},
      "c7QkZS9ylEF2lJLLxdv_P": {},
      "tN7IEcYjiOiZYA1qrMf_l": {},
      "1MCLWJXRREqO5DQMaiLD8": {},
      "eC4N2O2jfvum_Mith_BZi": {},
      "OkHoie2AaIzh2s2zcVQn_": {},
      "uQvtNc6PkQ76hMWLJOF3-": {},
      "T22kAwPXBP9bzrozhtvcJ": {},
      "43oCs21ydXOWvyfniFj1m": {},
      "XV5R69WzZ2yfts8gGCKJ-": {},
      "dJHJE0MMDa02Q4ss7R0W7": {},
      "wClvVGAxr4AUz0PalCTpD": {},
      "rdH_uK_YYahxOEWDrBMx-": {},
      "p3xW0eNuzPkkEUHznLh2x": {},
      "54iU9ebr18kyacaMhOwW_": {},
      "8CrkUMJTvOc3o1-3-wpC6": {},
      "H0pXaOnVI_CDl9iYXJ3SI": {},
      "N0Akq9rnZrt__lhO2em1-": {},
      "pZEKFAJqVYshsxx3-z1J3": {},
      "cVKxOiU3p4NNb0igNQS_R": {},
      "eiusFkfGPBCgMu5AzNcbF": {},
      "3fazjMjUavcVbpem7k7Xg": {},
      "A1NW9zBABiAMOoWXXG69l": {},
      "oQ7ohXxqx9Yo7acTnpuPZ": {},
      "zQ6Nnm5mktkkmrsZVs-s7": {},
      "VTLkKN4Nbegz26eGbs_0Y": {},
      "gQWMlf_i5KstgRsFznfnQ": {},
      "8AK_mn9jBUUjI7HLcFbHa": {},
      "Sd3cwZM1yr1bOOIQv6gHc": {},
      "5ZUrCdw95C_zLkHvgMKxQ": {},
      "foyPkmJmZLYc9ojZAtd3e": {},
      "a2qc6NvOVL9dGixvBaN0i": {},
      "CkwFatxi5BcigLoLS6b7m": {},
      "V_8ZRjOHMLPse6jbAmI-r": {},
      "gcvRFzb3so4PGeKw1wjvj": {},
      "7f8Axve_J6OHcOwLlt-Ly": {},
      "MkLAKnJgfW9iStGJZ5hfB": {},
      "nom7O_klzQ6Jjnm5KhWtP": {},
      "P-6n2OCsNz3pn0VmHqpa4": {},
      "a0tUoxqP8eCRzXaMN3uWc": {},
      "TYGIFGgnZkyKrwg_XiILt": {},
      "BtVoDvcVG03CpDqEIO4C9": {},
      "k7fGO1A6AmCtm8MWnaI25": {},
      "UcCOA5V0BrpYevZlxfl_L": {},
      "wKGurxnDO00OJ23RzO51V": {},
      "bK2rgh4jALRKUWXAiwkdf": {}
    },
    "getHelpArticle({\"pathname\":\"/help/articles/using-graph-ql,\"})": {
      "VrYLOGcQsrGTRrVcwO7sP": {},
      "Tr71WtH27GZBYpHK8Ljr-": {},
      "UBkGfJ8LtXHtwLnOTCdzz": {},
      "6CBI5naxsr_pJEmMusYUE": {},
      "iRlGgbGy1JmlYO54o5PUH": {},
      "9DL8QOOjfEJ1nppy4fDsx": {},
      "dDY-bp3fvqyk66flOIOD4": {},
      "hkweOhQlNx6qwlkT3WQMB": {},
      "9VX3Ai2VQNYmYBOYneFHy": {},
      "pPMX0A3jw5r27_azEGQjI": {},
      "JgxoclGTSJ0LbDdj5sMeu": {},
      "vIQzcGQte8LZazT8fVpjI": {},
      "LVbabdS2Abju7rv32YCE9": {}
    },
    "getHelpArticle({\"pathname\":\"/help/articles/human-genome-automated-annotation\"})": {
      "sYtfcz2bRMs3HBVIj2HlU": {},
      "_-dBYjXcVEk7NB0CJfJrI": {},
      "moYbWa6pkKrx7LvDdV201": {},
      "bewIjXn5JPBbUtqQzvDPp": {},
      "O5lPR_SbEdm0aflIkkMaC": {},
      "0dtj_mSBXuhNGVGJSRDdY": {},
      "ASFPond_C_JFroX-XRJJt": {},
      "GKNUqWE5u_PmRMLu7YL4Y": {},
      "biOvK0fDkzPU5CsYroWBm": {},
      "Wk_eUO5vcLuFQcUC_cRG7": {},
      "NVqYbC77z2Jiy8y_jZEXo": {},
      "fz0QzRB-54jCItNbqwFmt": {},
      "05RsZB9B6PrNU6P2Ni377": {},
      "sqGTffTmly0U9aALtLheh": {},
      "NbvnJY7Y6sUhTs8EN53JZ": {},
      "V1qWkRUlugiQtaC6vdGEm": {},
      "bY_-rv5-ap1y9ASUzRBNE": {},
      "xwz384xYLcRBp2WvyuMQT": {},
      "lZ_6wlihaj8PPKEES6XKa": {},
      "u7ouvfiWS1S2YZfQZC0YG": {},
      "aSZqbFsPWVVQi_1cy20DS": {},
      "w4_SYgNY9DT5ZftnHfn97": {},
      "orRvaZ3vIKoaedzQ_Gg9L": {},
      "hXICt9k3gPWAV1SXNpZTJ": {},
      "3A0Z_rqhswt4-VRWThgOh": {},
      "o7M8-6yVAe5j-JxT6g59b": {}
    },
    "getHelpArticle({\"pathname\":\"/help/articles/braker-2-genome-annotation\"})": {
      "EnECKzvpEpxCCmno_AAUL": {},
      "ec0mkW8PGmQEzlzWdHEYC": {},
      "GKvqnf9vmpNAJpvCffD3K": {},
      "wCWpYoUpD8Iie6M-7nuou": {},
      "kAOaDVqxELWh305BwEm6D": {},
      "7GEgcpSA1rY0uFX9nRRHT": {},
      "JJQY8G5fYl_xM5n2ilL4r": {},
      "3EQ_BlC-URUmBWTo_jmCl": {},
      "MZ3BcOzbchSGt1rU4DbiY": {},
      "-nWn8LpignsRg5rRl_iuO": {},
      "PGKyBWq6CSPTiecXDKxP_": {}
    },
    "getHelpArticle({\"pathname\":\"/help/articles/community-genome-annotation\"})": {
      "VZtxxhvfKstZt5Fbn1gsf": {},
      "W26eGgfZJDd8equwWYvMQ": {},
      "t3WsDr8xH6KAxhvmVPgXm": {},
      "BmoO3vlfGV2S_ifXMR3tm": {},
      "6B6H-ecf2adxSW8-I17i0": {},
      "tc4RHN84UYE7ByCLsBjsJ": {},
      "Rr6AXzAf4biU7Rg1qD8i4": {},
      "FZO65gKCZy7eKECs8Gc1C": {},
      "yb2K2cs6U6qvpnTARcwyp": {},
      "PPFZV7f35zD18uLujM01s": {},
      "dN_n-iET09xfBu9LWPCbC": {},
      "M854esrWN50I6BHiKhSkR": {},
      "LO7EXPsiz-F3xAJm1O6AK": {},
      "4hVrLTVT0wGSGDR3IBTtm": {},
      "nZo6y9eXQFMENMn0pSglg": {},
      "ym9fR-5rseU5gNZmYQILx": {},
      "HGHoP7NQ6edIUFc8kuoEC": {},
      "0S0x3TBPYa71INDtgFF1K": {},
      "cpahuIE8XOlcZ0tvWb9bh": {},
      "DDplzkYyxG3PGeocmxvOw": {},
      "CzLWL_dO-kzcJaGDSkqDo": {},
      "5R72LFK1Sj8NPHqxplts0": {},
      "pIW9swFT8KJtIY-jEJJLB": {},
      "2eFsq6j7k7uUPd0A0ERQK": {},
      "86OUeXe53eRcO9yHz_2TV": {},
      "7NXA0XJAhb6rUR4hHyo-5": {}
    },
    "getHelpArticle({\"pathname\":\"/help/articles/vertebrate-genome-annotation\"})": {
      "0c5q99Cq8MycPOPN9F9xC": {},
      "zNth3VRPoRQcvszkiy3pk": {},
      "FuXHw9qcpQIl26HzRogMN": {},
      "rfpJNwMnSNmHerJpyz1RV": {},
      "5Kge36V1ulHbeZmM4Tygb": {},
      "L1EOeG-RHSc4k6jaFLolL": {},
      "sSguEFQWZO1xiSkeYU0vu": {},
      "T1LuwzJR1gvZ7lZNcQUvb": {},
      "Vt__lp6F0A3AOhJkLN8Ag": {},
      "W1Mm0XNFfTpUaDDiUWkPt": {},
      "RBnT6L5L98UDhzKykX5L1": {},
      "dk5Ofw_2G7TDV1Q7st8se": {},
      "CpHSics9Lx5ujp-C_Pbcn": {},
      "WObKp5tQ7WPtkXulsGcLG": {},
      "jQGf9VJ9WlVrj1eX5DnO5": {},
      "BGGPA9EtN6XDpqgrmrCWW": {},
      "VV8kKOI79vw4jJIydWOv0": {},
      "K0Rbl1JjQXGsrlM3sWDUu": {},
      "BClqVsMDPTABw1lA0B1uk": {},
      "5PSbR7IJIBnAHDUYhzw_E": {},
      "mp9vDeP52JkvdRYlinv9v": {},
      "Hu2Q0XCwYvhh0LOrtHdmn": {},
      "DSs0y1bOWyhS63o1K81Wz": {},
      "qv8ppHGUAZ0KaAKXRMszr": {},
      "yKua7MujIE6k2euXtYH9z": {},
      "-w81HnGA9qyIYH_RtEbPX": {},
      "jqfcXhiD2dhiU4p-_wr8v": {},
      "o3Sd6kFACzCufYa2X3awj": {},
      "yw9J42Qi5i-A6YB-_TQGY": {},
      "cSVacC1Z7Hss3z0wIvyu4": {},
      "cxtrrfBrI6vQ-5BK-NXRp": {},
      "6J9Z44k5YtBlUkJBsPKN4": {},
      "mhSlYHa9KBVI3AcjKgBdk": {},
      "jfl6krl-F6rGuIWOCA1xt": {},
      "AMISOWxYR6lXwghYhThIH": {},
      "KOkpZP0RBEfJV3s1LMoG1": {},
      "5muIkM2Q-J7gQ2NcUurQf": {},
      "PRcvkSVY93N-GmzNJwoyJ": {},
      "pcqoVJcwSI8vj3gMHkU5z": {},
      "tX2YgjYPSUwJCqMYj8GuF": {},
      "pFtnACQgzCRRNa2IVCtU4": {}
    }

}

One obvious lesson that I learnt from this was that I forgot to unsubscribe from a particular query. But it also made it painfully obvious to me that I do not fully understand how redux works on the server.

In particular, here is how I am creating a redux store:

import restApiSlice from 'src/shared/state/api-slices/restSlice';

import createRootReducer from 'src/root/rootReducer';

const middleware = [graphqlApiSlice.middleware, restApiSlice.middleware];

export const getServerSideReduxStore = () => {
  return configureStore({
    reducer: createRootReducer(),
    middleware: (getDefaultMiddleware) =>
      getDefaultMiddleware().concat(middleware)
  });
};

The restApiSlice, in turn, is created and exported like this:

// from the restSlice file

export default createApi({
  reducerPath: 'restApi',
  baseQuery: staggeredBaseQueryWithBailout,
  endpoints: () => ({}) // will inject endpoints in other files
});

The above snippet suggests to me that restApiSlice becomes a singleton that is created only once during the first module evaluation, and is not recreated at every request to the server. It must be a singleton, because it would be imported in other files that would inject their endpoints into it, like so:

// another api slice
import restApiSlice from 'src/shared/state/api-slices/restSlice';

// ...

const helpApiSlice = restApiSlice.injectEndpoints({
  endpoints: (builder) => ({
    getHelpArticle: builder.query<HelpArticleResponse, HelpArticleQueryParams>({
      query: (params) => ({
        url: `${config.docsBaseUrl}/article?url=${encodeURIComponent(
          params.pathname
        )}`
      })
    })
  })
});

Eventually, this getServerSideReduxStore function is called in an Express route per every request; so every request creates a brand new store:

const viewRouter = async (req: Request, res: Response) => {
  const reduxStore = getServerSideReduxStore();

  const matchedPageConfig = routesConfig.find((route) =>
    matchPath(route.path, req.path)
  ) as RouteConfig;

  let statusCode = 200;
  let didError = false;

  if (matchedPageConfig.serverFetch) {
    try {
      const fetchResult = await matchedPageConfig.serverFetch({
          path: req.path,
          store: reduxStore
        })
    }
  }

  // ...

But, if restApiSlice is a singleton, then creating a fresh copy of the store every request won't help, if it uses the same restApiSlice every time. And, if restApiSlice holds a state and if it persists through the whole lifetime of a server, how to prevent it from carrying over its state between requests?

Repro

https://github.com/azangru/repro-redux-toolkit-query-server

In the repro, If an api slice has an async queryFn that returns immediately, then the subscriptions block in the serialized redux store is always empty (good!). However, if a queryFn function takes long time to return, and if server-side code does not explicitly unsubscribe from the query after its completion, then the serialized redux store will contain evidence of other requests.

My questions to this are:

  • Why do previous subscriptions persist in the serialized redux state even though a new store is created every request?
  • What is the proper way of handling server-side redux queries to make sure that there is no cross-request state contamination?

Originally posted by @azangru in #5108

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions