11001

Browser Architecture: JavaScript Event Loop

What Is the Event Loop?

JavaScript is a single-threaded language by default, meaning it executes only one piece of code at a time. User interaction, DOM manipulations, style calculations, rendering, and many other tasks all run on the same thread, known as the main thread.

A natural question arises: if everything runs on a single thread, why don't web pages freeze when tens of operations happen at once? Why do we still see smooth animations, responsive buttons, and near-instant UI updates?

This is where the Event Loop comes into play.

The Event Loop is the mechanism that allows JavaScript to handle asynchronous operations without blocking the main thread. It is not part of the JavaScript engine itself, but part of the environment in which JavaScript runs, such as the browser or Node.js. The exact mechanism is slightly different depending on the environment.

In this post, we will talk about the browser event loop.

Five Core Things

(+ abstraction)

To understand browser event loop mechanism, we need five core things:

  1. Call Stack - this is a data structure that works on Last In, First Out (LIFO) principle. It is controlled by the JavaScript engine, and this is place where synchronous code runs.
    As we discussed before, only one function can be executed at a time. Function cannot be interrupted until it is completely finished.
    When we invoke a function, the engine creates a new execution context for that function (containing function-related variables, arguments and e.t.c), which is then pushed onto the Call Stack. Hundreds or thousands of execution contexts can pile up on the stack during deep recursions or nested calls.
    *Think of execution contexts as “function environments”, one for each function (called local context, rewrite it) that is currently executing (plus the global context). (Explain it on function instead of execution context).

  2. Web APIs - these are browser-managed features (this is not part of the JavaScript engine or ECMAScript specification) that run in parallel to the main thread, Web APIs allow us to initiate tasks in the background.
    Once a Web API operation finishes, it pushes its callback into the task or microtask queue.

    Commonly used examples (add list here):
    Timers API - to delay code (setTimeout, setInterval)
    Fetch API - makes HTTP requests
    DOM APIs - manipulate HTML/ CSS (document.querySelector, document.addEventListener, etc)
    Web Storage API - to save data in the browser's localStorage, sessionStorage
    Geolocation API - get user location data (navigator...). Whenever a function is called on the Call Stack related to Web APIs, it is sent to a browser API.
    ... *Mentioned only commonly used, but there is more


    *Queue - data structure which works on First In, First Out (FIFO) principle.

  3. Tasks queue - it contains callbacks from Web APIs, user interaction events, I/O operations. You can create a task using setTimeout() or setInterval().
    *It is worth mentioning that the initial execution of script is itself a task.

  4. Microtask queue - this is a queue for Promise handler callbacks (then(), catch(), finally()) and a few other things such as MutationObserver and queueMicrotask() callbacks. It is hight-priority queue, which means all tasks in the microtask queue will be completed before going to the next iteration.
    *Also code after await

  5. Rendering - is the process by which the browser converts HTML and CSS into actual pixels on the screen.
    It is important to know:
    - Rendering occurs only at the end of an event loop iteration, it never interrupts code execution, and the browser decides if allow it (usually ~60 times/sec, but can be skipped).
    - Browsers may skip rendering if no visual changes occurred, if the tab is hidden, or if there are pending tasks in the task queue.

How the Browser Event Loop Works?

So right now we have five core things to see how event loop works.

The Event Loop continuously checks if the Call Stack is empty and pushes tasks from task queue and microtask queue onto the call stack to execute. While the event loop decides which functions get pushed onto the Call Stack, it does not manage the stack itself (As we discussed it maintained by JavaScript engine).(+ examples from start)

Full event loop iteration:

  1. Select the oldest (first) task (Only one per iteration) from the task queue and execute it completely. (After the task is handled, its function is popped off the Call Stack)

  2. Execute all microtasks in order from oldest to newest.

  3. Render the page (If needed and allowed by the browser)

*endless loop, then, it "starts over", goes back to step 1 by again checking task queue

task 1 - Initial Script Execution

Evaluate Script and schedule operations, sync operations (ALL, Synchronously execute the script as though it were a function body from top to bottom. Call order = top to bottom in code Return order - last called, first returned (macrotask)const pause = (millis) => new

*This is to "register" its callbacks to the Web API, which then offloads the operation to the browser. call stack - (Can call browser APIs)

- show execute selected task - Run callback synchronously entirely where During this new macro/ micro task can be queued

show nesting principle (recursion or a lot fns calls)

- Microtasks can also schedule other microtasks! This could create a scenario where we create an infinite microtask loop, delaying rendering or next iterations, and freezing the rest of the program. So be careful! 

- When JavaScript keeps the main thread continuously busy with tasks, the browser might? postpone rendering and processes the next task (next iteration) instead.

Promise(resolve => setTimeout(resolve, millis));

const start = Date.now();
console.log('Start');

pause(1000).then(() => {
  const end = Date.now();
  const secs = (end - start) / 1000;

  console.log('End:', secs);
});

setTimeout(() => {
    console.log('timeout 1');
    document.body.innerHTML = 'A';
});

requestAnimationFrame(() => {
    console.log('requestAnimationFrame');
    document.body.innerHTML = 'B';
});

setTimeout(() => {
    console.log('timeout 2');
    document.body.innerHTML = 'C';
});

console.log('script');
document.body.innerHTML = 'D';

Interesting Moments

  • requestAnimationFrame - function allows you to run code right before the next render.
    *It is commonly used for animations and visual updates that should be synchronized with the browser’s rendering cycle.

  • As discussed earlier, JavaScript executes on a single thread, and rendering never happens while the JavaScript engine is executing a task, regardless of how long that task takes.
    Changes to the DOM are painted only after the task is complete. JavaScript is single-threaded and runs on the same thread as rendering. The browser queues these changes but cannot render because JavaScript is blocking the thread. DOM changes accumulate in memory (the DOM tree is updated, but not the pixels on screen). Changes to the DOM are painted only after the task is complete.  (Give example, otherwise it will block thread and we will not see any UI changes and can't execute other code)

  • alert()
    The alert() function is blocking. It pauses JavaScript execution, blocks rendering, and prevents user interaction until the dialog is dismissed.

  • new Promise((resolve, reject) => { ... }) - the executor function runs immediately and synchronously when the Promise is constructed. not a constructor of Promises. Calling fetch creates a Promise Object in memory, which is "pending" by default. After initiating the network request, the fetch function call is popped off the Call Stack.

  • All code after any await runs as a microtask. Everything written after an await — even if it is completely synchronous code - becomes a microtask that will only run after the promise on the await line settles. (Show example)

  • For heavy calculations that shouldn’t block the event loop, we can use Web Workers. In parallel, another thread, Web Workers can exchange messages with the main process. Web Workers do not have access to the DOM, so they are useful mainly for calculations that utilize multiple CPU cores simultaneously. Use Web Workers with postMessage to the main thread

Conclusions

Understanding of the event loop helps you see how things work under the hood and identify asynchronous or performance problems.

At first, this mechanism can be difficult to understand, so read or watch the material more than once. I’ll include a link to additional resources on this topic in the video description.

Thank you for reading, and enjoy development.

Sources

HTML Spec WHATWG (Living Standard): Section 8.1.7 Event loops

MDN Web Docs. In depth: Microtasks and the JavaScript runtime environment

MDN Web Docs. await

MDN Web Docs. Using microtasks in JavaScript with queueMicrotask()