React's Scheduler and the Event Loop: Who Really Decides When Work Runs
See how React's cooperative Scheduler runs inside the JavaScript event loop, why transitions can yield, and why no scheduler can rescue a blocked thread.
This article is published in English.
Sources: How Does React’s Scheduler Work With the JavaScript Event Loop?
React decides not only what should change on screen but also when that work happens, using priorities, lanes and interruptible rendering. Yet React is just JavaScript, and JavaScript already has an event loop that decides when code gets to run. So why does React ship its own scheduler, and which of the two is actually in charge? This article untangles the relationship, shows where concurrent features like transitions and useDeferredValue fit, and explains the hard limit that no amount of clever scheduling can remove, on the web and in React Native alike.
Start with the call stack, not React
Plain JavaScript runs code on a call stack, one frame at a time. In the snippet below, nothing is asynchronous: a log, a function call that logs, and a final log.
console.log("A");
function calculate() {
console.log("B");
}
calculate();
console.log("C");
The output follows the order of the code exactly:
A
↓
B
↓
C
Now swap the function call for a timer with a delay of zero:
console.log("A");
setTimeout(() => {
console.log("B");
}, 0);
console.log("C");
The order changes:
A
C
B
This surprises people because the delay is literally:
0
The explanation is that setTimeout() never means "run this now". It means the callback becomes eligible to run after the delay, and it still has to wait until the currently executing code finishes and the runtime gets around to it. Deciding when that happens is the job of the event loop.
The event loop in brief
A simplified picture is enough for reasoning about React:
┌─────────────┐
│ Call Stack │
└──────┬──────┘
│
↓
JavaScript Runs
│
↓
Stack becomes free
│
↓
Event Loop
│
┌──────┴──────┐
↓ ↓
Microtasks Tasks
Code runs on the stack until the stack is empty. Only then does the event loop pick up queued work: microtasks (such as resolved promise callbacks) are drained first, then the next task (such as a timer callback or an input event) is taken. Real browsers and runtimes add more detail, including rendering steps between tasks, but one rule matters above all others here: synchronous JavaScript that is already running cannot be interrupted by other JavaScript. If the thread is busy, everything else waits. See closures and the event loop for more on the queues.
A deliberately frozen app
To see what that rule means for React, consider a component whose press handler spins in a busy loop for about five seconds before updating state. The example uses React Native's Button, but the behavior is identical in React DOM:
function App() {
const [count, setCount] = useState(0);
function handlePress() {
const start = Date.now();
while (Date.now() - start < 5000) {
// intentionally blocking JavaScript
}
setCount(c => c + 1);
}
return (
<Button
title={`Count: ${count}`}
onPress={handlePress}
/>
);
}
For those five seconds, the JavaScript thread:
JavaScript Thread
looks like this:
██████████████████████████████ BLOCKED FOR 5 SEC
Queued updates, an urgent priority, a Fiber tree ready to process: none of it matters, because React itself is JavaScript and needs a turn on the thread. The scheduler cannot seize the CPU from outside the runtime.
Who is in control
It is tempting to picture React on top, directing JavaScript:
React Scheduler
↓
Controls JavaScript
The accurate picture is the reverse. The runtime grants execution time, and React's scheduler decides how to spend it:
JavaScript Runtime
↓
Provides execution opportunity
↓
React Scheduler
↓
Coordinates React work
The scheduler lives entirely inside the environment JavaScript provides. It can organize React's own work cleverly, but it only ever acts when it has been given the thread.
So why have a scheduler at all
If React cannot override the event loop, what does its scheduler contribute? Consider a screen with thousands of items:
App
├── Header
├── Search
├── Filters
├── ProductList
│ ├── Product
│ ├── Product
│ ├── Product
│ ├── ...
│ └── Product × 5000
└── Footer
Under a purely synchronous model, rendering this tree is one long uninterrupted block:
Start Render
↓
████████████████████████████
↓
Finish Entire Render
Meanwhile, the user keeps interacting:
Type
Tap
Scroll
If rendering is a single giant task, those interactions sit in the queue until it finishes, and the interface feels stuck. What React needs is the ability to split rendering into units and cooperate with the host, handing the thread back regularly so input and painting can happen.
Cooperative scheduling
React's internal scheduler is exactly that kind of cooperative system. Rather than the old instruction:
Render everything NOW
it continually asks a series of questions about pending work:
There is React work pending.What should we work on first?How urgent is it?Should we continue?Should we yield?Did something more important arrive?
As a flow, the loop looks like this:
Pending React Work
↓
Scheduler
↓
Check Priority
↓
Perform Some Work
↓
Should We Yield?
↙ ↘
YES NO
↓ ↓
Give Host Continue
a Chance Rendering
React performs a slice of work, checks whether it should yield, and if so returns control to the host so the browser (or native runtime) can process input, run other tasks, and paint. Then it picks up where it left off, or reconsiders what to work on next.
The key word is cooperative. React takes nothing away from JavaScript; it structures its own work so it never holds the thread longer than necessary. Cooperation, however, only works if every participant plays along.
Two layers, two questions
It helps to phrase the difference as two separate questions.
- The event loop asks: when can JavaScript run its queued work?
- React's scheduler asks: now that React has the thread, which React work deserves attention first?
Stacked together, the layers look like this:
Browser / Native Runtime
↓
JavaScript Runtime
↓
Event Loop
↓
JavaScript gets to run
↓
React Scheduler
↓
Choose React Work
↓
Fiber
↓
Render / Reconcile
↓
Commit
Each layer operates inside the one above it.
When typing competes with a big render
Picture React partway through rendering a large product list:
Rendering Products...
██████████████░░░░░░░░
Then the user starts typing a search query, one keystroke at a time:
R
Re
Rea
React
React now has two kinds of work competing for the same thread:
Large List Rendering
+
User Input Update
The user cares far more about the typing: each character should appear instantly, even if the list lags a little. Prioritizing the keystroke and interrupting the list render makes that possible.
How Fiber provides the breakpoints
Scheduling only helps if work can actually be split. That is Fiber's role: each fiber represents the work for one part of the tree, so the render becomes a sequence of small steps rather than one monolithic operation:
App Fiber
↓
Header Fiber
↓
Search Fiber
↓
List Fiber
↓
Product Fiber
↓
Product Fiber
↓
...
With concurrent rendering, React can process some of those steps, stop, handle something more urgent, and then continue or start over:
Start Rendering
↓
Perform Some Fiber Work
↓
Pause
↓
Handle More Urgent Work
↓
Continue / Restart
React's documentation describes concurrent rendering as interruptible in precisely this sense: a render may be started, paused, resumed later, or abandoned if it has become stale, all before anything is committed. That is a very different claim from saying React runs JavaScript in parallel, which it does not.
Concurrent is not parallel
The phrase:
Concurrent React
often conjures an image of dedicated threads for each job:
Thread 1 → InputThread 2 → Product ListThread 3 → React Rendering
That model is wrong. React's concurrency is interleaving on a single thread, closer to this:
Work A
██████░░░░░░ ↓ pauseWork B
████ ↓Work A
██████
Work A runs for a while, pauses, work B runs, then work A resumes. Different pieces of rendering are coordinated over time, but your component code never executes on several threads at once.
Urgent work arriving during a transition
Suppose React is rendering an expensive update that has been wrapped in a transition:
startTransition(() => {
setResults(expensiveResults);
});
While that render is still in progress, the user types another character. The sequence then looks roughly like this:
Transition Render
██████████░░░░░░░
↓ User Types ↓Urgent Update
████████ ↓React can reconsider
lower-priority work
Because the transition is non-urgent, React can set it aside, handle the keystroke first, and then return to the lower-priority work, often restarting it with fresh state.
Deferring a value instead of an update
useDeferredValue applies the same idea from the reading side. Instead of marking a state update as non-urgent, you derive a lagging copy of a value:
const deferredQuery = useDeferredValue(query);
The input keeps using the real, up-to-date state:
<TextInput
value={query}
onChangeText={setQuery}
/>
while the expensive component receives the deferred copy:
<ExpensiveList query={deferredQuery} />
As a result, the field can already show:
React
while the list briefly still reflects the previous query:
Reac
Meanwhile React prepares the new list off to the side, and a fresh keystroke simply makes it throw that attempt away and begin again with the latest query. The expensive component is not made any faster by this; the aim is purely to keep less important rendering from blocking the interaction the user is focused on. Choosing between the two tools is usually simple: use startTransition when you own the state update, and useDeferredValue when you only receive the value, for example as a prop.
The limit: scheduling cannot preempt your code
Everything above depends on React getting the thread back regularly. Now consider a function that performs an enormous synchronous computation:
function calculateEverything() {
for (let i = 0; i < 10_000_000_000; i++) {
// huge synchronous calculation
}
}
While it runs, the JavaScript thread:
JS THREAD
is completely occupied:
████████████████████████████Scheduler waiting...React waiting...Input waiting...
The scheduler, React and pending input all wait together. Cooperative scheduling can only yield between units of React's own work; it cannot break into a loop that you wrote. Concurrent rendering is therefore no substitute for efficient JavaScript. Heavy computation still needs to be reduced, chunked, memoized or moved off the main thread, for instance into a worker.
Why render and commit stay separate
This same model explains why React insists on splitting an update into two phases:
Render
and:
Commit
Rendering is where React works out what the UI should become, and in concurrent mode that preparation can be interrupted. What React refuses to do is show an unfinished result. So during the render phase:
Render
React may complete some fibers, get interrupted, and resume or restart, and only when the whole tree is ready does it commit:
Fiber A ✓
Fiber B ✓
Fiber C ...
↓
Interrupted
↓
Continue / Restart
↓
Entire Tree Ready
↓
COMMIT
↓
Visible UI
That is why an interrupted render never produces a screen that mixes states:
Half old UI
+
Half new UI
The commit only ever applies a consistent, completed result.
Why render must be pure
"Keep components pure during render" is familiar advice, and interruptible rendering shows exactly why it matters. Look at a component that fires an analytics call from its body:
function Product() {
analytics.track("Product Rendered");
return <View />;
}
Under concurrent rendering, React may go through a sequence like this:
Starts Render
↓
Pauses
↓
Abandons Work
↓
Renders Again
Each of those attempts runs the component function, so this call:
analytics.track(...)
may fire for renders that were never committed and never seen by anyone. Your metrics would count phantom product views. React needs the freedom to:
Render
Pause
Retry
Abandon
Restart
without any of those attempts changing the outside world. Side effects belong in effects or event handlers, which run only for committed work. For a practical guide to choosing where logic should live, see picking the right React tool: derive, handle, fetch, defer or effect.
The React Native angle
In a React Native app, the path from a keystroke to the screen passes through the same layers, with a native renderer at the end:
User Types
↓
JS Event Handler
↓
setState()
↓
React Scheduler
↓
Fiber Work
↓
React Native Renderer
↓
Native UI Update
Block the JavaScript thread with expensive synchronous work, and the chain stops at the second step:
User Types
↓
JS Thread Busy
████████████████
↓
React can't process JS work yet
The app appears frozen even though the native UI thread itself may be perfectly healthy. When diagnosing sluggishness, it pays to separate the possible causes, because each has a different fix:
Is React rendering too much?ORIs JavaScript itself blocked?ORIs expensive native work happening?ORIs the UI thread overloaded?
Too much rendering calls for memoization, virtualization or transitions. A blocked JavaScript thread calls for moving or splitting the heavy work. Expensive native work or an overloaded UI thread points toward native profiling instead.
The full pipeline
Putting every piece together, a single interaction flows through these stages:
USER INTERACTION
↓
JavaScript Runtime
↓
Event Handler
↓
setState()
↓
Update Created
↓
Lane / Priority
↓
React Scheduler
↓
Choose Work
↓
Fiber Render
↓
Can Yield / Restart
↓
Reconciliation
↓
Completed Work
↓
Commit
↓
UI Updated
And all of it takes place inside the JavaScript runtime's boundaries:
┌─────────────────────────────┐
│ JavaScript Runtime │
│ │
│ Event Loop gives JS a chance│
│ ↓ │
│ React coordinates │
│ its own work │
└─────────────────────────────┘
The event loop hands JavaScript its turns; within each turn, React decides how to spend it.
Questions worth being able to answer
Is React's scheduler the same thing as the event loop?
No. The event loop belongs to the host runtime. React's scheduler coordinates React's work within the time that runtime grants.
Does concurrent React run components on several threads?
No. Concurrent rendering lets React pause, resume, restart or abandon render work on a single thread. Component code does not become parallel.
Can React interrupt rendering?
Yes, in concurrent rendering. React can put urgent updates first and discard stale render work before it is committed.
Can the scheduler rescue a blocked thread?
No. Long-running synchronous JavaScript keeps React waiting like everything else until the thread is free.
Why must components stay pure?
Because a render may run more than once and may be interrupted or thrown away. Side effects during render would leak out of work that never reaches the screen.
How the mental model evolves
Most developers start with a picture where setting state leads straight to a re-render:
setState()
↓
React renders
↓
Screen updates
Learning about lanes and scheduling adds a few stages in between:
setState()
↓
Update
↓
Lane
↓
Scheduler
↓
Render
↓
Commit
The final layer is the runtime itself, sitting beneath everything React does:
JavaScript Runtime
↓
Event Loop
↓
React Scheduler
↓
Fiber
↓
Render
↓
Commit
React schedules its own work intelligently, but always from inside the JavaScript environment. Seen that way, transitions, interruptible rendering and a blocked JS thread fit one consistent picture.
Where the pipeline goes next
This article traced the path from a state update down to the event loop:
State Update
↓
Update Queue
↓
Fiber
↓
Lanes
↓
Scheduler
↓
JavaScript Event Loop
In React Native, that path continues further, from React's commit into real platform views. A tree as simple as this:
<View>
<Text>Hello React Native</Text>
</View>
still passes through the bundler, the Hermes engine, JSI and the Fabric renderer before it becomes native Android or iOS UI. Those layers bring their own threads and scheduling concerns, and they are worth understanding separately once the JavaScript side is clear.
Key takeaways
- The event loop decides when JavaScript runs; React's scheduler decides which React work to do when it gets that time.
- React's scheduling is cooperative: it yields between units of Fiber work so input and painting can proceed.
- Concurrency in React means interleaving on one thread, not parallel execution.
startTransitionmarks updates as non-urgent;useDeferredValuelets a component render against a lagging value.- No scheduler can interrupt your own long synchronous code, so heavy computation still needs to be optimized or moved.
- Commits apply only complete results, and renders must be pure because they can be retried or discarded.
- A high-priority update cannot render while a five-second synchronous calculation holds the thread; it waits until that code returns.