Async bugs are not random. They are event loop ordering issues. This post shows 6 patterns you can apply immediately to stop guessing and start controlling execution.
1. Replace setTimeout(fn, 0) with queueMicrotask for deterministic ordering
You think you are deferring work to “next tick.” You are actually pushing it behind every microtask.
Before
setTimeout(() => {
updateUI();
}, 0);
After
queueMicrotask(() => {
updateUI();
});
Microtasks always run before timers. This removes 10 to 50ms of unpredictable delay when promise chains are long.
2. Move side effects out of stale closures after setState
Reading state right after setting it gives you old values. This is not React. This is closure timing.
Before
function handleClick() {
setCount(count + 1);
sendMetric(count); // stale
}
After
function handleClick() {
const next = count + 1;
setCount(next);
sendMetric(next);
}
You eliminate an entire class of “sometimes wrong” bugs with one local variable.
3. Collapse multiple async state updates into a single microtask
React batches updates inside the same async continuation. Use that instead of splitting logic.
Before
async function load() {
const user = await fetchUser();
setName(user.name);
const stats = await fetchStats();
setStats(stats);
}
After
async function load() {
const [user, stats] = await Promise.all([
fetchUser(),
fetchStats()
]);
setName(user.name);
setStats(stats);
}
Two awaits create two microtasks and two render opportunities. One await keeps everything in a single microtask and one render.
4. Use Promise-based sequencing instead of mixing timers and async
Mixing macrotasks and microtasks creates ordering bugs that only appear under load.
Before
setTimeout(async () => {
const data = await fetchData();
process(data);
}, 0);
After
Promise.resolve()
.then(fetchData)
.then(process);
You stay entirely in the microtask queue. No cross-queue ordering surprises.
This pattern compounds with the JavaScript error handling patterns that prevent 3AM wake-up calls when you need consistent failure handling across async chains.
5. Always clear intervals to avoid event loop memory leaks
Intervals keep closures alive forever. That includes large objects.
Before
useEffect(() => {
const id = setInterval(() => {
fetchUpdates();
}, 5000);
}, []);
After
useEffect(() => {
const id = setInterval(() => {
fetchUpdates();
}, 5000);
return () => clearInterval(id);
}, []);
This is not optional. Each forgotten interval leaks memory and CPU over time.
6. Replace CPU-heavy sync work with workers to avoid blocking
One blocking function freezes everything. Requests. UI. timers.
Before
const result = processLargeDataset(data); // blocks event loop
updateUI(result);
After
const worker = new Worker(new URL('./worker.js', import.meta.url));
worker.postMessage(data);
worker.onmessage = (e) => {
updateUI(e.data);
};
Moving heavy work off the main thread reduces event loop lag from hundreds of milliseconds to near zero.
7. Use functional updates to avoid race conditions in concurrent events
Multiple async events updating the same state will overwrite each other.
Before
setCount(count + 1);
setCount(count + 1);
After
setCount(prev => prev + 1);
setCount(prev => prev + 1);
Functional updates serialize correctly across microtasks. You get deterministic increments instead of lost updates.
Closing
Pick one pattern and apply it today. Start with removing setTimeout(0) and replacing it with microtasks. Then fix stale state reads. You will see bugs disappear without adding retries or delays.
Top comments (0)