How React 18 Handles Events: Sync vs Async

#Editor#React#JavaScript

When you build a text editor for the web, you eventually run into things you used to take for granted that you now have to implement yourself.

Over roughly a year and a half of building a text editor, I ended up implementing things I had assumed the web would just provide, and along the way I ran into some pretty unusual problems.

Editors often have requirements (multiple carets, precise control over text position, smooth UX) that the browser's default caret can't satisfy, so sometimes you have to implement the caret yourself.

But after migrating to React 18, I started seeing the caret disappear while typing, and while debugging it I discovered that React 18 handles events differently from what I had assumed.

In this post I want to share the shift in event handling flow that I learned by analyzing React 18's internal code directly, and how that shift actually affects rendering timing.

The Problem

To draw the caret, I need the actual height of the text element. For that, the caret component has to render after the text component has been committed.

To render the caret after the text component's commit, I intentionally deferred the caret component using a microtask.

queueMicrotask(() => {
  // Render the caret after measuring the text element's size
  cursorComponent();
});
 

After migrating from React 17 to React 18, the custom caret started disappearing whenever text was typed via the keyboard.

Tracking Down the Cause

When I compared the keyboard input event to the mouse click event, I noticed the following difference:

  • Key input: text component re-renders → caret component doesn't render
  • Mouse click: text component doesn't re-render → caret component renders correctly

At the moment the caret tried to render, the text DOM wasn't ready yet, so the height measurement failed and the rendering failed along with it.

What the debugging showed:

  • The text component and the caret component were rendering inside the same microtask
  • The caret ran first and referenced a text DOM that didn't exist yet → failure

In other words, on React 18 the text rendering itself was no longer happening immediately after the event — it was being deferred to a microtask — and because of that the old caret rendering approach stopped working.

The core of the problem, summarized

  1. On React 18, text component rendering is deferred to a microtask
  2. The caret component is still rendered inside a microtask
  3. The caret tries to render before the text DOM exists → render fails and the UX breaks

The concepts in the official docs — Automatic Batching, Concurrent Rendering — weren't enough on their own to explain the root cause. In the end I had to read React's internal code directly to understand what was going on.

Digging into the React 18 Updates

Concurrent Rendering

One of the biggest changes in React 18 is the introduction of Concurrent Rendering. Concurrent Rendering schedules multiple pieces of work concurrently and classifies them appropriately by priority, which makes the UI more responsive and lets React render the UI incrementally without blocking the browser.

The core problems React's Concurrent Rendering aims to solve are:

  • Maintaining high responsiveness even during large-scale screen transitions
  • Guaranteeing immediate response to user input
  • Incremental rendering that doesn't block the browser

The Lane System and Priorities

React 18 uses a Lane system to manage the priority of work. Lanes assign a priority to each update, ensuring that important updates are processed first.

Lanes are implemented as bitmasks, and the further right a bit sits, the higher its priority.

// React 18 Lane definitions
const NoLanes = 0b0000000000000000000000000000000;
const NoLane = 0b0000000000000000000000000000000;
 
const SyncLane = 0b0000000000000000000000000000010;
const InputContinuousLane = 0b0000000000000000000000000001000;
const DefaultLane = 0b0000000000000000000000000100000;
 
const TransitionLanes = 0b0000000011111111111111110000000;
const TransitionLane1 = 0b0000000000000000000000010000000;
// ... up to TransitionLane16
 
const RetryLanes = 0b0000111100000000000000000000000;
const RetryLane1 = 0b0000000100000000000000000000000;
// ... up to RetryLane4

Discrete Events and the Priority System

A Discrete Event is an event that originates from a user action and is fired as a discrete, individual occurrence. Because it comes from a user action, it has to react immediately, and it has the highest priority during scheduling.

DOM event classification react-dom/src/events/ReactDOMEventListener.js

// Determines priority per DOM event
function getEventPriority(domEventName) {
  switch (domEventName) {
    case 'click':
    case 'input':
    case 'keydown':
    case 'keyup':
    case 'submit':
      return DiscreteEventPriority;  // Sync Lane
      
    case 'drag':
    case 'scroll':
    case 'mousemove':
    case 'wheel':
      return ContinuousEventPriority;  // InputContinuous Lane
      
    default:
      return DefaultEventPriority;  // Default Lane
  }
}

Mapping of event types to Lanes: react-reconciler/src/ReactFiberLane.js

Event typeExampleMapped LanePriority
Discreteclick, keydown, inputSyncLane⬆️ Very high
Continuousscroll, mousemove, wheelInputContinuousLane⬆️ Medium
Defaultfetch, setTimeout and general workDefaultLane➖ Normal
TransitionPage transitions, large state changesTransitionLanes⬇️ Low

The Sync Lane and How SyncQueue Is Processed

To guarantee an immediate response, updates triggered by Discrete Events are processed on the Sync Lane, and React uses an internal syncQueue instead of its general scheduler for them. The flow goes like this.

How a Discrete Event is processed:

  1. Sync Lane assigned
    The update that came from the Discrete Event is classified as SyncLane.

  2. Queued in syncQueue
    performSyncWorkOnRoot, the synchronous rendering function, is registered in syncQueue.

// Queueing in syncQueue when processing a Discrete Event
function ensureRootIsScheduled(root, currentTime) {
  const nextLanes = getNextLanes(root, NoLanes);
  const newCallbackPriority = getHighestPriorityLane(nextLanes);
  
  if (newCallbackPriority === SyncLane) {
    // For a Sync Lane, register on the syncQueue
    scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));
  }
}
  1. Render is performed
    After that, flushSyncCallbacks() runs the callbacks queued in syncQueue, which does the following:
    • Renders every component whose state changed
    • Commits the changed components
    • Performs the DOM update

Through this priority system, React stays fast on the user's immediate inputs while deferring lower-priority updates appropriately to optimize overall performance.

The key point here is that in React 18, the timing at which this syncQueue is processed has changed.

React 17 vs 18: Comparing How syncQueue Is Processed

The timing at which callbacks added to the syncQueue are flushed changed between React 17 and 18. Related commit

React 17: processed immediately after the discrete event

  1. Register callback in syncQueue: If a component's state changed during discrete event handling, the sync render callback performSyncWorkOnRoot is registered in syncQueue.

react-reconciler/src/SchedulerWithReactIntegration.new.js

export function scheduleSyncCallback(callback: SchedulerCallback) {
  if (syncQueue === null) {
	syncQueue = [callback]; // performSyncWorkOnRoot registered
  } else {
    syncQueue.push(callback);
  }
}
  1. Processed immediately After discrete event handling, flushSyncCallbackQueue is called right away.

react-reconciler/src/ReactFiberWorkLoop.old.js

export function discreteUpdates(fn, a, b, c, d) {
  // ... handle the discrete event
  try {
    return runWithPriority(UserBlockingSchedulerPriority, fn.bind(null, a, b, c, d));
  } finally {
    if (executionContext === NoContext) {
      flushSyncCallbackQueue(); // Run immediately!
    }
  }
}
  1. Callback is executed flushSyncCallbackQueue runs the callback performSyncWorkOnRoot that sits in syncQueue.

react-reconciler/src/SchedulerWithReactIntegration.new.js

function flushSyncCallbackQueueImpl() {
  if (syncQueue !== null) {
    const queue = syncQueue;
    syncQueue = null; // Run callbacks queued in syncQueue
    for (let i = 0; i < queue.length; i++) {
      let callback = queue[i]; // performSyncWorkOnRoot
      do {
        callback = callback(true);
      } while (callback !== null);
    }
  }
}

React 18: syncQueue callbacks are processed in a microtask

  1. Register callback in syncQueue If a component's state changed during discrete event handling, the sync render callback performSyncWorkOnRoot is registered in syncQueue (same as React 17).

react-reconciler/src/ReactFiberSyncTaskQueue.new.js

export function scheduleSyncCallback(callback: SchedulerCallback) {
  if (syncQueue === null) {
	syncQueue = [callback]; // performSyncWorkOnRoot registered
  } else {
    syncQueue.push(callback);
  }
}
  1. Schedule as a microtask flushSyncCallbackQueue, which processes syncQueue callbacks, is scheduled as a microtask. Commit introducing the change

react-reconciler/src/ReactFiberWorkLoop.new.js

function ensureRootIsScheduled(root, currentTime) {
  if (newCallbackPriority === SyncLane) {
    // Register callback in syncQueue
    scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));
    if (supportsMicrotasks) {
      // Scheduled as a microtask!
      scheduleMicrotask(() => {
        if (executionContext === NoContext) {
          flushSyncCallbacks();
        }
      });
    }
  }
}
  1. Not processed immediately After discrete event handling, flushSyncCallbackQueue is no longer called. Commit introducing the change

react-reconciler/src/ReactFiberWorkLoop.new.js

export function discreteUpdates(fn, a, b, c, d) {
 try {
   setCurrentUpdatePriority(DiscreteEventPriority);
   return fn(a, b, c, d); // handle the discrete event
 } finally {
   setCurrentUpdatePriority(previousPriority);
   // React 18: no flushSyncCallbackQueue call here!
 }
}
  1. Executed inside the microtask: When the microtask runs, it calls the flushSyncCallback function registered in step [2] (renamed here), and that in turn runs the callback (performSyncWorkOnRoot) sitting in syncQueue (same as React 17).

react-reconciler/src/ReactFiberSyncTaskQueue.new.js

export function flushSyncCallbacks() {
 if (syncQueue !== null) {
   const queue = syncQueue;
   syncQueue = null;
   
   // Run the callbacks queued in syncQueue
   for (let i = 0; i < queue.length; i++) {
     let callback = queue[i]; // performSyncWorkOnRoot
     do {
       callback = callback(true);
     } while (callback !== null);
   }
 }
}

Key difference:

  • React 17: renders immediately right after handling the discrete event
  • React 18: renders inside a microtask after handling the discrete event

Because of this change, in React 18 even the synchronous rendering caused by Discrete Events (keyboard, click, etc.) runs inside a microtask. That's why the text component's rendering ends up deferred to a microtask in React 18 — and that's the direct cause of the caret rendering issue I described earlier.

How continuous events and default events are handled in React 18

In React 18, in addition to Discrete Events, there are Continuous, Default, and Idle events, which are classified under Lane priorities other than SyncLane and are processed asynchronously through the Scheduler.

  1. Registered with the Scheduler performConcurrentWorkOnRoot is scheduled as a macrotask. If it isn't a SyncLane, React calls the Scheduler's scheduleCallback() with a priority based on the event type and registers the callback as a macrotask.

react-reconciler/src/ReactFiberWorkLoop.new.js

function ensureRootIsScheduled(root: FiberRoot, currentTime: number) {
  if (newCallbackPriority === SyncLane) {
    // SyncLane handling
  } else {
    // Continuous / Default / Idle handling
    let schedulerPriorityLevel;
    switch (lanesToEventPriority(nextLanes)) {
      case DiscreteEventPriority:
        schedulerPriorityLevel = ImmediateSchedulerPriority;
        break;
      case ContinuousEventPriority:
        schedulerPriorityLevel = UserBlockingSchedulerPriority;
        break;
      case DefaultEventPriority:
        schedulerPriorityLevel = NormalSchedulerPriority;
        break;
      case IdleEventPriority:
        schedulerPriorityLevel = IdleSchedulerPriority;
        break;
      default:
        schedulerPriorityLevel = NormalSchedulerPriority;
        break;
    }
    // Async scheduling through the Scheduler
    newCallbackNode = scheduleCallback(
      schedulerPriorityLevel,
      performConcurrentWorkOnRoot.bind(null, root), // scheduled as a macrotask
    );
  }
  root.callbackPriority = newCallbackPriority;
  root.callbackNode = newCallbackNode;
}
  1. Runs inside the macrotask performConcurrentWorkOnRoot runs from the Scheduler's task queue. The scheduled macrotask is invoked from the Scheduler queue and internally renders using Concurrent Mode.

react-reconciler/src/ReactFiberWorkLoop.new.js

function performConcurrentWorkOnRoot(root, didTimeout) {
  // Execute rendering
  let exitStatus = shouldTimeSlice
    ? renderRootConcurrent(root, lanes)  // yieldable rendering
    : renderRootSync(root, lanes);       // synchronous rendering
    
  if (exitStatus !== RootInProgress) {    
    if (exitStatus !== RootDidNotComplete) {
      // Render complete — get ready to commit
      const finishedWork: Fiber = root.current.alternate;
      root.finishedWork = finishedWork;
      root.finishedLanes = lanes;
      finishConcurrentRender(root, exitStatus, lanes);
    }
  }
  // Schedule the next work
  ensureRootIsScheduled(root, now());
  // Decide whether to continue running
  if (root.callbackNode === originalCallbackNode) {
    return performConcurrentWorkOnRoot.bind(null, root); 
  }
  return null;
}

Discrete vs Continuous/Default Events

AspectDiscrete EventsContinuous/Default Events
LaneSyncLaneContinuousLane / DefaultLane
SchedulingscheduleSyncCallback → microtaskscheduleCallback → macrotask
Run functionperformSyncWorkOnRootperformConcurrentWorkOnRoot
Render styleSynchronous render (renderRootSync)Time slicing (renderRootConcurrent)
Yieldable?NoYes
BatchingBatched inside the microtaskAuto-batching via the Scheduler

Auto Batching

What Is Auto Batching?

Batching is a performance optimization where React groups multiple state updates into a single render. Up through React 17, batching only happened inside React event handlers, but in React 18 every update is batched automatically.

Limited Batching in React 17

In React 17, batching only worked inside React event handlers:

function handleClick() {
  setCount(c => c + 1);  // no re-render yet
  setFlag(f => !f);      // no re-render yet
  // React batches these into a single re-render
}

But it didn't batch inside async work like Promises or setTimeout:

function handleClick() {
  fetchSomething().then(() => {
    setCount(c => c + 1);  // first re-render
    setFlag(f => !f);      // second re-render
  });
}
 
setTimeout(() => {
  setCount(c => c + 1);    // first re-render
  setFlag(f => !f);        // second re-render
}, 1000);

Auto Batching in React 18

When you use createRoot in React 18, batching applies automatically in every context — setTimeout, Promise, fetch, you name it.

function handleClick() {
  fetchSomething().then(() => {
    setCount(c => c + 1);  // no re-render yet
    setFlag(f => !f);      // no re-render yet
    // React batches these into a single re-render
  });
}
 
setTimeout(() => {
  setCount(c => c + 1);    // no re-render yet  
  setFlag(f => !f);        // no re-render yet
  // React batches them within the same event loop tick
}, 1000);
 
Promise.resolve().then(() => {
  setCount(c => c + 1);    // no re-render yet
  setFlag(f => !f);        // no re-render yet  
  // React batches them within the same event loop tick
});

How Auto Batching Works

The change that made Auto Batching possible is a change in how updates are processed:

1. Discrete Events (key input, click, etc.)

// React 17: immediate synchronous execution
function handleKeyDown() {
	setState(...);
	// → flushes immediately → renders right away
}
 
// React 18: deferred to a microtask
function handleKeyDown() {
	setState(...);
	// → schedules flushSyncCallbacks() as a microtask
	// → renders after the event handler finishes
}

2. Async updates (setTimeout, Promise, etc.)

// React 17: each one is a separate render
setTimeout(() => {
  setA(1); // render 1
  setB(2); // render 2
});
 
// React 18: batched into one
setTimeout(() => {
  setA(1);
  setB(2);
  // handled as a single render
});

How the React 18 Changes Affected Caret Rendering

The React 18 changes I covered above had a direct effect on the caret rendering logic. Now let me dig into how exactly concurrent rendering and Auto Batching caused the caret rendering issue.

The Core Problem: Render Timing Shift Reversed the Order

In React 17 the text component rendered synchronously and immediately, and the caret component was deferred via queueMicrotask, so the text → caret order was naturally guaranteed.

In React 18, though, the text component itself got deferred into a microtask, which meant the caret and the text ended up running in the same microtask — and as a result the caret ran before the text component had been committed, which is what broke things.

How key input is handled in React 17

function handleKeyDown(event) {
  setText(newText);           // 1. state update
  // 2. flushSyncCallbacks() runs immediately
  // 3. text render & commit completes
  // 4. measure the text DOM
  queueMicrotask(() => {
    cursorComponent(textHeight); // 4. render the caret
  });
}

How key input is handled in React 18

function handleKeyDown(event) {
  setText(newText);           // 1. state update (added to syncQueue)
  // 2. flushSyncCallbacks() → scheduled as a microtask
 
  queueMicrotask(() => {
    cursorComponent(textHeight);     // 3. textHeight not available ❌
  // 4. In the same microtask, flushSyncCallbacks() runs and renders the text
  });
}

The Combined Effect of Auto Batching and Concurrent Rendering

Two of React 18's key changes combined to produce the problem:

ChangeRole and impact
Concurrent RenderingWhen handling a Discrete event, defers sync work into a microtask
Auto BatchingGroups updates inside the microtask into a single pass (delaying render timing)
→ The result: text and caret end up rendering in the same tick, and the order gets tangled

Step-by-Step Comparison

StepReact 17React 18
1Key input event firesKey input event fires
2setState called → immediate sync rendersetState called → microtask scheduled
3Text DOM updated & measurableMicrotask queue waits
4queueMicrotask: caret renders ✅Microtask: caret render attempt ❌
5-Microtask: text DOM measured

Execution Order in the Microtask Queue

In React 18, the microtasks run in this order:

[
  () => cursorComponent(),        // caret logic: queued first
  () => flushSyncCallbacks(),     // text render: added later by React
]
 

Why was the change made in the first place?

The change in React 18 was primarily about performance:

  • Auto Batching: better performance by avoiding unnecessary re-renders
  • Concurrent Rendering: better responsiveness by not blocking the main thread
  • An unintended side effect: code that relied on the old render order now behaves differently

In React 18, when syncQueue handling for Discrete Events moved to a microtask, the old flow of "text render → measure text DOM → caret render" turned into "text render → caret render attempt → measure text DOM." That's what made the caret unable to compute its position and stop rendering correctly.

This is precisely how React 18's performance optimizations unintentionally affected my caret implementation.

How I Solved It

I tried a few approaches to fix the caret rendering issue under React 18.

First Try: Use a Macrotask

The first thing I tried was deferring the caret rendering into a macrotask via setTimeout.

function handleKeyDown(event) {
  setText(newText);              // text update
	// DOM measurement done
  setTimeout(() => {             // run inside a macrotask
    cursorComponent(textHeight); // render the caret
  }, 0);
}

The caret did render in the right position, but the UX wasn't great. When several characters were typed in quick succession, the caret looked like it was teleporting around.

The limitation of the macrotask approach is that macrotasks run too late in the event loop.

Analyzing the JavaScript Event Loop

To understand the difference between macrotasks and microtasks, I dug into the JavaScript event loop.

The basic flow of the event loop

console.log('1. sync');
 
queueMicrotask(() => console.log('2. microtask'));
setTimeout(() => console.log('4. macrotask'), 0);
queueMicrotask(() => console.log('3. microtask'));
 
// Output: 1 → 2 → 3 → 4

The timing I actually needed was:

  • After React finishes rendering
  • but not as late as a macrotask

In other words, I needed an exact sweet spot between the microtask and the macrotask.

Second Try: Nested Microtasks

I came up with the idea of calling queueMicrotask inside another queueMicrotask, so the caret rendering would happen after React's flushSyncCallbacks.

function handleKeyDown(event) {
  setText(newText);           // text update
  
  queueMicrotask(() => {      // first microtask
    queueMicrotask(() => {    // second microtask
      cursorComponent();      // render the caret
    });
  });
}

How Auto Batching interacts with nested microtasks

// Check 1: microtasks at the same level are batched
queueMicrotask(() => {
  setText("first text");
  setText("second text"); // Auto Batching merges these
});
 
// Check 2: nested microtasks are batched separately
queueMicrotask(() => {
  setText("outer text");
  
  queueMicrotask(() => {
    setText("inner text"); // handled as a separate batch
  });
});
  • Microtasks at the same level get grouped into a single batch
  • Nested microtasks are processed separately
    → which means the caret render can be safely deferred until after the text render

Carefully tuned timing that accounts for React 18's execution order:

// Execution order in React 18
handleKeyDown();
// 1. setText() called → scheduleMicrotask(flushSyncCallbacks)
// 2. queueMicrotask (first) registered
// 3. queueMicrotask (second) registered (inside the first)
 
// Microtask queue runs:
// → flushSyncCallbacks() (text render)
// → first microtask
// rendered together via auto batching
// → second microtask (caret render) ✅

The takeaways from nested microtasks:

  • Immediate response: the caret renders as soon as you type
  • Accurate position: position is computed after the text DOM is ready
  • Natural UX: smooth typing without the macrotask delay

Another Issue: Complexity in Collaborative Editing

// Event classification in collaborative editing
const updateFromOtherUser = (newText) => {
  setText(newText);  // a change coming from another user
  // This isn't a Discrete Event — it's a Default Event (low priority)
};

In collaborative editing, updates coming in from other users are handled internally by React as Default events. Render timing is deferred into a macrotask, so this case needs a different strategy.

Event typeLane priorityRender timingExample
DiscreteSyncLaneMicrotaskKey input, click
DefaultDefaultLaneMacrotaskNetwork response, timer

Final Strategy: Branch by Event Type

For collaborative editing, I had to use different strategies depending on the event type:

function handleTextUpdate(newText, source = 'user') {
  setText(newText);
 
  if (source === 'user') {
    // Keyboard-driven: fast and accurate render
    queueMicrotask(() => {
      queueMicrotask(() => {
        cursorComponent();
      });
    });
  } else {
    // Collaboration-driven: rely on the stable macrotask
    setTimeout(() => {
      cursorComponent();
    }, 0);
  }
}

With React 18's shift in render timing, careful timing control became essential for the caret rendering logic.

  • Macrotask is too slow → UX suffers
  • Microtask is too fast → DOM not reflected
  • Nested microtasks hit the sweet spot
  • Cases with different event priorities — like collaborative editing — need separate handling → This is how I ended up with a stable approach for controlling caret rendering precisely and naturally.

What I Learned by Wrestling with React 18 Directly

When React 18 first introduced Auto Batching and Concurrent Rendering, I treated them as "something React handles internally to optimize things." The information I usually came across was just "React classifies events automatically and reorders work for performance."

But while actually dealing with the editor's caret rendering issue, I went straight to the internal code to understand how React classifies events and when rendering actually happens.

The things I specifically learned were:

  • Discrete events (like keyboard input) run in microtasks,
    while Default / Continuous and other events run as macrotasks
  • This behavior is determined by React's internal code (scheduleSyncCallback, scheduleCallback) and by checks on executionContext
  • This change in handling directly affects when DOM rendering actually happens
  • And I came to understand how microtasks and macrotasks flow through the event loop

None of this is something you can pick up just by reading the changelog. It only became clear after analyzing React's internal code. It was the kind of experience that let me viscerally understand how Concurrent Rendering actually handles events and defers rendering, and on what event-loop boundary Auto Batching operates — things I had only known as concepts before.

More than anything, it reminded me that React's "optimizations" don't always guarantee the result you intended in a complex project, and that taking the time to understand how things actually work internally really matters.

References https://goidle.github.io/react/in-depth-react18-lane/ https://goidle.github.io/react/in-depth-react18-concurrent_render/