When we create a new tab in the browser, a new browsing context is created. A browsing context aggregates a lot of different things that we take for granted: things like session and history management, navigation and resource loading. We also have an event loop, which is used to process the interaction between scripts and the DOM.
In this tutorial we will first get an essential understanding of the event loop, which is mostly designed following the reactor pattern. Then we look at some auxiliary objects that are associated with the browsing context. Finally we will work out a list of best practices to utilize the event loop as efficiently as possible.
The Reactor Pattern
Each browsing context comes with an associated event loop. The event loop is bound to the window
of the context’s current document. It can be shared with another window
that hosts a document
from the same origin.
The event loop takes care of running events in a normalized fashion, i.e. such that they cannot interleave or execute in parallel. For instance, even though Worker
allows us to run some other JavaScript in parallel, we cannot interact with the DOM or the core JavaScript directly. We need to send messages, which are processed via events. These events are processed by the event loop and will therefore run in sequential order.
Most event loops are implemented in some kind of reactor pattern. One of the reasons is that all reactor systems are single-threaded by design, but can of course exist in multi-threaded environments. The structure of a reactor is as follows:
- We have resources (presented as handles, i.e. references, such as JavaScript callbacks).
- A demultiplexer to normalize running the events.
- A dispatcher, which registers / unregisters handlers and dispatches events.
- Of course we also need event handlers to use the resources referenced with the handles.
The demultiplexer sends the handle to the dispatcher as soon as possible, i.e. after all previous events have been handled. Internally the demultiplexer may have some kind of queue, which operates using the first-in-first-out (FIFO) principle. Real implementations may use some kind of priority queue.
The following diagram sketches the relation between the different components of the reactor pattern.
In the case of JavaScript running in the browser, the handle is a JavaScript function, possibly capturing some objects. The event handlers are DOM event handlers. The reactor allows code to run concurrently without any potential cross-threading problems.
There are multiple dispatchers, since, for example, every EventTarget
is able to register, unregister and dispatch events. There is, however, only one assigned demultiplexer per event loop.
Browsing Event Loop
Now that we have a vague understanding of the reactor pattern and its implementation in an event loop, we need to have a closer look at the event loop from the point of interaction, which is JavaScript in our case.
Every time we make a function call in JavaScript a frame is added to the so-called call-stack by the runtime. The stack has a last-in-first-out (LIFO) structure. The call-stack keeps track of the call path. A frame consists of the arguments for the function and its local state, defined by the instruction currently being run and all local variables with their corresponding values.
To illustrate this concept, let’s consider the following piece of code.
function init() { setTimeout(function () {}, 0); } init();
If we now save the call-stack after each change, we end up with the following picture. We start with an empty call-stack. After calling the init
function we have an element on the stack. The setTimeout
call gives us another element, even though the function itself will only make the context switch to a native function. Having returned to the init
method we are left with a single element on the stack. Finally the stack is empty again.
At this point it starts to make sense to distinguish two modes of triggering events in JavaScript: synchronous and asynchronous. The difference can be illustrated by the following example code. We use an asynchronous event (click
) trigger to run a synchronous (focus
) one. Note that click
can also be triggered in a synchronous manner by using the click()
method.
var button = document.querySelector('button'); var text = document.querySelector('input[type=text]'); button.addEventListener('click', function () { console.log('foo'); text.focus(); console.log('bar'); }, false); text.addEventListener('focus', function () { console.log('baz'); }, false);
We’ll see that foo, baz and bar will be logged. Therefore the focus
event was processed right after calling focus()
. There are also events, such as the DOM mutation events, which are always triggered synchronously. We discuss DOM mutation events in the eighth article of this series.
The event demultiplexer implementation for the event loop in a browser has to distinguish between running ordinary event callbacks (known as Tasks) and preferred smaller chunks of code, which are named Microtasks.
While tasks are enqueued in the normal way, microtasks are enqueued with high priority. They are executed as soon as possible, i.e. always before the next task. Most DOM interactions are enqueued to the event loop as tasks, e.g. calling the callback function of setTimeout
. On the other hand, the callbacks of the new Promise
type are invoked as microtasks.
Another example for an API that enqueues microtasks is the MutationObserver
interface. For the moment we postpone the discussion of the MutationObserver
to the eighth article of this series.
The following example illustrates the difference between an ordinary task and a microtask.
function init () { console.log('foo'); setTimeout(later, 0); now(); Promise.resolve().then(soon); console.log('bar'); } function later () { console.log('baz'); } function soon () { console.log('norf'); } function now () { console.log('qux'); } init();
We’ll see that foo, qux, bar, norf and baz are logged. The order of norf and baz could be wrong depending on the browser, but should be as presented. Running the previous sample code in the browser’s console also yields an interesting result.
As an example, the outcome for the latest version of Opera is displayed below.
The result of the init
function (undefined
) is displayed after the callback from the Promise
has been executed. Therefore the debugger tools of the browser are integrated using a normal task—not a microtask.
While the browser may use the time between normal tasks to perform intrinsic steps, such as issuing a render call, microtasks will be run immediately after running the current code has been finished.
Session and History Management
A browsing context contains many more services. Most of these services are usually not accessible, but some of them may expose an API that we can use. For instance, some hardware-based services are exposed in the navigator
object.
Furthermore we can access the (local) history via the history
object. Here we find several useful methods. The presumably most useful one is pushState()
. It takes three parameters:
- The state of the entry to push. This has to be a string. We could use a JSON data structure.
- A title for our reference. This is not used by most browsers, hence we can omit this parameter altogether.
- Finally a URL to display. Browsers will usually show it in the location bar.
Let’s see an example.
history.pushState(null, null, 'http://www.example.com/my-state');
Calling pushState
will immediately change the displayed URL. What happens when the user presses the omnipresent back button? Generally speaking it is up to the browser to decide, but a good decision would be to pop the current state.
The forward or back operations are also exposed via the history
object. For instance we can do the following:
history.back(); history.forward();
Invoking back
programmatically or via the UI may lead to going back to the previous page. If the stack is already empty we cannot pop the current state. This can also be experienced with a short demo, which involves two buttons and a list.
The example code to trigger the logic looks as follows.
var list = document.querySelector('ul'); var buttons = document.querySelectorAll('button'); var back = buttons[0]; var forward = buttons[1]; back.addEventListener('click', function (ev) { history.back(); }, false); forward.addEventListener('click', function (ev) { var c = list.childElementCount.toString(); var url = 'foo-' + c; history.pushState(c, null, url); var item = document.createElement('li'); item.textContent = url; list.appendChild(item); }, false); window.addEventListener('popstate', function (ev) { list.removeChild(list.children[ev.state * 1]); }, false);
The history API is a nice way to implement routing in a single-page application (SPA). An alternative way that still uses the URL for routing is given by manipulating the URL’s hash and listening to the hashchange
event. Again we use events to trigger callbacks asynchronously.
Best Practices
A drawback of the event loop is that a web application depends highly on the processing time of the currently active task or microtask. We are therefore not able to enqueue a long-running computation. The browser would stop the computation after some time. A good practice to circumvent this is to split the computation into several parts, which are processed by the event loop one after another.
A simple scheme to display the event loop is shown below. The loop spins freely until a task is waiting to be run. If we consider the task to be JavaScript-related, we need to pass the control to the JavaScript engine. Finally the code could register some more callbacks. If the event is fired, all callbacks are enqueued on the event loop as tasks to be run.
In browsers, callbacks are enqueued any time the associated event occurs. Events without a callback won’t enqueue anything.
Calling setTimeout()
waits at least the provided time before enqueuing the specified callback in the queue. If there is no other task in the queue, the callback will be invoked right away, otherwise we have to wait. This is the reason why the second argument of setTimeout
defines a minimum time and not a guaranteed time.
Long-running computations should be placed in a Worker
, which provides its own event loop. It also contains its own memory management. Therefore a web worker will not interfere with the event loop of our web application, since communication is done via events. The whole scheme is non-blocking.
In general we should always try to avoid using blocking code. Most of the API is already exposed in a non-blocking fashion, either using callbacks directly or events. Unfortunately, there are legacy exceptions, such as alert
or synchronous XHR. They should never be used unless we know exactly what we are doing.
So should we use a task or a microtask? The Promise
API uses a microtask for a reason. If available we may want to aim for that. The microtask is performed as soon as possible, practically following directly after the current code is executed. It could therefore prevent unnecessary rendering. Why should we render once before inserting a result, which will result in requiring another render operation? However, if in doubt we should pass the control back to the browser by using a standard task.
Conclusion
It is important to fully understand how JavaScript works and how it interacts with the DOM and other resources. This starts with the event loop. The concept is not only implemented in the browser, but is also present in the core of Node.js. Mastering JavaScript means mastering the event loop.
In the previous tutorial we saw that the browser supplies us with several mechanisms that are aggregated in the browsing context and exposed at several points. Even though we cannot control some of the internal mechanisms, we still should know that they exist and how they work.
Comments