Every senior frontend interview covers the JavaScript event loop React engineers all know the basic answer: call stack, microtask queue, macrotask queue, repeat. It’s correct. It’s also incomplete.
In 2026, that answer won’t get you the job. Understanding the JavaScript event loop in React goes deeper — into microtask starvation, cooperative scheduling, React 18’s automatic batching, and why your UI can feel frozen even when the main thread looks free.
This post covers the full picture: how the event loop actually works, how React’s Scheduler sits on top of it, real code examples with async/await and hooks, and three production bugs that every senior engineer eventually encounters. If you’re also thinking about why senior engineers specialise in frontend at all, this is the kind of depth that separates frontend specialists from generalists.
The React Event Loop Mental Model Most Tutorials Stop At
Here’s the standard explanation:
- JavaScript runs on a single thread — one call stack
- Async callbacks (Promises,
queueMicrotask) go into the microtask queue - Timer callbacks (
setTimeout,setInterval) go into the macrotask queue - After each task, the browser drains all microtasks before moving on
- Then the browser gets a chance to render — then picks the next macrotask
This is true. But it leaves out two things that matter in production:
- The render opportunity step — when the browser can paint, but isn’t guaranteed to
- The animation frame queue —
requestAnimationFramecallbacks run after microtasks but before the next paint
The practical consequence: if you flood the microtask queue, the browser can never reach the render step. Your call stack is empty. Your app looks like it should be free. But the screen doesn’t update.
That’s microtask starvation. It’s how senior engineers break production.
Microtask Starvation — The Code
Here’s the trap in plain JavaScript:
See the Pen Microtask Starvation — Event Loop Demo by Slawa Warda (@bessuraba) on CodePen.
Each .then() schedules a new microtask. The event loop drains microtasks before it ever reaches the macrotask queue — or the render step. The result is a frozen UI with an empty call stack.
Now here’s why this matters in React:
// Risk is highest when processItem resolves near-immediately
useEffect(() => {
async function processItems() {
for (const item of largeDataSet) {
await processItem(item); // Each await = a new microtask checkpoint
}
}
processItems();
}, []);
If largeDataSet has thousands of items and processItem resolves near-immediately (e.g. it’s a synchronous computation wrapped in a Promise), you’re creating thousands of microtask checkpoints in rapid succession. The browser can’t paint between them. The user sees a frozen frame. If processItem involves real async work such as a network request, the browser typically gets render opportunities between turns — but the risk is real whenever the awaited work is fast.
The fix: yield back to the browser explicitly.
useEffect(() => {
async function processItems() {
for (const [index, item] of largeDataSet.entries()) {
await processItem(item);
// Yield to the browser every 50 items
if (index % 50 === 0) {
// Feature-detect before using
if ('scheduler' in globalThis && 'yield' in scheduler) {
await scheduler.yield(); // Modern API — yields as a prioritized task
} else {
await new Promise(r => setTimeout(r, 0)); // Fallback
}
}
}
}
processItems();
}, []);
scheduler.yield() is the modern approach — it yields control back to the main thread and continues later as a prioritized task, giving the browser a render opportunity. It is part of the Prioritized Task Scheduling API (W3C Editor’s Draft) and as of early 2026 is primarily supported in Chromium-based browsers (Chrome 115+). The feature detection above is necessary for any production use. The setTimeout(0) fallback achieves a similar result less precisely.
How React’s Scheduler Sits on Top of the JavaScript Event Loop
React doesn’t use a separate thread. It doesn’t use Web Workers for rendering. Instead, it implements cooperative multitasking on top of the single-threaded event loop.
Here’s the core idea behind React’s Scheduler:
// Simplified version of what React's Scheduler does internally
function workLoop() {
while (workInProgress && !shouldYield()) {
workInProgress = performUnitOfWork(workInProgress);
}
if (workInProgress) {
// More work to do — schedule continuation as a task
scheduleCallback(workLoop); // Uses MessageChannel internally
}
}
function shouldYield() {
// Has more than ~5ms elapsed since we started this batch?
return performance.now() - startTime > 5;
}
React checks shouldYield() between each unit of work. When it has used its time budget, it stops and schedules continuation in later tasks — commonly using efficient browser primitives like MessageChannel. This gives the browser a chance to handle user input and paint before React continues rendering.
This is what startTransition enables: it marks state updates as non-urgent, so React can yield in the middle of rendering them if a higher-priority event (like a user click) arrives.
import { startTransition } from 'react';
// Without startTransition — blocks until complete
setSearchResults(heavyFilter(data));
// With startTransition — React can interrupt and yield
startTransition(() => {
setSearchResults(heavyFilter(data));
});
Critical detail: startTransition doesn’t use different browser queues. It uses React’s internal Lanes system — bitmasks that assign priority to work. The browser event loop doesn’t know about Lanes. React does.
One important caveat: in React 18, state updates that happen after an await inside a transition lose their transition context and revert to urgent updates. If you need them to remain low-priority, wrap them in another startTransition call after the await. React 19 improves this with Actions — async functions passed to startTransition can now span await points and keep their transition context automatically. If your team is on React 19, this limitation largely goes away.
React 18 Automatic Batching: How It Changes Event Loop Behaviour
Before React 18, state updates inside setTimeout or Promises were not batched:
// React 17 — triggers TWO re-renders
setTimeout(() => {
setCount(c => c + 1); // re-render
setFlag(f => !f); // re-render
}, 1000);
From React 18 onward with createRoot, all state updates are batched by default — including those inside setTimeout, Promises, and async/await:
// React 18+ — triggers ONE re-render
setTimeout(() => {
setCount(c => c + 1); // batched
setFlag(f => !f); // batched — single re-render
}, 1000);
This matters for the event loop because React waits until the end of the current task or microtask before flushing updates. Understanding this changes how you reason about update timing — especially when debugging why a state update “didn’t happen yet” inside an async callback.
3 Production Bugs Caused by Event Loop Misunderstanding
Bug 1 — The Async Search Race Condition
Scenario: Search input triggers a fetch on every keystroke. User types fast.
// The bug — no cleanup
useEffect(() => {
fetchResults(query).then(data => {
setResults(data); // Could be stale!
});
}, [query]);
User types “AB” (fetch takes 2s), then “ABC” (fetch takes 1s). “ABC” resolves first and sets results correctly. Then “AB” resolves later and overwrites with stale data.
This isn’t a React bug. It’s a race condition caused by async completion order: the newer request (“ABC”) finishes first and sets the correct results, but the older request (“AB”) finishes later and overwrites state with stale data.
The fix:
useEffect(() => {
const controller = new AbortController();
fetchResults(query, { signal: controller.signal })
.then(data => setResults(data))
.catch(err => {
if (err.name !== 'AbortError') throw err;
});
return () => controller.abort(); // Cancel before next effect runs
}, [query]);
The cleanup function runs before the next effect — cancelling the in-flight request before its microtask resolution can overwrite current state.
Bug 2 — The Zombie setState
Scenario: Component mounts, starts async work, unmounts immediately (navigation, modal close).
// The bug
useEffect(() => {
const interval = setInterval(() => {
fetchData().then(data => {
setState(data); // Component is unmounted — state update on dead component
});
}, 1000);
return () => clearInterval(interval); // Clears the macrotask...
// ...but NOT the in-flight Promise microtask
}, []);
The interval is cleared. But a Promise already in-flight from the previous tick will still resolve — and its .then() will still call setState on an unmounted component. React 18 removed the console warning for this pattern and treats it as a no-op rather than an error. The real reason to fix it isn’t the warning — it’s avoiding unnecessary network work and preventing stale data from being applied if the component re-mounts or shares state via a store.
The fix:
useEffect(() => {
let cancelled = false;
const interval = setInterval(() => {
fetchData().then(data => {
if (!cancelled) setState(data); // Guard against zombie updates
});
}, 1000);
return () => {
cancelled = true;
clearInterval(interval);
};
}, []);
The cancelled ref is checked at the microtask checkpoint where .then() runs — after the cleanup function has already set it to true.
Bug 3 — Input Jank From Microtask Heavy-Lifting
Scenario: Data grid re-renders on every keystroke. Logic is wrapped in Promises so it looks async. But the UI is still janky.
// Looks async — still blocks rendering
useEffect(() => {
Promise.all(largeArray.map(item => processAsync(item)))
.then(results => setData(results));
}, [input]);
Even though each processAsync returns a Promise, if they all resolve synchronously (or near-synchronously), Promise.all floods the microtask queue. The browser gets no render opportunities until the entire array is processed.
The fix: chunk the work and yield between chunks.
async function processInChunks(array, chunkSize = 50) {
const results = [];
for (let i = 0; i < array.length; i += chunkSize) {
const chunk = array.slice(i, i + chunkSize);
const chunkResults = await Promise.all(chunk.map(processAsync));
results.push(...chunkResults);
// Feature-detect before using scheduler.yield()
if ('scheduler' in globalThis && 'yield' in scheduler) {
await scheduler.yield(); // Render opportunity between chunks
} else {
await new Promise(r => setTimeout(r, 0)); // Fallback
}
}
return results;
}
The Interview Questions That Actually Get Asked
Here are three questions interviewers ask at senior level about the JavaScript event loop in React — with the answers that separate candidates who know the loop from candidates who understand it.
Q1: Can a recursive Promise.resolve().then() block a user click?
Yes. Microtasks run to completion before the event loop processes any subsequent task — including UI events. A recursive microtask chain starves the click handler permanently. This is why scheduler.yield() exists: it deliberately introduces a task break, yielding control back to the browser.
Q2: In React 18, are state updates inside await batched?
Yes — if you are using createRoot, all updates are automatically batched, including those after await. The createRoot call is the on-switch for this behaviour. If a codebase is still using the legacy ReactDOM.render (React 17 mode), updates after await are not batched. One additional nuance: inside transitions specifically, updates after an await may lose their transition context in React 18 and need another startTransition wrapper — though React 19 Actions solve this natively.
Q3: How does startTransition interact with the browser event loop?
It doesn’t change which queue work lands in. startTransition works through React’s internal Lanes system — it marks updates as interruptible, so React’s Scheduler can check shouldYield() and break work into task-sized chunks. From the browser’s perspective, it’s just a series of short tasks with render opportunities between them.
What to Take Away
The event loop hasn’t changed. But the way modern React sits on top of it has. If you understand microtask starvation, cooperative scheduling, and how React 18’s automatic batching affects update timing, you’ll reason about async bugs faster — and answer senior interview questions from a place of actual understanding rather than memorised diagrams.
I’m currently open to Senior Frontend Engineer roles — remote, React/Next.js/TypeScript focused. Find my work at slawa-warda.me or connect on LinkedIn.
