Summary
I am seeing a RangeError: Maximum call stack size exceeded failure in RTK Query when a store contains a large number of RTK Query middlewares and the first query dispatch triggers middleware self-registration.
From tracing the failure, this appears to come from RTK Query's internal middlewareRegistered dispatch path re-entering the full middleware chain via mwApi.dispatch for every uninitialized API middleware. With enough createApi instances, the resulting nested cascade appears to grow roughly quadratically and can overflow the browser call stack.
In practice, this presents as:
- a browser console
RangeError
- the query failing to initialize correctly
config.middlewareRegistered never reaching a usable state for the affected API
- the component staying stuck in loading or empty-data state
I realize that 100+ createApi instances is not a common setup, but the current behavior is a hard runtime failure and the failure mode is difficult to diagnose because the overflow is thrown deep inside RTK Query internals during thunk execution.
Reproduction repo
https://github.com/PabloJoan/break-rtk
The repo includes:
Versions
@reduxjs/toolkit: 2.11.2
react: 18.1.0
react-dom: 18.1.0
react-redux: 8.0.2
react-scripts: 5.0.1
typescript: 4.2.4
- OS: macOS
Steps to reproduce
- Clone the reproduction repo:
https://github.com/PabloJoan/break-rtk
- Run
npm ci
- Run
npm start
- Open the app in the browser
- Let the initial RTK Query request run
- Open the browser console
Actual behavior
The first query dispatch triggers a stack overflow during RTK Query middleware initialization.
Console excerpt:
redux-thunk.mjs:12 Uncaught RangeError: Maximum call stack size exceeded
at Module.isAction (redux-thunk.mjs:12:1)
at index.ts:51:1
at index.ts:67:1
at index.ts:67:1
at index.ts:67:1
at redux-thunk.mjs:7:1
at actionCreatorInvariantMiddleware.ts:29:1
at Object.dispatch (applyMiddleware.ts:52:1)
at index.ts:57:1
at index.ts:67:1
... many repeated nested middleware frames continue ...
Observed state/behavior after the failure:
- the query does not complete successfully
- the RTK Query registration path does not fully finish
- the UI remains in a loading or no-data state
Expected behavior
RTK Query middleware initialization should not be able to overflow the JavaScript call stack simply because many API middlewares are present in the store.
Even if the number of APIs is unusually high, I would expect the initialization path to be bounded and non-recursive enough that:
- the first query can initialize cleanly, or
- RTK Query surfaces a deterministic, actionable error instead of failing deep inside a nested internal dispatch chain
Why I think this happens
My understanding of the internal flow is:
- Every
createApi instance contributes its own RTK Query middleware.
- Each middleware has its own
initialized closure flag.
- On the first action it sees, the middleware sets
initialized = true and dispatches api.internalActions.middlewareRegistered(...).
- That dispatch uses
mwApi.dispatch, so it re-enters the full composed store dispatch from the top rather than continuing with next.
- The next uninitialized RTK Query middleware then does the same thing.
So the first action effectively produces a nested cascade like this:
dispatch(action A)
-> MW1 sees first action
-> mwApi.dispatch(middlewareRegistered1)
-> full chain restarts
-> MW2 sees first action
-> mwApi.dispatch(middlewareRegistered2)
-> full chain restarts
-> MW3 sees first action
-> mwApi.dispatch(middlewareRegistered3)
-> ... repeats ...
Because each nested dispatch traverses all previously initialized middlewares before reaching the next uninitialized one, the amount of nested work appears to scale roughly as O(N^2).
Relevant source references:
I also think the failure threshold is easier to hit when the first action is a query thunk, because thunk and createAsyncThunk add extra frames around the RTK Query internal dispatch chain. In my traces, the RangeError is thrown inside that cascade and then the thunk flow catches it and continues down a rejected path, which makes the resulting behavior look more like a failed query than an obvious middleware initialization bug.
Workaround / mitigation I tested
I was able to mitigate the problem locally by prepending a middleware that flattens nested middlewareRegistered dispatches.
The workaround does this:
- track whether a dispatch is already in progress
- if a nested RTK Query
middlewareRegistered action appears during that dispatch, queue it instead of letting it recurse immediately
- let the outer action finish
- flush the queued
middlewareRegistered actions sequentially afterward
That avoids the deep recursive cascade and reduces the initialization shape from approximately O(N^2) nested dispatch depth to O(N) sequential processing.
The reason this appears to work is that RTK Query sets initialized = true before dispatching middlewareRegistered, so by the time the queued action is replayed later, that middleware no longer tries to trigger a fresh registration cascade.
I am not attached to this exact workaround as an upstream solution, but it suggests that flattening or otherwise de-recursing the self-registration path would avoid the overflow.
Question
Is this considered a bug or known scalability limitation in RTK Query?
If it is a bug, would you be open to changing the middleware self-registration flow so that it does not recursively re-enter the full dispatch chain for each uninitialized API middleware on the first action?
If helpful, I can refine the reproduction further or reduce it to a smaller standalone store setup focused only on the middleware initialization path.
Summary
I am seeing a
RangeError: Maximum call stack size exceededfailure in RTK Query when a store contains a large number of RTK Query middlewares and the first query dispatch triggers middleware self-registration.From tracing the failure, this appears to come from RTK Query's internal
middlewareRegistereddispatch path re-entering the full middleware chain viamwApi.dispatchfor every uninitialized API middleware. With enoughcreateApiinstances, the resulting nested cascade appears to grow roughly quadratically and can overflow the browser call stack.In practice, this presents as:
RangeErrorconfig.middlewareRegisterednever reaching a usable state for the affected APII realize that 100+
createApiinstances is not a common setup, but the current behavior is a hard runtime failure and the failure mode is difficult to diagnose because the overflow is thrown deep inside RTK Query internals during thunk execution.Reproduction repo
https://github.com/PabloJoan/break-rtk
The repo includes:
src/services/pokemon.tshttps://github.com/PabloJoan/break-rtk/blob/main/src/services/pokemon.tssrc/services/pokemon-fix.tshttps://github.com/PabloJoan/break-rtk/blob/main/src/services/pokemon-fix.tssrc/rtkMiddlewareFlattener.tshttps://github.com/PabloJoan/break-rtk/blob/main/src/rtkMiddlewareFlattener.tsVersions
@reduxjs/toolkit:2.11.2react:18.1.0react-dom:18.1.0react-redux:8.0.2react-scripts:5.0.1typescript:4.2.4Steps to reproduce
https://github.com/PabloJoan/break-rtknpm cinpm startActual behavior
The first query dispatch triggers a stack overflow during RTK Query middleware initialization.
Console excerpt:
Observed state/behavior after the failure:
Expected behavior
RTK Query middleware initialization should not be able to overflow the JavaScript call stack simply because many API middlewares are present in the store.
Even if the number of APIs is unusually high, I would expect the initialization path to be bounded and non-recursive enough that:
Why I think this happens
My understanding of the internal flow is:
createApiinstance contributes its own RTK Query middleware.initializedclosure flag.initialized = trueand dispatchesapi.internalActions.middlewareRegistered(...).mwApi.dispatch, so it re-enters the full composed store dispatch from the top rather than continuing withnext.So the first action effectively produces a nested cascade like this:
Because each nested dispatch traverses all previously initialized middlewares before reaching the next uninitialized one, the amount of nested work appears to scale roughly as
O(N^2).Relevant source references:
middlewareRegistered: https://github.com/reduxjs/redux-toolkit/blob/v2.11.2/packages/toolkit/src/query/core/buildSlice.ts#L676I also think the failure threshold is easier to hit when the first action is a query thunk, because thunk and
createAsyncThunkadd extra frames around the RTK Query internal dispatch chain. In my traces, theRangeErroris thrown inside that cascade and then the thunk flow catches it and continues down a rejected path, which makes the resulting behavior look more like a failed query than an obvious middleware initialization bug.Workaround / mitigation I tested
I was able to mitigate the problem locally by prepending a middleware that flattens nested
middlewareRegistereddispatches.The workaround does this:
middlewareRegisteredaction appears during that dispatch, queue it instead of letting it recurse immediatelymiddlewareRegisteredactions sequentially afterwardThat avoids the deep recursive cascade and reduces the initialization shape from approximately
O(N^2)nested dispatch depth toO(N)sequential processing.The reason this appears to work is that RTK Query sets
initialized = truebefore dispatchingmiddlewareRegistered, so by the time the queued action is replayed later, that middleware no longer tries to trigger a fresh registration cascade.I am not attached to this exact workaround as an upstream solution, but it suggests that flattening or otherwise de-recursing the self-registration path would avoid the overflow.
Question
Is this considered a bug or known scalability limitation in RTK Query?
If it is a bug, would you be open to changing the middleware self-registration flow so that it does not recursively re-enter the full dispatch chain for each uninitialized API middleware on the first action?
If helpful, I can refine the reproduction further or reduce it to a smaller standalone store setup focused only on the middleware initialization path.
Agreeing with Mark, we'd accept a PR to fix this specific performance problem, and it's a great analysis of the code path, but please, still don't do this in your app.
Sure, but it's far from efficient and will never be.
In this case, you're clearly holding the chainsaw at the chain end and not the handle.
Please don't do that.
It's not designed that way. Even if this one performance issue is solved, you will miss out on a lot of features, and some other features might work against you. Also, it will still give you worse performance. More middleware, more reducers. Code that could run once runs 100 times.
We seriously…