Implementation notes on react's scheduling model as of (shortly before) 16.8.0
While the public API intended for users to use is the
scheduler package, the
reconciler currently does not use
scheduler's priority classes internally.
ReactFiberScheduler has its own internal "mini-scheduler" that uses the
scheduler package indirectly for its deadline-capable scheduleCallback.
This is kind of a documentation of implementation details that I suppose will be gone by the end of the year, but what can you do.
ReactFiberScheduler keeps a list of pending batches of updates, which it
internally calls "work". Each individual fiber in the tree is a "unit" on which
a "unit of work" will be "performed".
Updates caused from inside a React render, lifecycle method/hook, or React-controlled event handler belong to an implicit batch.
Updates triggered from outside React are effectively a single-update batch unless it's inside the scope of one of the batching method wrappers, shown later.
Any update on any fiber traverses the entire tree mounted on the root
createRoot) where the update happened. React is very
good at bailing out of fibers that are not the subject of the update.
ReactFiberScheduler has its own priority classes that are independent from the
priority classes in the
ConcurrentMode, any and all updates are sync no matter what. The only
difference is whether they are batched or not.
When the fiber where an update is triggered is in
ConcurrentMode, there are 3
possible priority classes:
- 5000ms deadline, async
- This is the default priority for any update that does not otherwise have its own priority or deadline.
interactive(roughly equivalent to
- 150ms deadline, async
- in development builds it's 500ms to make slow interactions feel worse
- At most one
interactivepriority work may be scheduled.
scheduler.ImmediatePrioritybut "more immediate"):
- sync (duh)
ImmediatePrioritythis won't involve the
schedulerat all and just immediately start work.
syncupdate will skip over any pending non-
syncupdate, even if it has expired
- is the default (instead of
deferred) for updates triggered in the commit phase
useLayoutEffectrun effectively in a single batch belonging to this priority. Any updates they trigger will be
- sync (duh)
Any update triggered directly during a render phase inherits the deadline of the
current render phase. However, because any one fiber's update is atomic, this
part of the processing is synchronous per fiber even in
In class components, this means any update caused in a lifecycle that runs
render itself; this includes
setStates called in the
and the lifecycles that derive state, which are processed like a
with function argument. All state updates are accumulated while the lifecycle is
invoked and then applied synchronously after each lifecyle method returns.
render itself is either a guaranteed noop or a
guaranteed infinite loop, unless your
render is impure.
In function components, all state updates (i.e. invoking dispatchers from
useReducer) that happen during the render function are
accumulated into a queue during the render pass. If the queue is non-empty, the
component re-renders, and
useReducer apply their respective
queued updates from the previous pass as they are reached; until a render pass
produces no further queued updates. The number of re-renders is currently
limited to 50.
NOTE: this is not in any alpha but will be in 16.8.0: any function component
that invokes any hook will be double-rendered in
StrictMode, and this is
outside the render pass loop described above. Both the hooks and the
components themselves must be pure. This also means that, whenever
useState would invoke their callbacks, they will always be double-invoked. On
mount, the first
useRef object will always be discarded, and only the one from
the second invocation will persist.
useEffect are all collected into a single independent batch, called the
"passive effects", and run inside a
scheduler.scheduleCallback with no
deadline, queued right before the commit phase ends. However, should any further
React update happens, regardless of priority class or deadline, the schedule
will be canceled and all pending
useEffects for the entire previous commit
will be invoked synchronously before any work starts. This happens even when
setState or a
useReducer dispatcher. If the value is a
callback, the previous commit's pending
useEffects will all have been
processed by the time the callback is invoked.
interactive update forces the previous
interactive update, as well as
any other outstanding updates with a shorter remaining deadline than that to
commit synchronously first before the new
interactive update itself is
In other words, it converts the previous
interactive update, as well as all
work that should have expired by the time it expired into a single
These are the only cases I found where a non-
sync update may be, effectively,
sync by the reconciler.
Batch wrappers, or how to request a priority level
It seems to be intended that user generic code uses priority classes and the
methods from the
scheduler package instead of these.
However, sometimes it is needed to interact with React specifically, so ReactDOM
exposes these (mostly prefixed with
unstable_, just like
batchedUpdatescauses all updates triggered inside it to share the same deadline. In other words, they will all belong to the same unit of work, and will all be rendered and committed together.
batchedUpdatesdoes not have its own priority class, instead the callback inherits the current one.
batchedUpdatescan be nested; this merely merges the updates inside it with the outermost batch (batches are flattened).
- Other batching methods are not implemented as, but behave as if their
callbacks were themselves wrapped in
batchedUpdatesdoes not otherwise inherit a specific priority class, it defaults to
interactiveUpdatesis a batch that has
interactivepriority. React synthetic event handlers run as an
- Remember again that at most one
interactivepriority work may be scheduled. Should another
interactivebatch be queued, the previous
interactivework is synchronously committed.
- Remember again that at most one
syncUpdatesis a batch that has
syncpriority. React will immediately render and commit all updates inside this batch, before the
- In non-
ConcurrentModeany kind of batching just behaves like this one.
- Explicitly requesting
renderor a lifecycle method/hook (except
useEffectspecifically) is an assertion error. The only implicit batches where you are allowed to request
useLayoutEffect) and in an event handler, which is just an
- In non-
useEffect callback or destructor triggers a
sync update through
either being in a non-
ConcurrentMode tree, or by using
mentioned above, there will be a
sync commit done before any new update can
even begin evaluation.
If any work being processed that's not yet in the commit phase, be it
deferred, is interrupted by a higher priority work, all
progress done so far is completely thrown out.
Anything done in the commit phase is always
sync or belongs to a cascading
sync batch so the commit phase can never be interrupted.
After React commits the higher priority (shorter deadline) work, it will start
or restart the next higher priority work on top of the freshly committed state.
This will typically be
interactive batches before
deferred batches, but if a
deferred batch has fallen too far behind (i.e. its deadline is too
close to expiry) it will run ahead of
This means that any lifecycle or render method (function component body) not in
the commit phase can potentially be called multiple times per render.
StrictMode ensures that is always done at least twice per render during
The only things called by React that are guaranteed to only be invoked once per
React update are the commit phase lifecycles (
useLayoutEffect), passive effects
useEffect) and, for completeness, event handlers.
There is a non-
ConcurrentMode hack to only invoke class component
constructors once if the component or another child or sibling sharing the
same Suspense boundary suspends when the class is being mounted. This does not
ConcurrentMode and classes are also subject to multiple construction
if the update where they are being first mounted is interrupted by a higher
These instances, regardless of mode, will be discarded without invoking
componentWillUnmount if they are never part of a
Note that that any update caused inside a
scheduler.scheduleCallback does not
count as a batch unless the update is itself wrapped in
syncUpdates. React currently is not aware of
scheduler tasks and only uses it as a
requestIdleCallback with a timeout.
Work loop and consequences
This also means that the behavior of state updates is different in subtler ways
than I thought in
ConcurrentMode than in non-
I will use the traditional
setState to represent an update but calls to
ReactRoot#render, updates to
values also cause updates.
ConcurrentMode, a non-batched
setState will always commit
synchronously no matter what. Any batching of them will also commit
synchronously, but as a single update that is computed on top of the result of
all of the
setStates may or may not form incidental
mini-batches, depending on how busy the renderer is and on whether their
deadlines get exceeded.
If the deadlines are not exceeded, the renderer will render and commit them
one-by-one, stopping whenever it is going to exceed its frame time budget which
it receives from
scheduler. This can happen even in the middle of processing a
single batch. There is a cooperative yield point after processing each
If there is an incomplete work, or any non-
sync batch is still remaining after
the renderer yields, another
scheduler.scheduleCallback is queued with the
deadline of the batch that is closest to expire. It normally uses a
requestIdleCallback-like mechanism, but if the batch is already expired it
will immediately queue a macrotask.
In other words, as long as nothing has expired, only the singular work, or batch with the deadline closest to expiration is worked on in a particular render + commit loop. This is why batching is important: it ensures the requested work shares the same deadline and thus belong to the same render + commit loop.
When the work loop is resumed, if there was another work queued with a shorter deadline than the current work, all non-committed work done so far is thrown out. The higher priority work skips ahead of the queue and is done in its own commit.
If the work loop is resumed because any pending work's deadlines got exceeded, similarly all non-committed work done so far is thrown out, but all work with expired deadlines is done together in a single batch. The renderer will still yield when it exceeds its frame time budget, but because it has already expired it will be immediately resumed[^1].
This can be catastrophic if there are a significant number of pending updates with deadlines spaced together just enough that none of them can finish in time before the next one. Each time the partial work is thrown out there will be even more work to do for this single deadline expiration batch. Probably just one of the reasons why it's not considered ready for release.
This continues until React exhausts the work queue, and then it's up to user interactions or other application code to cause updates.
sync batch ignores the scheduling. Any partial, non-committed work will be
thrown out and the loop will process and commit all
sync updates while
ignoring everything else, even expired non-
sync work. If this interrupted a
partial update, it will then start over on top of the new tree when the loop
resumes as was originally scheduled.
React will protect you against infinite recursion of
sync updates in the
commit phase by counting how many times you caused a cascading update. Currently
this limit is 50.
However, there is currently nothing to protect you against infinite async
updates, other than the max limit of a single queued
Yielding is intended to let the browser at least render a frame before continuing, but if we are already rendering an expired task, this will continuously synchronously drain the queue as long as there are expired tasks, even the freshly inserted already-expired continuation callback.