How JavaScript Runs in the Browser

How the browser builds the DOM from HTML, the order in which it processes link and script tags, the defer attribute, the DevTools Console, and checking results as text in the practice console.

Code that changes text on the page with JavaScript can work or fail depending on where you load it. When it fails, no error appears on the page, so you can't tell whether the code itself is wrong or the problem is the order things run in.

This article covers the order in which the browser loads HTML and runs JavaScript.

The Code in This Article Is Meant to Be Opened as HTML

The practice console can't reproduce the order in which HTML is loaded, so this article has no exercises. Each example shows what happens when you put index.html and app.js in the same folder and open index.html in Chrome, with the results written as comments at the end of the lines. How to check things in the console is covered in the last section.

Building Nested Elements from HTML — the DOM Tree

Say you want app.js to change the "Calculating…" text on an order confirmation page to "3,300 yen". What JavaScript changes here isn't the text in the index.html file. It works on separate data that the browser built by reading the HTML.

The browser turns each HTML element (a piece of the page marked off by tags, like <p></p>) into an object, and the whole set of their parent-child relationships is called the DOM (Document Object Model, also called the DOM tree; its entry point is document).

Settings written inside a tag, like id="total" and defer, are called attributes. An id must be unique: no other element on the page can use the same one.

<!-- ---- index.html (order confirmation page) ---- -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <title>Order Confirmation</title>
    <link rel="stylesheet" href="style.css">   <!-- CSS that controls the look -->
  </head>
  <body>
    <h1>Order Confirmation</h1>
    <ul id="items">                           <!-- has two li children -->
      <li>Mug × 2</li>
      <li>Coaster × 1</li>
    </ul>
    <p id="total">Calculating…</p>            <!-- the element app.js wants to change -->
  </body>
</html>
The DOM Tree Built from index.html
html element — top of the DOM, reached from document
head element — info not shown on the page
  • meta — character encoding is UTF-8
  • title — contains "Order Confirmation"
  • link — points to style.css
body element — the part shown on the page
h1 element
  • Contains "Order Confirmation"
ul element — id is items
  • li — contains "Mug × 2"
  • li — contains "Coaster × 1"
p element — id is total
  • Contains "Calculating…"
  • The element app.js wants to change
head and body sit side by side as children of html, and the li elements go inside ul. The way tags are nested becomes the parent-child structure of the DOM.

app.js gets the p element with document.querySelector("#total") (which finds the element with the id written after the #, or returns null if there isn't one) and changes its textContent (the text inside the element). Since the p element is a child of body, whether it's found depends on whether body is in the DOM yet.

Reading HTML from the Top — the Order of link and script

Suppose you load app.js, which updates the total, in <head> next to the CSS. index.html clearly contains <p id="total">, yet when you open the page it still says "Calculating…", and the update in app.js has no effect.

During HTML parsing (the browser reading the HTML from the start and adding each element it finds to the DOM in order), when the browser reaches a <link>, it keeps parsing while it fetches the CSS. When it reaches a <script src>, it pauses parsing, fetches the JavaScript, and runs it to the end before reading the rest.

// ---- index.html (loading app.js in head) ----
// <head>
//   <link rel="stylesheet" href="style.css">   ← parsing continues
//   <script src="app.js"></script>             ← parsing pauses to run it
// </head>
// <body>
//   <p id="total">Calculating…</p>              ← added to the DOM after app.js
// </body>

// ---- app.js ----
const total = document.querySelector("#total");   // null (not in the DOM yet)
total.textContent = "3,300 yen";
// TypeError: Cannot set properties of null (setting 'textContent')
The p Doesn't Exist Yet When the Script Runs
<link href="style.css">Fetchesthe CSSParsing goes onto the next line<script src="app.js">Pauses parsing,runs app.jsNo #total yet,so null returned<p id="total">Resumes parsing,adds it to the DOM#total is addedtoo late</html>Parsing ends,DOM is completeStill shows"Calculating…"
Parsing pauses at the script line and reads body only after app.js has run. Even when app.js throws an error, parsing resumes and the page is displayed.

The update didn't work because the p element wasn't in the DOM yet when app.js ran. In the next section, you'll leave the two lines of app.js as they are and move the point at which they run to after the DOM is complete.

Running After the DOM Is Complete — the defer Attribute

If you move <script> to just before </body>, it runs after the p element has been read, so #total is found. But then whether it works depends on where the tag sits, and if you later move the loading line back into <head> with the CSS, you get the same null again.

The defer attribute (a <script> setting that fetches JavaScript without pausing HTML parsing and runs it once the DOM is complete) is written as <script defer src="app.js"></script>, and it can go in <head> just like the CSS <link>.

// ---- index.html (loading in head with defer) ----
// <head>
//   <link rel="stylesheet" href="style.css">
//   <script defer src="app.js"></script>       ← starts fetching, parsing continues
// </head>
// <body>
//   <p id="total">Calculating…</p>              ← app.js runs after everything up to </html> is read
// </body>

// ---- app.js (the same 2 lines as before, plus 1 to check) ----
const total = document.querySelector("#total");   // the p element
total.textContent = "3,300 yen";                  // the text on the page changes
console.log(total.textContent);                   // 3,300 yen
defer Fetches in Parallel and Runs Last
<script defersrc="app.js">Reads on and addsbody to the DOMFinishes readingup to </html>DOM is completeStarts fetchingapp.jsFetch goes on;parsing continuesFetched, butnot run yetRuns app.js andfinds #total
app.js is fetched while parsing continues, and even if the fetch finishes first, it waits to run. It runs only after the DOM is complete.

When there are several deferred scripts, they run in the order they appear in the HTML, not the order their fetches finish. The table below shows, for each way of writing the script, when app.js runs and whether #total is found.

How the script is writtenWhen app.js runs#total
<script src="app.js"> in headRight away, pausing parsingNot found (null)
<script src="app.js"> before </body>After the p is readFound
<script defer src="app.js"> in headAfter the DOM is completeFound
Inline code in <script defer> in headRight away (defer is ignored without src)Not found (null)
<script type="module" src="app.js"> in headAfter the DOM, even without deferFound (not when opened as a file)

Finding Where It Stopped and Inspecting the DOM — DevTools

On the page before defer was added, app.js threw a TypeError, but nothing showed up on the page. Without knowing which file and line it failed on, you can't track down why the page is stuck on "Calculating…".

DevTools (a panel built into the browser for inspecting a page's DOM and errors) has a Console tab, which in Chrome opens with Ctrl + Shift + J on Windows or Command + Option + J on Mac. It lists console.log output, along with any errors and the file name and line number where each was thrown.

// ---- Error shown in the Console tab (click app.js:2 on the right to open that line) ----
// Uncaught TypeError: Cannot set properties of null (setting 'textContent')
//     at app.js:2:19       ← thrown at line 2, column 19

// ---- After the page loads, type this after the > in the Console and press Enter ----
document.querySelector("#total");                 // <p id="total">Calculating…</p>
document.querySelector("#total").textContent;     // 'Calculating…'

// ---- A line to add after line 1 of app.js (don't type this in the Console) ----
console.log("total at run time:", total);         // total at run time: null
The Same Expression Returns the p After Loading
app.js runswhile loadingParsing is pausedin headquerySelectorreturns nullTypeErroron line 2Typed in Consoleafter loadingParsing is done,DOM is completequerySelectorreturns the pIts text reads"Calculating…"
The expression that returned null in app.js returns the p element in the Console. Code that works in the Console won't necessarily work in app.js.

An expression you type in the Console runs against the DOM as it is at that moment. To check a value inside app.js, add a console.log before the line you suspect, so that the value at the time the code runs is printed in the Console tab.

Checking Results as Text — innerHTML and outerHTML

In the exercises in the articles that follow, you'll find, change, and add elements. But the practice console has no visible page, and until you reload, every exercise shares a single invisible page, so each run starts with the elements from the previous run still in place.

Assigning to innerHTML (a property that reads and writes an element's contents as an HTML string) replaces the contents entirely. Each exercise starts by assigning to the innerHTML of document.body (the body element) to clear out earlier elements, and you check the result by printing outerHTML (an HTML string that includes the element's own tags).

// ① Replace everything inside body (elements left over from the last run are removed here)
document.body.innerHTML = `<p id="total">Calculating…</p>`;

// ② Find the element and change its text
const total = document.querySelector("#total");
total.textContent = "3,300 yen";

// ③ The page isn't visible, so print the results as strings
console.log(total.textContent);          // 3,300 yen
console.log(total.outerHTML);            // <p id="total">3,300 yen</p>
console.log(document.body.innerHTML);    // <p id="total">3,300 yen</p>
The Invisible Page Exercises Share
Article page — the practice console
  • Only console.log output comes back, as text
Invisible page — one for all exercises
  • Contents of document.body — kept until the next run
First run
  • const total — usable only within this run
  • Assigns a p to document.body.innerHTML
Second run
  • const total — created as a new variable, separate from last time
  • The assignment at the start removes the first run's p
Whatever is inside body stays there until the next run. Assigning to innerHTML at the start clears out the leftover elements.

Code in the console runs inside a new function each time, so const total is created as a separate variable from the previous run, and you don't get a redeclaration error. It also runs after the page has finished loading, so you don't need to think about defer.

Printing an Element Directly Shows {}

The practice console has no page to show and converts output to text before returning it. If you pass an element straight to console.log, it shows up as {} with nothing visible inside, so you can't tell whether your change worked. To check an element, print its outerHTML or textContent.

QUIZ

Knowledge Check

Answer each question one by one.

Q1In a script loaded in head without defer, what does document.querySelector("#total") return?

Q2When does app.js run with <script defer src="app.js">?

Q3If you run document.body.innerHTML = '<p>Calculating…</p>'; twice, how many p elements are there?