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
React InternalsPart 03 · 4 of 18

The whole path at a glance

1. ReactDOMRoot.prototype.render

JavaScript
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

JavaScript
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

  1. (legacy mode only, compiled out in 19) non-concurrent fiber → SyncLane.
  2. Render-phase update (we're inside RenderContext with lanes in progress) → reuse a lane from the current render. The comment calls this "not officially supported".
  3. Inside a transition (ReactSharedInternals.T !== null, set by startTransition) → requestTransitionLane(transition). All transitions started in the same event share one transition lane.
  4. Otherwise → eventPriorityToLane(resolveUpdatePriority()).

resolveUpdatePriority is a host-config function. For DOM:4

JavaScript
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

JavaScript
// 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

JavaScript
// 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

JavaScript
// 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

JavaScript
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

  1. Appends the root to a singly linked list (firstScheduledRoot … lastScheduledRoot via root.next), unless it is already there.
  2. Sets mightHavePendingSyncWork = true and calls ensureScheduleIsScheduled, which queues at most one microtask per tick through scheduleImmediateRootScheduleTask (guarded by didScheduleMicrotask).19
JavaScript
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

JavaScript
// 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

  1. If an async commit is still pending (View Transition), bail: clear callbackNode and return null.
  2. flushPendingEffectsDelayed() flushes leftover passive effects from a previous commit. If that cancelled this task, exit.
  3. 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.
  4. performWorkOnRoot(root, lanes, forceSync = !disableSchedulerTimeoutInWorkLoop && didTimeout) enters the work loop.
  5. Afterwards, call scheduleTaskForRootDuringMicrotask again. If root.callbackNode is 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

JavaScript
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

WhenWhat happens
sync, in your scriptcreateRoot + root.render: update queued, pendingLanes = DefaultLane, microtask queued
rest of your scriptany other code. More root.render calls overwrite: each is a new update, and the last payload wins when processed in order
microtask checkpointprocessRootScheduleInMicrotask → Scheduler NormalPriority task → port.postMessage
next macrotask (after possible paint of HTML shell)performWorkOnRootViaSchedulerTask → renderRootSync → commit (see Commit Phase)
a later framebrowser paints the committed DOM

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-level root.render gets DefaultLane.
  • Call site matters: a native click listener or flushSync gives SyncLane, startTransition a transition lane.
  • updateContainerImpl: builds an UpdateState update 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 in root.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: recomputes getNextLanes, calls performWorkOnRoot, and returns a continuation if the same task is still scheduled.
  • includesBlockingLane: includes DefaultLane, so the initial render runs in renderRootSync; only startTransition gets you yielding.

← Prev · Index · Next →

Footnotes

  1. ReactDOMRoot.prototype.render — packages/react-dom/src/client/ReactDOMRoot.js#L107-L136 (opens in a new tab) ↩

  2. updateContainer — packages/react-reconciler/src/ReactFiberReconciler.js#L353-L370 (opens in a new tab) ↩

  3. requestUpdateLane — packages/react-reconciler/src/ReactFiberWorkLoop.js#L812-L868 (opens in a new tab) ↩

  4. resolveUpdatePriority — packages/react-dom-bindings/src/client/ReactDOMUpdatePriority.js#L35-L45 (opens in a new tab) ↩

  5. Event priorities are lanes — packages/react-reconciler/src/ReactEventPriorities.js#L22-L28 (opens in a new tab) ↩

  6. getEventPriority — packages/react-dom-bindings/src/events/ReactDOMEventListener.js#L312-L416 (opens in a new tab) ↩

  7. flushSync sets ReactDOMSharedInternals.p = DiscreteEventPriority — packages/react-dom/src/shared/ReactDOMFlushSync.js#L22-L35 (opens in a new tab) ↩

  8. updateContainerImpl — packages/react-reconciler/src/ReactFiberReconciler.js#L393-L458 (opens in a new tab) ↩

  9. createUpdate — packages/react-reconciler/src/ReactFiberClassUpdateQueue.js#L210-L221 (opens in a new tab) ↩

  10. UpdateState / ReplaceState — packages/react-reconciler/src/ReactFiberClassUpdateQueue.js#L155-L156 (opens in a new tab) ↩

  11. class-queue enqueueUpdate — packages/react-reconciler/src/ReactFiberClassUpdateQueue.js#L223-L274 (opens in a new tab) ↩

  12. concurrent enqueueUpdate / enqueueConcurrentClassUpdate — packages/react-reconciler/src/ReactFiberConcurrentUpdates.js#L90-L163 (opens in a new tab) ↩

  13. getRootForUpdatedFiber — packages/react-reconciler/src/ReactFiberConcurrentUpdates.js#L252-L276 (opens in a new tab) ↩

  14. finishQueueingConcurrentUpdates — packages/react-reconciler/src/ReactFiberConcurrentUpdates.js#L50-L84 (opens in a new tab) ↩

  15. 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) ↩

  16. scheduleUpdateOnFiber — packages/react-reconciler/src/ReactFiberWorkLoop.js#L987-L1113 (opens in a new tab) ↩

  17. markRootUpdated — packages/react-reconciler/src/ReactFiberLane.js#L825-L849 (opens in a new tab) ↩

  18. ensureRootIsScheduled — packages/react-reconciler/src/ReactFiberRootScheduler.js#L116-L152 (opens in a new tab) ↩

  19. ensureScheduleIsScheduled — packages/react-reconciler/src/ReactFiberRootScheduler.js#L154-L169 (opens in a new tab) ↩

  20. scheduleImmediateRootScheduleTask — packages/react-reconciler/src/ReactFiberRootScheduler.js#L650-L696 (opens in a new tab) ↩

  21. DOM scheduleMicrotask — packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js#L871-L877 (opens in a new tab) ↩

  22. processRootScheduleInMicrotask — packages/react-reconciler/src/ReactFiberRootScheduler.js#L259-L348 (opens in a new tab) ↩

  23. flushSyncWorkAcrossRoots_impl — packages/react-reconciler/src/ReactFiberRootScheduler.js#L185-L247 (opens in a new tab) ↩

  24. scheduleTaskForRootDuringMicrotask — packages/react-reconciler/src/ReactFiberRootScheduler.js#L384-L509 (opens in a new tab) ↩

  25. performWorkOnRootViaSchedulerTask — packages/react-reconciler/src/ReactFiberRootScheduler.js#L513-L606 (opens in a new tab) ↩

  26. performSyncWorkOnRoot — packages/react-reconciler/src/ReactFiberRootScheduler.js#L608-L622 (opens in a new tab) ↩

  27. performWorkOnRoot / shouldTimeSlice — packages/react-reconciler/src/ReactFiberWorkLoop.js#L1137-L1181 (opens in a new tab) ↩

  28. includesBlockingLane — packages/react-reconciler/src/ReactFiberLane.js#L684-L694 (opens in a new tab) ↩