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
The whole path at a glance
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.render
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 lane
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
3. updateContainerImpl: build and enqueue the update
// 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 lazy
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. scheduleUpdateOnFiber
// 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 microtask
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".
processRootScheduleInMicrotask
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 else
// 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: performWorkOnRootViaSchedulerTask
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: performWorkOnRoot
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.
Timeline of a cold start
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 Compiler
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.
Summary
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 takeaways
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) ↩