Everything is a task
When we visit a web page, a whole environment is created for us within the browser. This environment has several subsystems that enable the webpage we're looking at to look and behave as it should according to World Wide Web Consortium (W3C) specs. Tasks are the fundamental abstraction inside a web browser. Anything that happens is either a task itself, or a smaller part of a larger task.
Note
If you're reading any of the W3C specifications, the term "user agent" is used instead of "web browser". In 99.9% of cases, the major browser vendors are what we're reading about.
In this section, we'll look at the major components of these environments, and how task queues and event loops facilitate the communication between these components, to realize the overall appearance and behavior of the web page.
Meet the players
Let's introduce some terminology that will help us throughout the various sections in this chapter:
- Execution environment: This container gets created whenever a new web page is opened. It's the all-encompassing environment, which has everything that our JavaScript code will interact with. It also serves as a sandbox—our JavaScript code can't reach outside of this environment.
- JavaScript interpreter: This is the component that's responsible for parsing and executing our JavaScript source code. It's the browser's job to augment the interpreter with globals, such as
window
, andXMLHttpRequest
. - Task queue: Tasks are queued whenever something needs to happen. An execution environment has at least one of these queues, but typically, it has several of them.
- Event loop: An execution environment has a single event loop that's responsible for servicing all task queues. There's only one event loop, because there's only one thread.
Take a look at the following visualization of an execution environment created within a web browser. The task queues are the entry points for anything that happens in the browser. For example, one task can be used to execute a script by passing it to the JavaScript interpreter, while another task is used to render pending DOM changes. Now we'll dig into the parts that make up the environment.
The Execution environment
Perhaps the most revealing aspect of the web browser execution environment is the relatively minor role played by our JavaScript code and the interpreter that executes it. Our code is simply a cog in a much larger machine. There's certainly a lot going on within these environments, because the platform that browsers implement serve an enormous purpose. It's not simply a matter of rendering elements on the screen, then enhancing these elements with style properties. The DOM itself is similar to a micro platform, just as networking facilities, file access, security, and so on. All these pieces are essential for a functioning web economy of sites, and more recently, applications.
In a concurrency context, we're mostly interested in the mechanics that tie all these platform pieces together. Our application is written mainly in JavaScript, and the interpreter knows how to parse and run it. But, how does this ultimately translate into visual changes on the page? How does the networking component of the browser know to make an HTTP request, and how does it invoke the JavaScript interpreter once the response has arrived?
It's the coordination of these moving parts that restricts our concurrency options in JavaScript. These restrictions are necessary, because without them, programming web applications would become too complex.
Event loops
Once an execution environment is in place, the event loop is one of the first components to start. Its job is to service one or more task queues in the environment. Browser vendors are free to implement queues as they see fit, but there has to be at least one queue. Browsers can place every task in one queue if they please, and treat every task with equal priority. The problem with doing so would mean that if the queue is getting backlogged, tasks that must receive priority, such as mouse or keyboard events, are stuck in line.
In practice, it makes sense to have a handful of queues, if for no other reason than to separate tasks by priority. This is all the more important because there's only one thread of control—meaning only one CPU—that will process these queues. Here's what an event loop that services several queues by varying levels of priorities looks like:
Even though the event loop is started along with the execution environment, this doesn't mean that there's always tasks for it to consume. If there were always tasks to process, there would never be any CPU time for the actual application. The event loop will sit and wait for more tasks, and the queue with the highest priority gets serviced first. For example, with the queues used in the preceding image, the interactive queue will always be serviced first. Even if the event loop is making its way through the render queue tasks, if an interactive task is queued, the event loop will handle this task before resuming with render tasks.
Task queues
The concept of queued tasks is essential to understand how web browsers work. The term browser is actually misleading. We used them to browse static web pages in an earlier, sparser web. Now, large and complex applications run in browsers—it's really more of a web platform. The task queues and event loops that service them are probably the best design to handle so many moving parts.
We saw earlier in this chapter that the JavaScript interpreter, along with the code that it parses and runs, is really just a black box when viewed from the perspective of an execution environment. In fact, invoking the interpreter is itself a task, and is reflective of the run-to-completion nature of JavaScript. Many tasks involve the invocation of the JavaScript interpreter, as visualized here:
Any one of these events—the user clicking an element, a script loading in the page, or data from a prior API call arriving in the browser—creates a task that invokes the JavaScript interpreter. It tells the interpreter to run a specific piece of code, and it'll continue to run it until it completes. This is the run-to-completion nature of JavaScript. Next, we'll dig into the execution contexts created by these tasks.