root.render - Scheduling the First Update
root.render(<App />) does not render. It picks a lane (normally DefaultLane), wraps the element in an Update {payload: {element}}, stashes the update in a global concurrent queue…
- Published
- Reading time
- 9 min read
root.render(<App />) does not render. It picks a lane (normally DefaultLane), wraps the element in an Update {payload: {element}}, stashes the update in a global concurrent queue, marks the lane pending on the FiberRoot, and makes sure a microtask is queued. In that microtask, the root scheduler picks the lanes and, for a non-sync lane, books a Scheduler task (performWorkOnRootViaSchedulerTask) that runs in a later macrotask. Your App function first runs inside that task.
The whole path at a glanceCopy link to section:
Diagram source:
sequenceDiagram
autonumber
participant U as your code
participant DR as ReactDOMRoot.render
participant RC as updateContainer / Impl
participant Q as concurrentQueues
participant WL as scheduleUpdateOnFiber
participant RS as RootScheduler
participant MT as microtask
participant S as Scheduler (MessageChannel)
U->>DR: root.render(<App/>)
DR->>RC: updateContainer(children, root, null, null)
RC->>RC: lane = requestUpdateLane(current) => DefaultLane
RC->>RC: update = createUpdate(lane); update.payload = {element}
RC->>Q: enqueueUpdate(rootFiber, update, lane)
Q-->>RC: FiberRoot (walk .return to HostRoot)
RC->>WL: scheduleUpdateOnFiber(root, rootFiber, lane)
WL->>WL: markRootUpdated(root, lane) — pendingLanes |= lane
WL->>RS: ensureRootIsScheduled(root)
RS->>MT: scheduleImmediateRootScheduleTask() => queueMicrotask
Note over U,MT: root.render returns — nothing rendered yet
MT->>RS: processRootScheduleInMicrotask()
RS->>RS: scheduleTaskForRootDuringMicrotask: getNextLanes => DefaultLane
RS->>S: scheduleCallback(NormalPriority, performWorkOnRootViaSchedulerTask)
S->>RS: (next macrotask) performWorkOnRootViaSchedulerTask(root)
RS->>RS: performWorkOnRoot(root, lanes, forceSync) => Work Loop1. ReactDOMRoot.prototype.renderCopy link to section:
function (children) {
const root = this._internalRoot;
if (root === null) throw new Error('Cannot update an unmounted root.');
// DEV: warn on a 2nd argument (callback or container)
updateContainer(children, root, null, null);
}1 children can be any ReactNodeList: an element, a string, an array, or null. root.render(null) is a valid way to clear the tree without unmounting. parentComponent and callback are always null for createRoot roots.
2. updateContainer: pick a laneCopy link to section:
export function updateContainer(element, container, parentComponent, callback) {
const current = container.current; // the HostRoot fiber
const lane = requestUpdateLane(current);
updateContainerImpl(current, lane, element, container, parentComponent, callback);
return lane;
}2 requestUpdateLane is the one function that decides the priority of every update in React: setState, dispatch and root.render alike. In order:3
- (legacy mode only, compiled out in 19) non-concurrent fiber →
SyncLane. - Render-phase update (we're inside
RenderContextwith lanes in progress) → reuse a lane from the current render. The comment calls this "not officially supported". - Inside a transition (
ReactSharedInternals.T !== null, set bystartTransition) →requestTransitionLane(transition). All transitions started in the same event share one transition lane. - Otherwise →
eventPriorityToLane(resolveUpdatePriority()).
resolveUpdatePriority is a host-config function. For DOM:4
export function resolveUpdatePriority() {
const updatePriority = ReactDOMSharedInternals.p; // set by React's event wrappers, flushSync, ...
if (updatePriority !== NoEventPriority) return updatePriority;
const currentEvent = window.event;
if (currentEvent === undefined) return DefaultEventPriority;
return getEventPriority(currentEvent.type); // 'click' -> Discrete, 'mousemove' -> Continuous
}For a top-level root.render(<App />) in a module script, no React event is in progress and window.event is undefined. So the result is DefaultEventPriority, which is DefaultLane: event priorities are lanes in disguise.5
- Top-level script →
DefaultLane. - Inside a native
clicklistener (even one not attached by React) →window.event.type === 'click'→getEventPriorityreturnsDiscreteEventPriority→SyncLane.6 - Inside
startTransition(() => root.render(<App/>))→ a transition lane, which is interruptible and time-sliced. - Inside
flushSync(() => root.render(...))→pis set to Discrete →SyncLane, flushed beforeflushSyncreturns.7
In practice DefaultLane and SyncLane both render without yielding in 19 (see below), so the visible difference is when the render starts: at the end of the current microtask checkpoint for Sync, or in a later Scheduler macrotask for Default.
3. updateContainerImpl: build and enqueue the updateCopy link to section:
// packages/react-reconciler/src/ReactFiberReconciler.js (simplified)
function updateContainerImpl(rootFiber, lane, element, container, parentComponent, callback) {
const context = getContextForSubtree(parentComponent); // emptyContextObject for createRoot
if (container.context === null) container.context = context;
else container.pendingContext = context;
const update = createUpdate(lane);
// Caution: React DevTools currently depends on this property being called "element".
update.payload = {element};
if (callback !== null) update.callback = callback;
const root = enqueueUpdate(rootFiber, update, lane);
if (root !== null) {
startUpdateTimerByLane(lane, 'root.render()', null);
scheduleUpdateOnFiber(root, rootFiber, lane);
entangleTransitions(root, rootFiber, lane);
}
}8 createUpdate makes a class-queue update: {lane, tag: UpdateState, payload: null, callback: null, next: null}.9 UpdateState (0) means "shallow-merge the payload into the previous state", so the HostRoot's state becomes {...prev, element: <App/>} when processed.10 There is a DEV warning here ("Render methods should be a pure function of props and state") for calling root.render from inside a component's render.
enqueueUpdate: deliberately lazyCopy link to section:
enqueueUpdate (class queue) checks for the unsafe render-phase path. Otherwise it delegates to enqueueConcurrentClassUpdate.11 That function does not link the update into the fiber's queue yet. It pushes a 4-tuple onto a module-level array:12
// packages/react-reconciler/src/ReactFiberConcurrentUpdates.js (simplified)
function enqueueUpdate(fiber, queue, update, lane) {
concurrentQueues[concurrentQueuesIndex++] = fiber;
concurrentQueues[concurrentQueuesIndex++] = queue;
concurrentQueues[concurrentQueuesIndex++] = update;
concurrentQueues[concurrentQueuesIndex++] = lane;
concurrentlyUpdatedLanes = mergeLanes(concurrentlyUpdatedLanes, lane);
fiber.lanes = mergeLanes(fiber.lanes, lane); // updated eagerly (bailout checks need it)
if (fiber.alternate !== null) fiber.alternate.lanes = mergeLanes(fiber.alternate.lanes, lane);
}It then returns getRootForUpdatedFiber(fiber), which walks .return up to a HostRoot and returns its stateNode (the FiberRoot). For the root fiber itself, that is immediate.13
Why defer the linking? An update can arrive while a concurrent render is in progress, for example an event fires during a yield. If it were appended to queue.pending and childLanes were bubbled immediately, the in-progress render could see half of the event's updates. Buffering keeps that render consistent. The buffer is drained by finishQueueingConcurrentUpdates(), which builds the circular pending list and then calls markUpdateLaneFromFiberToRoot to set childLanes on ancestors.14 It runs in prepareFreshStack at the start of the next render, and at the end of a render.15
4. scheduleUpdateOnFiberCopy link to section:
// packages/react-reconciler/src/ReactFiberWorkLoop.js (simplified)
export function scheduleUpdateOnFiber(root, fiber, lane) {
// If the work loop is suspended on data, or a commit is suspended, restart from the top
if ((root === workInProgressRoot && (reason === SuspendedOnData || reason === SuspendedOnAction))
|| root.cancelPendingCommit !== null) {
prepareFreshStack(root, NoLanes);
markRootSuspended(root, workInProgressRootRenderLanes, workInProgressDeferredLane, false);
}
markRootUpdated(root, lane);
if ((executionContext & RenderContext) !== NoContext && root === workInProgressRoot) {
// render-phase update: track it, warn in DEV
} else {
if (root === workInProgressRoot) { /* interleaved update bookkeeping */ }
ensureRootIsScheduled(root);
// legacy-only sync flush (dead code with disableLegacyMode)
}
}16 markRootUpdated is short but important:17
root.pendingLanes |= updateLane;
if (updateLane !== IdleLane) {
root.suspendedLanes = NoLanes; // a new update might unblock suspended work: retry everything
root.pingedLanes = NoLanes;
root.warmLanes = NoLanes;
}So after root.render, root.pendingLanes === DefaultLane (0b100000). That bit is the root's only record that work exists.
5. ensureRootIsScheduled and the microtaskCopy link to section:
This is the React 18.3+/19 root scheduler. It replaced the old design, where every setState immediately called Scheduler.scheduleCallback. ensureRootIsScheduled now does two cheap things:18
- Appends the root to a singly linked list (
firstScheduledRoot…lastScheduledRootviaroot.next), unless it is already there. - Sets
mightHavePendingSyncWork = trueand callsensureScheduleIsScheduled, which queues at most one microtask per tick throughscheduleImmediateRootScheduleTask(guarded bydidScheduleMicrotask).19
function scheduleImmediateRootScheduleTask() {
if (supportsMicrotasks) {
scheduleMicrotask(() => {
// Safari can run microtasks in the middle of render/commit (iframe append); bail to a macrotask then
if ((getExecutionContext() & (RenderContext | CommitContext)) !== NoContext) {
Scheduler_scheduleCallback(ImmediateSchedulerPriority, processRootScheduleInImmediateTask);
return;
}
processRootScheduleInMicrotask();
});
} else {
Scheduler_scheduleCallback(ImmediateSchedulerPriority, processRootScheduleInImmediateTask);
}
}20 For DOM, scheduleMicrotask is queueMicrotask, with fallbacks to Promise.resolve().then and then setTimeout.21
Why a microtask? It is the natural batching boundary. Every update in the same synchronous JS turn (ten setStates in a handler, a root.render followed by a setState) gets merged into pendingLanes before anyone decides what to render. This is "automatic batching", and it works the same inside setTimeout, promises and native handlers, because it no longer depends on React knowing it is "inside an event".
processRootScheduleInMicrotaskCopy link to section:
22 It clears didScheduleMicrotask and walks the root list. For each root it calls scheduleTaskForRootDuringMicrotask, and removes roots that return NoLane. The comment notes this microtask is the only place roots leave the list, which guards against reentrancy bugs. Finally it calls flushSyncWorkAcrossRoots_impl to synchronously render any root whose next lanes include SyncLane, right there in the microtask.23
scheduleTaskForRootDuringMicrotask: Sync vs everything elseCopy link to section:
// simplified
markStarvedLanesAsExpired(root, currentTime);
const nextLanes = getNextLanes(root, wipLanes, rootHasPendingCommit);
if (nextLanes === NoLanes || suspendedOnData || root.cancelPendingCommit !== null) {
cancel existing; root.callbackNode = null; return NoLane;
}
if (includesSyncLane(nextLanes) && !checkIfRootIsPrerendering(root, nextLanes)) {
// Synchronous work is always flushed at the end of the microtask, so we don't need an additional task.
root.callbackPriority = SyncLane; root.callbackNode = null; return SyncLane;
}
const newCallbackPriority = getHighestPriorityLane(nextLanes);
if (newCallbackPriority === root.callbackPriority) return newCallbackPriority; // reuse existing task
cancelCallback(root.callbackNode);
switch (lanesToEventPriority(nextLanes)) {
case DiscreteEventPriority: case ContinuousEventPriority: schedulerPriorityLevel = UserBlockingPriority; break;
case DefaultEventPriority: schedulerPriorityLevel = NormalPriority; break;
case IdleEventPriority: schedulerPriorityLevel = IdlePriority; break;
}
root.callbackNode = scheduleCallback(schedulerPriorityLevel, performWorkOnRootViaSchedulerTask.bind(null, root));
root.callbackPriority = newCallbackPriority;24 For our first render: nextLanes = DefaultLane, not sync → NormalPriority Scheduler task. root.callbackNode now holds the Scheduler Task object, and root.callbackPriority = DefaultLane.
The "same priority → reuse the existing task" check is why ten setStates at the same priority do not create ten tasks. A higher-priority update cancels the task and books a new one at the higher priority.
6. The Scheduler task: performWorkOnRootViaSchedulerTaskCopy link to section:
In a later macrotask (the Scheduler's MessageChannel message; see Lanes & Scheduler), the Scheduler calls the bound function with didTimeout:25
- If an async commit is still pending (View Transition), bail: clear
callbackNodeand returnnull. flushPendingEffectsDelayed()flushes leftover passive effects from a previous commit. If that cancelled this task, exit.- Recompute
getNextLanes. The comment explains why: Scheduler batches several callbacks into one macrotask without running microtasks in between, so new updates may have arrived since the microtask. performWorkOnRoot(root, lanes, forceSync = !disableSchedulerTimeoutInWorkLoop && didTimeout)enters the work loop.- Afterwards, call
scheduleTaskForRootDuringMicrotaskagain. Ifroot.callbackNodeis still the same task, return a continuation (performWorkOnRootViaSchedulerTask.bind(null, root)). The Scheduler keeps the same task in its heap and calls it again after yielding to the browser.
Sync lanes take the other path: performSyncWorkOnRoot (from the microtask flush) calls performWorkOnRoot(root, lanes, true).26
Where this page stops: performWorkOnRootCopy link to section:
const shouldTimeSlice =
(!forceSync && !includesBlockingLane(lanes) && !includesExpiredLane(root, lanes))
|| checkIfRootIsPrerendering(root, lanes);
let exitStatus = shouldTimeSlice ? renderRootConcurrent(root, lanes) : renderRootSync(root, lanes, true);27 includesBlockingLane covers Sync, InputContinuous, Default and Gesture lanes (and their hydration variants).28 So the initial render, although it arrives through a Scheduler task, runs in renderRootSync: no yielding between fibers. Only transitions, retries, idle and offscreen work are time-sliced. The work loop itself (prepareFreshStack, workLoopSync, performUnitOfWork) is covered in Work Loop.
It makes it asynchronous (it starts in a later task, so your root.render call returns immediately). It does not make it time-sliced. A huge initial tree still blocks the main thread for its whole render. To get yielding, wrap the update in startTransition. Note that a later Sync/Default update will then interrupt and restart it (see Lanes & Scheduler).
Timeline of a cold startCopy link to section:
Because rendering starts in a macrotask and not a microtask, the browser can paint the static HTML in between. That is one reason a server-rendered or static shell stays visible until React commits.
With the React CompilerCopy link to section:
Unchanged. The compiler does not touch react-dom or the reconciler's scheduling code. root.render still produces exactly one update on the HostRoot at the same lane. The compiler's effect shows up later, inside the render (fewer allocations of JSX and closures, and skipped child work on updates). It does not change how much work gets scheduled. See With vs Without Compiler.
SummaryCopy link to section:
root.render(<App />) only records intent. It picks a lane, wraps the element in an Update, buffers it, marks the lane pending on the FiberRoot from createRoot, and queues one microtask. That microtask is the batching boundary: every update from the same synchronous turn lands in pendingLanes before the root scheduler flushes sync work or books a Scheduler task. The first render is asynchronous, not time-sliced. The next part covers the lanes and Scheduler this path leans on.
Key takeawaysCopy link to section:
requestUpdateLane: decides every update's lane; a top-levelroot.rendergetsDefaultLane.- Call site matters: a native
clicklistener orflushSyncgivesSyncLane,startTransitiona transition lane. updateContainerImpl: builds anUpdateStateupdate whose{element}payload is shallow-merged into the HostRoot state.concurrentQueues: linking is deferred so an in-progress render never sees half of an event's updates.markRootUpdated: sets the lane inroot.pendingLanes, the root's only record that work exists.ensureRootIsScheduled: lists the root and queues at most one microtask per tick, which makes batching automatic everywhere.scheduleTaskForRootDuringMicrotask: sync lanes flush at the end of the microtask, same-priority tasks are reused, anything else books a Scheduler task.performWorkOnRootViaSchedulerTask: recomputesgetNextLanes, callsperformWorkOnRoot, and returns a continuation if the same task is still scheduled.includesBlockingLane: includesDefaultLane, so the initial render runs inrenderRootSync; onlystartTransitiongets you yielding.
Footnotes
-
ReactDOMRoot.prototype.render— packages/react-dom/src/client/ReactDOMRoot.js#L107-L136 (opens in a new tab) ↩ -
updateContainer— packages/react-reconciler/src/ReactFiberReconciler.js#L353-L370 (opens in a new tab) ↩ -
requestUpdateLane— packages/react-reconciler/src/ReactFiberWorkLoop.js#L812-L868 (opens in a new tab) ↩ -
resolveUpdatePriority— packages/react-dom-bindings/src/client/ReactDOMUpdatePriority.js#L35-L45 (opens in a new tab) ↩ -
Event priorities are lanes — packages/react-reconciler/src/ReactEventPriorities.js#L22-L28 (opens in a new tab) ↩
-
getEventPriority— packages/react-dom-bindings/src/events/ReactDOMEventListener.js#L312-L416 (opens in a new tab) ↩ -
flushSyncsetsReactDOMSharedInternals.p = DiscreteEventPriority— packages/react-dom/src/shared/ReactDOMFlushSync.js#L22-L35 (opens in a new tab) ↩ -
updateContainerImpl— packages/react-reconciler/src/ReactFiberReconciler.js#L393-L458 (opens in a new tab) ↩ -
createUpdate— packages/react-reconciler/src/ReactFiberClassUpdateQueue.js#L210-L221 (opens in a new tab) ↩ -
UpdateState/ReplaceState— packages/react-reconciler/src/ReactFiberClassUpdateQueue.js#L155-L156 (opens in a new tab) ↩ -
class-queue
enqueueUpdate— packages/react-reconciler/src/ReactFiberClassUpdateQueue.js#L223-L274 (opens in a new tab) ↩ -
concurrent
enqueueUpdate/enqueueConcurrentClassUpdate— packages/react-reconciler/src/ReactFiberConcurrentUpdates.js#L90-L163 (opens in a new tab) ↩ -
getRootForUpdatedFiber— packages/react-reconciler/src/ReactFiberConcurrentUpdates.js#L252-L276 (opens in a new tab) ↩ -
finishQueueingConcurrentUpdates— packages/react-reconciler/src/ReactFiberConcurrentUpdates.js#L50-L84 (opens in a new tab) ↩ -
call in
prepareFreshStack— packages/react-reconciler/src/ReactFiberWorkLoop.js#L2284 (opens in a new tab) (also L2764, L3048 at the end of sync/concurrent renders) ↩ -
scheduleUpdateOnFiber— packages/react-reconciler/src/ReactFiberWorkLoop.js#L987-L1113 (opens in a new tab) ↩ -
markRootUpdated— packages/react-reconciler/src/ReactFiberLane.js#L825-L849 (opens in a new tab) ↩ -
ensureRootIsScheduled— packages/react-reconciler/src/ReactFiberRootScheduler.js#L116-L152 (opens in a new tab) ↩ -
ensureScheduleIsScheduled— packages/react-reconciler/src/ReactFiberRootScheduler.js#L154-L169 (opens in a new tab) ↩ -
scheduleImmediateRootScheduleTask— packages/react-reconciler/src/ReactFiberRootScheduler.js#L650-L696 (opens in a new tab) ↩ -
DOM
scheduleMicrotask— packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js#L871-L877 (opens in a new tab) ↩ -
processRootScheduleInMicrotask— packages/react-reconciler/src/ReactFiberRootScheduler.js#L259-L348 (opens in a new tab) ↩ -
flushSyncWorkAcrossRoots_impl— packages/react-reconciler/src/ReactFiberRootScheduler.js#L185-L247 (opens in a new tab) ↩ -
scheduleTaskForRootDuringMicrotask— packages/react-reconciler/src/ReactFiberRootScheduler.js#L384-L509 (opens in a new tab) ↩ -
performWorkOnRootViaSchedulerTask— packages/react-reconciler/src/ReactFiberRootScheduler.js#L513-L606 (opens in a new tab) ↩ -
performSyncWorkOnRoot— packages/react-reconciler/src/ReactFiberRootScheduler.js#L608-L622 (opens in a new tab) ↩ -
performWorkOnRoot/shouldTimeSlice— packages/react-reconciler/src/ReactFiberWorkLoop.js#L1137-L1181 (opens in a new tab) ↩ -
includesBlockingLane— packages/react-reconciler/src/ReactFiberLane.js#L684-L694 (opens in a new tab) ↩