Skip to main content

Command Palette

Search for a command to run...

Part 2: Event Loop: The heart of Javascript

Understand how Javascript handles your code during runtime

Updated
7 min read
Part 2: Event Loop: The heart of Javascript
U

I'm a MERN Stack developer and technical writer that loves to share his thoughts in words on latest trends and technologies.

For queries and opportunities, I'm available at r.utkarsh.0010@gmail.com

Introduction

The JavaScript event loop is one of the most fundamental concepts that every developer working with the language should understand. It is at the heart of how JavaScript handles asynchronous operations and ensures that your applications remain responsive. Despite its importance, it’s a concept that often confuses newcomers and experienced developers alike. In this article, we’ll demystify the event loop and explore how it works with detailed explanations and examples.

The Single-Threaded Nature of JavaScript

JavaScript is single-threaded, meaning it can execute only one task at a time in the main thread. This limitation might seem restrictive at first glance, but JavaScript employs the event loop to handle multiple tasks efficiently without blocking the main thread.

Why Single-Threaded?

The single-threaded nature simplifies JavaScript’s design, making it easier to avoid complex issues like race conditions and deadlocks common in multithreaded environments. Instead, JavaScript relies on asynchronous patterns and the event loop to manage concurrency.

The Key Players in the Event Loop

To understand the event loop, we need to introduce its key components:

  1. Call Stack: The call stack is a data structure that keeps track of the function calls in your program. When a function is called, it’s added to the stack. When the function completes, it’s removed from the stack.

    Example:

     function first() {
         console.log('First function');
     }
    
     function second() {
         first();
         console.log('Second function');
     }
    
     second();
    

    Output:

     First function
     Second function
    

    Here, second is added to the call stack, then first is added, and finally, both are removed in order.

  2. Web APIs: These are provided by the browser or Node.js runtime. Functions like setTimeout, fetch, or DOM event listeners are part of the Web APIs. They handle operations outside the JavaScript engine and notify the event loop when tasks are ready.

  3. Macrotask Queue (or Callback Queue): This queue holds tasks that are ready to be executed after the current function on the call stack finishes. Examples include callbacks from setTimeout or setInterval.

  4. Microtask Queue: Microtasks include promises and MutationObserver callbacks. They have higher priority than tasks in the task queue and are processed before moving on to the next task in the task queue.

  5. Event Loop: The event loop is the mechanism that coordinates the execution of code, collecting and processing events, and executing queued tasks and microtasks.

How the Event Loop Works

Let’s break down the process step by step:

  1. The JavaScript engine starts executing code from the top of the file.

  2. When it encounters an asynchronous operation (e.g., setTimeout), it delegates the task to the Web APIs and continues executing the rest of the code.

  3. Once the asynchronous operation completes, its callback is added to the macrotask queue (or the microtask queue in the case of promises).

  4. The event loop checks if the call stack is empty. If it is, it dequeues tasks from the microtask queue and executes them.

  5. After the microtask queue is empty, the event loop moves to the macrotask queue and processes tasks there.

Example: Understanding Execution Order

Here’s a simple example to illustrate the event loop in action:

console.log('Start');

setTimeout(() => {
  console.log('Timeout callback');
}, 0);

Promise.resolve().then(() => {
  console.log('Promise callback');
});

console.log('End');

Output:

Start
End
Promise callback
Timeout callback

Why this order?

  1. console.log('Start') and console.log('End') execute immediately because they are synchronous.

  2. setTimeout is an asynchronous operation. Its callback is added to the task queue.

  3. The promise resolves immediately, and its .then callback is added to the microtask queue.

  4. After the synchronous code finishes, the event loop processes the microtask queue first, executing the promise callback.

  5. Finally, the event loop processes the task queue and executes the setTimeout callback.

Adding Complexity

Let’s add more asynchronous operations to see how they interact:

console.log('Script start');

setTimeout(() => {
  console.log('setTimeout 1');
}, 0);

setTimeout(() => {
  console.log('setTimeout 2');
}, 0);

Promise.resolve().then(() => {
  console.log('Promise 1');
}).then(() => {
  console.log('Promise 2');
});

console.log('Script end');

Output:

Script start
Script end
Promise 1
Promise 2
setTimeout 1
setTimeout 2

Detailed Breakdown:

  1. console.log('Script start') executes immediately.

  2. Two setTimeout calls are made, and their callbacks are queued in the task queue.

  3. A promise resolves, and its .then callback is added to the microtask queue.

  4. console.log('Script end') executes.

  5. The microtask queue processes Promise 1 and Promise 2 in order.

  6. The task queue processes setTimeout 1 and setTimeout 2 in order.

Advanced Event Loop Patterns

1. Handling Heavy Computations

// Example: Chunking Heavy Computations
function processLargeArray(array, chunkSize = 1000) {
    let index = 0;

    function processChunk() {
        const chunk = array.slice(index, index + chunkSize);
        if (chunk.length === 0) return Promise.resolve();

        // Process chunk
        chunk.forEach(item => {
            // Heavy computation per item
        });

        index += chunkSize;

        // Schedule next chunk using microtask
        return new Promise(resolve => {
            queueMicrotask(() => {
                processChunk().then(resolve);
            });
        });
    }

    return processChunk();
}

// Usage
const largeArray = new Array(1000000).fill(0);
processLargeArray(largeArray).then(() => {
    console.log('Processing complete');
});

2. Custom Task Scheduling

class TaskScheduler {
    constructor() {
        this.highPriorityTasks = [];
        this.lowPriorityTasks = [];
        this.isProcessing = false;
    }

    addHighPriorityTask(task) {
        this.highPriorityTasks.push(task);
        this.processNextTask();
    }

    addLowPriorityTask(task) {
        this.lowPriorityTasks.push(task);
        this.processNextTask();
    }

    async processNextTask() {
        if (this.isProcessing) return;
        this.isProcessing = true;

        while (this.highPriorityTasks.length || this.lowPriorityTasks.length) {
            const task = this.highPriorityTasks.length
                ? this.highPriorityTasks.shift()
                : this.lowPriorityTasks.shift();

            try {
                await task();
            } catch (error) {
                console.error('Task failed:', error);
            }

            // Allow other tasks to interrupt
            await new Promise(resolve => setTimeout(resolve, 0));
        }

        this.isProcessing = false;
    }
}

// Usage
const scheduler = new TaskScheduler();
scheduler.addHighPriorityTask(async () => {
    console.log('High priority task');
});
scheduler.addLowPriorityTask(async () => {
    console.log('Low priority task');
});

3. Advanced Error Handling

// Example: Robust Error Handling in Async Operations
async function robustAsyncOperation(operation) {
    const MAX_RETRIES = 3;
    let attempts = 0;

    while (attempts < MAX_RETRIES) {
        try {
            const result = await operation();
            return result;
        } catch (error) {
            attempts++;

            if (attempts === MAX_RETRIES) {
                throw new Error(`Operation failed after ${MAX_RETRIES} attempts: ${error.message}`);
            }

            // Exponential backoff
            await new Promise(resolve => 
                setTimeout(resolve, Math.pow(2, attempts) * 1000)
            );
        }
    }
}

// Usage
async function fetchWithRetry(url) {
    return robustAsyncOperation(async () => {
        const response = await fetch(url);
        if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
        return response.json();
    });
}

Common Pitfalls and Best Practices

Misunderstanding Asynchronous Execution

A common misconception is that setTimeout with a delay of 0 executes immediately. However, its callback is added to the task queue and will only execute after the current stack and microtasks are cleared.

Overloading the Microtask Queue

Microtasks can pile up if not handled properly, leading to performance issues. For example:

function repeat() {
  Promise.resolve().then(repeat);
}
repeat();

This code creates an infinite loop of microtasks, effectively freezing the event loop.

Best Practices

  1. Avoid blocking the call stack with heavy computations; use Web Workers if necessary.

  2. Minimize the use of nested setTimeout or promises to keep the microtask and task queues manageable.

  3. Leverage tools like async/await for cleaner asynchronous code.

Best Practices for Event Loop Optimization

  1. Batch DOM Updates
// Bad practice
function updateList(items) {
    items.forEach(item => {
        const element = document.createElement('li');
        element.textContent = item;
        document.querySelector('ul').appendChild(element);
    });
}

// Better practice
function updateList(items) {
    const fragment = document.createDocumentFragment();
    items.forEach(item => {
        const element = document.createElement('li');
        element.textContent = item;
        fragment.appendChild(element);
    });
    document.querySelector('ul').appendChild(fragment);
}
  1. Use RequestAnimationFrame for Animations
function smoothAnimation() {
    let start = null;
    const duration = 1000; // 1 second

    function animate(timestamp) {
        if (!start) start = timestamp;
        const progress = timestamp - start;

        // Calculate animation state
        const percentage = Math.min(progress / duration, 1);

        // Apply animation
        element.style.transform = `translateX(${percentage * 100}px)`;

        if (progress < duration) {
            requestAnimationFrame(animate);
        }
    }

    requestAnimationFrame(animate);
}
  1. Optimize Promise Chains
// Bad practice - sequential
async function fetchAllData(urls) {
    const results = [];
    for (const url of urls) {
        const result = await fetch(url);
        results.push(await result.json());
    }
    return results;
}

// Better practice - parallel with controlled concurrency
async function fetchAllData(urls, concurrency = 3) {
    const results = [];
    const chunks = urls.reduce((acc, url, i) => {
        const chunkIndex = Math.floor(i / concurrency);
        acc[chunkIndex] = [...(acc[chunkIndex] || []), url];
        return acc;
    }, []);

    for (const chunk of chunks) {
        const chunkResults = await Promise.all(
            chunk.map(url => 
                fetch(url).then(res => res.json())
            )
        );
        results.push(...chunkResults);
    }
    return results;
}

Conclusion

The event loop is a powerful mechanism that allows JavaScript to handle asynchronous operations seamlessly. By understanding its workings, you can write more efficient and responsive applications. Remember the hierarchy: synchronous code → microtasks → tasks, and you’ll be well on your way to mastering the event loop.

Experiment with these concepts and code examples to solidify your understanding. Happy coding!

K
Kris W1y ago

The article is so comprehensive. it's easy to understand as there's also example. Nice Article!