JavaScript Event Loop: Microtasks Macrotasks
Welcome to TopperBlog! 👋
I'm a tech content creator passionate about helping developers level up their careers and master cutting-edge technologies.
🎯 What I Write About:
• AI/ML Engineering & LLMs
• Web3 & Blockchain Development
• System Design & Architecture
• Interview Preparation (FAANG)
• Freelancing & Remote Work
• Modern Tech Stacks (Next.js, React, Rust, TypeScript)
• Performance Optimization & Best Practices
💼 Mission: Sharing practical, actionable insights that accelerate your tech career and maximize your earning potential.
📚 15+ In-Depth Guides covering everything from earning $10k/month as a freelancer to cracking FAANG interviews.
🌐 Let's connect and grow together in this amazing tech journey!
#TechBlogger #SoftwareEngineering #CareerGrowth #WebDevelopment #AIEngineering
Why Traditional Mental Models Fail in Modern JavaScript
Most developers learn that JavaScript is "single-threaded" and "asynchronous," but these abstractions break down when building production applications in 2025. The traditional explanation—"callbacks run after the current code finishes"—doesn't explain why Promises resolve before setTimeout callbacks, why requestAnimationFrame behaves differently than both, or why queueMicrotask exists as a separate API.
The problem intensifies with modern frameworks and runtime environments. Next.js Server Components, React Server Actions, and edge runtime environments like Cloudflare Workers all introduce new execution contexts where task scheduling behaves differently than in traditional browser environments. Developers who don't understand the underlying event loop mechanics write code that works locally but fails in production, or works in Chrome but breaks in Safari.
Current constraints make this knowledge essential. Real-time collaborative applications need precise control over when state updates propagate to the UI. AI-powered features that stream responses from language models must coordinate multiple async operations without blocking user interactions. Progressive Web Apps running on low-end devices need to maximize responsiveness by scheduling work efficiently across event loop phases.
Understanding the JavaScript Event Loop Architecture
The JavaScript event loop operates as a continuous cycle that processes tasks from multiple queues with different priorities. This isn't a simple FIFO queue—it's a sophisticated scheduling system with distinct phases and priority levels.
Macrotasks (also called tasks) include:
- Script execution (the initial script tag)
- setTimeout and setInterval callbacks
- setImmediate (Node.js)
- I/O operations
- UI rendering
- User interaction events (clicks, keyboard input)
Microtasks include:
- Promise callbacks (then, catch, finally)
- queueMicrotask callbacks
- MutationObserver callbacks
- process.nextTick (Node.js, highest priority)
The execution model follows this pattern: execute one macrotask, then execute all queued microtasks, then potentially render, then repeat. This seemingly simple rule creates complex behavior that developers must master.
// Demonstrating execution order with practical implications
console.log('1: Synchronous start');
setTimeout(() => {
console.log('2: Macrotask - setTimeout');
Promise.resolve().then(() => {
console.log('3: Microtask inside macrotask');
});
}, 0);
Promise.resolve()
.then(() => {
console.log('4: Microtask - Promise 1');
return Promise.resolve();
})
.then(() => {
console.log('5: Microtask - Promise 2');
});
queueMicrotask(() => {
console.log('6: Microtask - queueMicrotask');
});
console.log('7: Synchronous end');
// Output order:
// 1: Synchronous start
// 7: Synchronous end
// 4: Microtask - Promise 1
// 6: Microtask - queueMicrotask
// 5: Microtask - Promise 2
// 2: Macrotask - setTimeout
// 3: Microtask inside macrotask
This execution order has critical implications. All microtasks run before the browser can render, meaning excessive microtask queuing blocks UI updates. Each macrotask gets its own microtask checkpoint, creating opportunities for state synchronization but also potential performance bottlenecks.
Production-Grade Task Scheduling Patterns
Modern applications need sophisticated task scheduling strategies that balance responsiveness, correctness, and performance. Here's a production-ready implementation for coordinating async operations in a real-time collaborative editor:
class TaskScheduler {
private microtaskQueue: Array<() => void> = [];
private macrotaskQueue: Array<() => void> = [];
private isProcessing = false;
private frameScheduled = false;
/**
* Schedule work that must complete before the next render
* Use for critical state updates that affect UI consistency
*/
scheduleMicrotask(task: () => void): void {
this.microtaskQueue.push(task);
if (!this.isProcessing) {
queueMicrotask(() => this.processMicrotasks());
}
}
/**
* Schedule work that can wait until after rendering
* Use for analytics, logging, non-critical updates
*/
scheduleMacrotask(task: () => void): void {
this.macrotaskQueue.push(task);
if (!this.frameScheduled) {
this.frameScheduled = true;
setTimeout(() => this.processMacrotasks(), 0);
}
}
/**
* Schedule work synchronized with browser rendering
* Use for animations and visual updates
*/
scheduleAnimationFrame(task: (timestamp: number) => void): void {
requestAnimationFrame((timestamp) => {
task(timestamp);
// Microtasks queued here run before the next frame
});
}
private processMicrotasks(): void {
this.isProcessing = true;
while (this.microtaskQueue.length > 0) {
const task = this.microtaskQueue.shift();
try {
task?.();
} catch (error) {
console.error('Microtask error:', error);
// Continue processing remaining microtasks
}
}
this.isProcessing = false;
}
private processMacrotasks(): void {
this.frameScheduled = false;
const tasks = [...this.macrotaskQueue];
this.macrotaskQueue = [];
tasks.forEach(task => {
try {
task();
} catch (error) {
console.error('Macrotask error:', error);
}
});
}
}
// Real-world usage in a collaborative editor
class CollaborativeEditor {
private scheduler = new TaskScheduler();
private pendingChanges: Change[] = [];
handleRemoteChange(change: Change): void {
// Critical: Apply change to local state before render
this.scheduler.scheduleMicrotask(() => {
this.applyChange(change);
this.validateState();
});
// Non-critical: Send analytics after render
this.scheduler.scheduleMacrotask(() => {
this.trackChange(change);
});
// Visual: Update cursor position smoothly
this.scheduler.scheduleAnimationFrame((timestamp) => {
this.updateCursorAnimation(change, timestamp);
});
}
private applyChange(change: Change): void {
this.pendingChanges.push(change);
// State update happens synchronously within microtask
this.document.apply(change);
}
private validateState(): void {
// Validation runs in same microtask batch
if (!this.document.isValid()) {
throw new Error('Invalid document state');
}
}
private trackChange(change: Change): void {
// Analytics can wait - doesn't block rendering
fetch('/api/analytics', {
method: 'POST',
body: JSON.stringify(change)
});
}
private updateCursorAnimation(change: Change, timestamp: number): void {
// Smooth animation synchronized with browser paint
const cursor = this.getCursor(change.userId);
cursor.animateTo(change.position, timestamp);
}
}
This architecture separates concerns by execution priority. Microtasks handle state consistency—operations that must complete atomically before the UI updates. Macrotasks handle background work that doesn't affect immediate user experience. Animation frames synchronize visual updates with the browser's rendering pipeline.
Handling Async/Await and Promise Chains
Async/await syntax creates microtasks implicitly, which can surprise developers who don't understand the underlying mechanics:
async function fetchUserData(userId: string): Promise<User> {
console.log('1: Function starts (synchronous)');
// This creates a microtask boundary
const response = await fetch(`/api/users/${userId}`);
console.log('2: After await (microtask)');
const data = await response.json();
console.log('3: After second await (another microtask)');
return data;
}
// Calling the function
console.log('A: Before call');
fetchUserData('123').then(user => {
console.log('4: Promise resolved');
});
console.log('B: After call');
// Output order:
// A: Before call
// 1: Function starts (synchronous)
// B: After call
// 2: After await (microtask)
// 3: After second await (another microtask)
// 4: Promise resolved
Each await creates a microtask checkpoint. This matters when coordinating multiple async operations:
class DataSynchronizer {
private cache = new Map<string, any>();
/**
* Incorrect: Race condition possible
*/
async fetchAndCacheWrong(key: string): Promise<any> {
if (this.cache.has(key)) {
return this.cache.get(key);
}
// Another call might start here before we finish
const data = await this.fetchFromAPI(key);
this.cache.set(key, data);
return data;
}
/**
* Correct: Prevents race conditions with microtask coordination
*/
async fetchAndCacheCorrect(key: string): Promise<any> {
if (this.cache.has(key)) {
return this.cache.get(key);
}
// Reserve the cache slot immediately (synchronous)
const promise = this.fetchFromAPI(key);
this.cache.set(key, promise);
try {
const data = await promise;
this.cache.set(key, data); // Replace promise with data
return data;
} catch (error) {
this.cache.delete(key); // Clean up on failure
throw error;
}
}
private async fetchFromAPI(key: string): Promise<any> {
const response = await fetch(`/api/data/${key}`);
return response.json();
}
}
The correct implementation prevents race conditions by synchronously reserving the cache slot before any await, ensuring multiple concurrent calls don't trigger duplicate fetches.
Common Pitfalls and Edge Cases
Microtask Starvation: Continuously queuing microtasks prevents rendering and blocks user input:
// Dangerous: Infinite microtask loop
function processQueue(items: any[]): void {
if (items.length === 0) return;
const item = items.shift();
processItem(item);
// This queues another microtask immediately
Promise.resolve().then(() => processQueue(items));
// Browser never gets a chance to render!
}
// Safe: Use macrotasks for long-running work
function processQueueSafely(items: any[]): void {
if (items.length === 0) return;
const item = items.shift();
processItem(item);
// Allows rendering between batches
setTimeout(() => processQueueSafely(items), 0);
}
// Better: Batch processing with time budgets
async function processQueueWithBudget(
items: any[],
budgetMs: number = 16
): Promise<void> {
const startTime = performance.now();
while (items.length > 0) {
const item = items.shift();
processItem(item);
// Yield to browser if we've exceeded our time budget
if (performance.now() - startTime > budgetMs) {
await new Promise(resolve => setTimeout(resolve, 0));
return processQueueWithBudget(items, budgetMs);
}
}
}
Promise Constructor Executor Timing: The Promise constructor executor runs synchronously, which catches many developers off guard:
console.log('1: Start');
new Promise((resolve) => {
console.log('2: Promise executor (synchronous!)');
resolve();
}).then(() => {
console.log('3: Promise then (microtask)');
});
console.log('4: End');
// Output: 1, 2, 4, 3
MutationObserver Batching: MutationObserver callbacks are microtasks that batch DOM changes, but the batching behavior varies across browsers:
const observer = new MutationObserver((mutations) => {
console.log(`Observed ${mutations.length} mutations`);
// This runs as a microtask after all DOM changes in current task
});
observer.observe(document.body, { childList: true, subtree: true });
// These changes are batched into a single microtask
document.body.appendChild(document.createElement('div'));
document.body.appendChild(document.createElement('div'));
document.body.appendChild(document.createElement('div'));
// Observer callback runs once with 3 mutations
Node.js process.nextTick Priority: In Node.js, process.nextTick has higher priority than Promise microtasks:
Promise.resolve().then(() => console.log('Promise'));
process.nextTick(() => console.log('nextTick'));
// Output in Node.js: nextTick, Promise
// This doesn't exist in browsers!
Best Practices for Event Loop Management
1. Choose the Right Scheduling Primitive
- Use
queueMicrotask()for critical state updates that must complete before rendering - Use
setTimeout(fn, 0)for deferrable work that shouldn't block rendering - Use
requestAnimationFrame()for visual updates synchronized with display refresh - Use
requestIdleCallback()for low-priority background work
2. Implement Time Budgets for Long Tasks
class TaskRunner {
async runWithBudget<T>(
tasks: Array<() => T>,
budgetMs: number = 50
): Promise<T[]> {
const results: T[] = [];
const startTime = performance.now();
for (const task of tasks) {
results.push(task());
if (performance.now() - startTime > budgetMs) {
// Yield to browser
await new Promise(resolve => setTimeout(resolve, 0));
return results.concat(
await this.runWithBudget(tasks.slice(results.length), budgetMs)
);
}
}
return results;
}
}
3. Avoid Mixing Synchronous and Asynchronous State Updates
// Bad: Inconsistent state during render
class BadCounter {
count = 0;
async increment(): Promise<void> {
this.count++; // Synchronous
await this.saveToDatabase(); // Async
this.render(); // Might render before save completes
}
}
// Good: Consistent async flow
class GoodCounter {
count = 0;
async increment(): Promise<void> {
const newCount = this.count + 1;
await this.saveToDatabase(newCount);
this.count = newCount; // Update only after save succeeds
this.render();
}
}
4. Monitor Event Loop Performance
class EventLoopMonitor {
private lastCheck = performance.now();
startMonitoring(): void {
const check = () => {
const now = performance.now();
const delay = now - this.lastCheck;
if (delay > 100) {
console.warn(`Event loop blocked for ${delay}ms`);
// Send to monitoring service
this.reportBlockage(delay);
}
this.lastCheck = now;
setTimeout(check, 50);
};
check();
}
private reportBlockage(duration: number): void {
// Send to your monitoring service
fetch('/api/metrics', {
method: 'POST',
body: JSON.stringify({
metric: 'event_loop_blockage',
duration,
timestamp: Date.now()
})
});
}
}
5. Test Async Timing in Integration Tests
describe('Task scheduling', () => {
it('processes microtasks before macrotasks', async () => {
const order: string[] = [];
setTimeout(() => order.push('macrotask'), 0);
Promise.resolve().then(() => order.push('microtask'));
// Wait for both to complete
await new Promise(resolve => setTimeout(resolve, 10));
expect(order).toEqual(['microtask', 'macrotask']);
});
it('batches state updates correctly', async () => {
const component = new Component();
const renderSpy = jest.spyOn(component, 'render');
component.update('a');
component.update('b');
component.update('c');
// Should batch updates in single microtask
await Promise.resolve();
expect(renderSpy).toHaveBeenCalledTimes(1);
});
});
Frequently Asked Questions
What is the difference between microtasks and macrotasks in JavaScript?
Microtasks (Promise callbacks, queueMicrotask) execute immediately after the current task completes and before the browser renders. Macrotasks (setTimeout, setInterval) execute in subsequent event loop iterations, allowing rendering between tasks. All queued microtasks run before the next macrotask begins.
How does async/await affect the JavaScript event loop in 2025?
Each await keyword creates a microtask boundary. When execution reaches an await, the function pauses, returns control to the event loop, and resumes as a microtask when the awaited Promise resolves. This means async functions can interleave with other microtasks, affecting execution order in complex async workflows.
What is the best way to prevent event loop blocking in production applications?
Implement time budgets for long-running tasks, breaking work into chunks that yield control back to the browser every 16-50ms using setTimeout. Monitor event loop delay with performance.now() checks. Use Web Workers for CPU-intensive operations that don't require DOM access. Profile with Chrome DevTools Performance tab to identify blocking tasks.
When should you use queueMicrotask instead of Promise.resolve().then()?
Use queueMicrotask() when you need to schedule a microtask without creating a Promise object, reducing memory overhead. It's more explicit and slightly more efficient for simple callbacks. Use Promise.resolve().then() when you need Promise chaining, error handling, or integration with async/await patterns.
**How