Mastering flushSync in React: When and Why to Force Synchronous DOM Updates
In React, state updates do not immediately mutate the browser DOM. Instead, React batches multiple state updates together and schedules them asynchronously to minimize layout thrashing and preserve smooth 60 FPS responsiveness.
In the vast majority of applications, this automatic batching is exactly what you want. However, specialized user interface interactions occasionally require the DOM to update immediately and synchronously.
Consider an auto-scrolling chat window: when a user sends a message, you append the new message to state and immediately call element.scrollIntoView(). Because React batches the state update, the DOM has not yet rendered the new message when scrollIntoView() runs, causing the scroll position to consistently lag behind by one item (React documentation).
To solve these edge cases, React provides flushSync in react-dom (React documentation). In this deep dive, we explore how flushSync forces immediate DOM synchronization, when to use it, its severe performance trade-offs, and safer architectural alternatives.
What Does flushSync Do?
flushSync is a utility exported by react-dom that forces React to immediately execute any pending work inside the provided callback and update the DOM synchronously:
import { flushSync } from 'react-dom';
function handleClick() {
flushSync(() => {
setCount(count + 1);
});
// React guarantees the DOM has updated by this line!
console.log(document.getElementById('counter')?.textContent);
}
By wrapping a state setter inside flushSync, React halts any background cooperative scheduling, processes the component render, commits the mutations to the DOM, and only returns once the physical DOM nodes match the updated state.
The Primary Use Case: Auto-Scrolling Dynamic Feeds
The classic problem solved by flushSync is auto-scrolling to the end of a dynamically expanding list:
'use client';
import { useState, useRef } from 'react';
import { flushSync } from 'react-dom';
interface Message {
id: string;
text: string;
}
export function ChatFeed() {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const listRef = useRef<HTMLUListElement>(null);
function handleSend() {
if (!input.trim()) return;
const newMessage: Message = { id: crypto.randomUUID(), text: input };
setInput('');
// 1. Force React to synchronously render the new message into the DOM
flushSync(() => {
setMessages(prev => [...prev, newMessage]);
});
// 2. The new <li> node is guaranteed to exist in the DOM right now!
const lastItem = listRef.current?.lastElementChild;
lastItem?.scrollIntoView({ behavior: 'smooth' });
}
return (
<div className="chat-container">
<ul ref={listRef} className="message-list">
{messages.map(msg => (
<li key={msg.id}>{msg.text}</li>
))}
</ul>
<div className="input-bar">
<input
value={input}
onChange={e => setInput(e.target.value)}
placeholder="Type a message..."
/>
<button onClick={handleSend}>Send</button>
</div>
</div>
);
}
Why Standard setState Fails Here:
If you called setMessages(...) normally without flushSync, lastElementChild would reference the previous message rather than the newly added one because React had not yet committed the render to the DOM.
Integrating with Synchronous Third-Party Browser APIs
Another valid scenario for flushSync is interfacing with browser APIs that require synchronous DOM state within the same JavaScript execution frame.
Example: Browser Print Dialogs
The browser's native window.print() blocks the JavaScript thread immediately. If you update state to display a print-optimized layout right before printing, asynchronous batching will cause window.print() to execute before the DOM updates:
function handlePrint() {
// Force DOM update before opening print modal
flushSync(() => {
setIsPrinting(true);
});
// The DOM contains the print-ready view
window.print();
setIsPrinting(false);
}
Critical Caveats and Performance Penalties
Because flushSync forces React to abandon its cooperative scheduler, it comes with severe caveats:
1. Significant Performance Degradation
Calling flushSync frequently defeats React's batching engine. If multiple updates are wrapped in flushSync in rapid succession, the browser triggers synchronous reflows and layout recalculations, causing frame drops and input lag.
2. Never Call flushSync Inside Lifecycle Methods or Render
function BadComponent() {
useEffect(() => {
// 🚩 WRONG: Calling flushSync inside an effect
flushSync(() => {
setValue(10);
});
}, []);
}
Calling flushSync inside a component render body, useEffect, or useLayoutEffect will throw a console warning:
"flushSync was called from inside a lifecycle method. React cannot flush when React is already rendering."
flushSync should only be called inside native event handlers or deferred microtasks.
3. Impact on Suspense Boundaries
If a state update inside flushSync suspends (such as reading an unresolved promise), flushSync may force pending <Suspense> boundaries to display fallback states prematurely.
When to Avoid flushSync: Safer Alternatives
Before reaching for flushSync, evaluate whether React's declarative lifecycle hooks can achieve your goal:
- Use
useLayoutEffectfor DOM Measurements: If you need to measure DOM geometry (e.g. tooltip dimensions) before the browser paints to prevent visual flicker, useuseLayoutEffect(React documentation). It runs synchronously after React updates the DOM, without needingflushSync. - Use Callback Refs: For measuring specific DOM nodes when they attach or detach, callback refs offer a clean, reactive hook into the DOM lifecycle.
Conclusion
flushSync is React’s emergency hatch for forcing synchronous DOM commitments. While it should be treated as a last resort due to its performance costs, it is an indispensable tool when coordinating immediate DOM actions like scroll adjustments, print dialogs, and third-party imperative integrations.