Ralfiz Academy
Free lesson · DOM 0.1

How the browser turns code into pixels

When a page loads, the browser parses your HTML into a tree of objects called the DOM and your CSS into the CSSOM. It combines them, works out every element’s size and position ( layout ), fills in…

About 45 minutesDeveloper courseStart here

At a glance

16 min read

When a page loads, the browser parses your HTML into a tree of objects called the DOM and your CSS into the CSSOM. It combines them, works out every element’s size and position (layout), fills in the pixels (paint) and stacks the painted layers on screen (composite).

JavaScript does not change your HTML file. It changes the DOM, the live tree in memory, and the browser re-runs only the pipeline stages that the change needs. Where you put your <script> tag, and whether it has defer, async or type="module", decides when your code runs and what it can see.

In this lesson you meet the pipeline, the DevTools Elements, Console and Network panels, and you build your first live page: a profile card.

In plain words

Your HTML file is a recipe, not the meal. When the browser receives it, it reads the text from top to bottom and builds a tree of objects in memory: one object for the <body>, one for each <h1>, one for each run of text. That tree is the Document Object Model, the DOM. Your CSS is read the same way into a second structure, the CSSOM. The browser then combines the two, decides where every box goes, and turns boxes into pixels.

JavaScript never edits your .html file on the server. It edits the DOM, the live tree in memory. Every time you change it, the browser redoes only the steps it needs to put the right pixels back on screen. This whole course is about working with that tree well.

Why it matters

Knowing the pipeline explains many bugs and slow pages you will meet as a junior developer:

  • A script in the <head> that says document.querySelector('#cart') gets null, because the parser has not reached the cart yet.
  • A page shows a white screen for two seconds because a large script blocks the parser before any content.
  • An animation stutters on a cheap phone because it changes left and width sixty times a second instead of transform.
  • “View source” shows one thing and DevTools shows another, because DevTools shows the DOM after JavaScript and the parser’s fixes.

Interviewers ask about this path (often called the critical rendering path) because it separates people who copy snippets from people who understand what the browser is doing.

How it works

Step 1: bytes become the DOM

The browser downloads HTML as bytes, decodes them into characters (UTF-8 for modern pages, which is why you add <meta charset="utf-8">), splits the characters into tokens such as “start tag ul” and “text Pen”, and builds nodes from the tokens. Nodes are linked into a tree: every node has one parent and any number of children.

The HTML parser is very forgiving. It never stops at an error; it follows precise rules in the HTML standard to repair your markup. An unclosed <li> is closed when the next one starts. A <tr> written straight inside <table> gets a <tbody> wrapped around it. A <b> left open at the end of a paragraph is closed for you. Try it below: the tree on the right is the real DOM the parser built from the text on the left.

Try it: What the parser really builds

HTML

<main class="tool">
  <header class="head">
    <h1>HTML → DOM</h1>
    <span class="pill" id="count">0 elements</span>
  </header>
  <div class="cols">
    <label class="pane">
      <span class="label">HTML source (what you wrote)</span>
      <textarea id="src" spellcheck="false"><ul><li>Pen<li>Notebook</ul>
<table><tr><td>Total<td>₹240</table>
<p>Hello <b>Ralfiz</p></textarea>
    </label>
    <section class="pane">
      <span class="label">DOM tree (what the parser built)</span>
      <ul class="tree" id="tree"></ul>
    </section>
  </div>
</main>

CSS

body { margin: 0; background: #f4f5fa; }
.tool { padding: 16px; display: grid; gap: 12px; }
.head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.head h1 { font-size: 18px; margin: 0; }
.pill { font-size: 12px; font-weight: 600; padding: 4px 10px; border-radius: 999px; background: #eef2ff; color: #4338ca; }
.cols { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }
.pane { display: grid; grid-template-rows: auto 1fr; gap: 8px; background: #fff; border: 1px solid #e6e8ef;
  border-radius: 12px; padding: 12px; min-height: 280px; }
.label { font-size: 12px; font-weight: 600; color: #667085; text-transform: uppercase; letter-spacing: .04em; }
textarea { font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; border: 1px solid #e6e8ef;
  border-radius: 8px; padding: 10px; resize: vertical; background: #fbfbfd; color: #1f2328; }
textarea:focus { outline: none; border-color: #818cf8; box-shadow: 0 0 0 3px #e0e7ff; }
.tree { list-style: none; margin: 0; padding: 0; font: 13px/1.7 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  overflow: auto; max-height: 300px; }
.node { white-space: nowrap; border-left: 1px dashed transparent; }
.node.el { color: #7c3aed; }
.node.txt { color: #0f766e; }
.node.new { background: #fef9c3; border-radius: 4px; }

JavaScript

const input = document.querySelector('#src');
const tree = document.querySelector('#tree');
const count = document.querySelector('#count');

function row(depth, label, kind) {
  const li = document.createElement('li');
  li.className = 'node ' + kind;
  li.style.paddingLeft = depth * 16 + 'px';
  li.textContent = label;
  return li;
}

function walk(node, depth, rows) {
  if (node.nodeType === Node.ELEMENT_NODE) {
    const tag = node.tagName.toLowerCase();
    const r = row(depth, '<' + tag + '>', 'el');
    if (tag === 'tbody' && !input.value.includes('<tbody')) r.classList.add('new');
    rows.push(r);
  } else if (node.nodeType === Node.TEXT_NODE && node.textContent.trim()) {
    rows.push(row(depth, '"' + node.textContent.trim() + '"', 'txt'));
  }
  for (const child of node.childNodes) walk(child, depth + 1, rows);
}

function render() {
  // DOMParser builds an inert document: no scripts run and no images load.
  const doc = new DOMParser().parseFromString(input.value, 'text/html');
  const rows = [];
  walk(doc.body, 0, rows);
  tree.replaceChildren(...rows);
  const n = doc.body.querySelectorAll('*').length;
  count.textContent = n + ' elements';
  return n;
}

input.addEventListener('input', render);
console.log('Parsed', render(), 'elements. Highlighted: nodes the parser added.');

Edit the HTML on the left. The tree shows the DOM the parser built: notice the tbody you never wrote and the closed tags it fixed for you.

This is why CSS like table > tr never matches and why DevTools shows elements you never typed. When you debug, trust the DOM (the Elements panel), not your source file.

Step 2: CSS becomes the CSSOM

Every stylesheet, <style> block and inline style is parsed into the CSSOM. Unlike HTML, CSS cannot be used half-parsed: a rule near the end of a file can override one at the top. So the browser treats stylesheets in the head as render-blocking: it will not paint the page until they have loaded, otherwise you would see a flash of unstyled content. Keep critical CSS small and load it early.

Step 3: style, layout, paint, composite

With both trees ready, the browser runs the rendering pipeline:

StageWhat happensTriggered again by
StyleMatch selectors to elements and compute final values (the render tree skips display:none elements)Class or attribute changes, new elements
LayoutCalculate each box’s size and positionwidth, height, padding, font-size, adding text
PaintDraw text, colours, borders and shadows into layerscolor, background, box-shadow
CompositeStack the layers on screen, often on the GPUtransform, opacity (on their own layer)

The later a change enters the pipeline, the cheaper it is. Click the buttons and watch which stages light up:

Try it: Which stages does a change trigger?

HTML

<main class="lab">
  <header>
    <h1>Rendering pipeline</h1>
    <p>Pick a change. The chips show the stages the browser must run again.</p>
  </header>
  <ol class="stages" id="stages">
    <li data-stage="style">Style</li>
    <li data-stage="layout">Layout</li>
    <li data-stage="paint">Paint</li>
    <li data-stage="composite">Composite</li>
  </ol>
  <div class="stage-area"><div class="box" id="box">Ralfiz</div></div>
  <div class="controls" id="controls">
    <button data-change="width">Change width</button>
    <button data-change="color">Change colour</button>
    <button data-change="transform">Move with transform</button>
    <button data-change="reset" class="ghost">Reset</button>
  </div>
  <p class="why" id="why">Nothing changed yet.</p>
</main>

CSS

body { margin: 0; background: #f4f5fa; color: #1f2328; }
.lab { padding: 16px; display: grid; gap: 12px; }
h1 { font-size: 18px; margin: 0; }
header p { margin: 4px 0 0; color: #667085; font-size: 14px; }
.stages { list-style: none; display: flex; flex-wrap: wrap; gap: 8px; margin: 0; padding: 0; counter-reset: s; }
.stages li { counter-increment: s; font-size: 13px; font-weight: 600; padding: 6px 12px; border-radius: 999px;
  background: #fff; border: 1px solid #e6e8ef; color: #98a2b3; transition: all .2s; }
.stages li::before { content: counter(s) '. '; }
.stages li.on { background: #4f46e5; border-color: #4f46e5; color: #fff; box-shadow: 0 4px 12px rgba(79,70,229,.3); }
.stage-area { background: #fff; border: 1px solid #e6e8ef; border-radius: 12px; padding: 24px 16px; overflow: hidden; }
.box { width: 120px; height: 64px; border-radius: 12px; display: grid; place-items: center; color: #fff;
  font-weight: 700; background: #6366f1; transition: transform .35s ease, background-color .35s ease; }
.controls { display: flex; flex-wrap: wrap; gap: 8px; }
button { font: inherit; font-size: 14px; font-weight: 600; padding: 8px 14px; border-radius: 10px; cursor: pointer;
  border: 1px solid #4f46e5; background: #4f46e5; color: #fff; }
button:hover { background: #4338ca; }
button.ghost { background: #fff; color: #344054; border-color: #d0d5dd; }
button:focus-visible { outline: 3px solid #c7d2fe; outline-offset: 2px; }
.why { margin: 0; font-size: 14px; color: #475467; min-height: 1.5em; }

JavaScript

const box = document.querySelector('#box');
const why = document.querySelector('#why');
const chips = document.querySelectorAll('#stages li');

// Typical costs in Chromium, Firefox and Safari (engines can optimise further).
const changes = {
  width: { stages: ['style', 'layout', 'paint', 'composite'],
    apply: () => { box.style.width = box.style.width === '220px' ? '160px' : '220px'; },
    text: 'Width changes geometry, so layout runs, then paint and composite.' },
  color: { stages: ['style', 'paint', 'composite'],
    apply: () => { box.style.backgroundColor = box.style.backgroundColor ? '' : '#db2777'; },
    text: 'Colour does not move anything: no layout, but the pixels must be repainted.' },
  transform: { stages: ['style', 'composite'],
    apply: () => { box.style.transform = box.style.transform ? '' : 'translateX(120px) rotate(-4deg)'; },
    text: 'transform moves an already painted layer: usually composite only.' },
  reset: { stages: [], apply: () => box.removeAttribute('style'), text: 'Back to the start.' },
};

document.querySelector('#controls').addEventListener('click', (event) => {
  const btn = event.target.closest('button');
  if (!btn) return;
  const change = changes[btn.dataset.change];
  change.apply();
  chips.forEach((chip) => chip.classList.toggle('on', change.stages.includes(chip.dataset.stage)));
  why.textContent = change.text;
  console.log(btn.dataset.change, '→', change.stages.join(' → ') || 'nothing');
});
document.querySelector('[data-change="width"]').click();

Click each change. Geometry changes redo layout, paint and composite; colour skips layout; transform can usually skip straight to composite.

Remember: the browser does not re-render after every line of JavaScript. It collects your changes and runs the pipeline once, before the next frame. The exception is when your code reads geometry, such as offsetHeight, right after a write: then the browser must run layout immediately to give you a correct answer. You will measure this in lesson 1.3.

Where scripts fit: blocking, defer, async and modules

A classic <script> without attributes is parser-blocking. When the parser meets it, it stops building the DOM, downloads the file, runs it, and only then continues. The script can only see the elements parsed before it. Browsers soften the cost with a preload scanner that looks ahead for other files to download, but the DOM still waits.

<head>
  <!-- Blocks parsing; cannot see the body yet -->
  <script src="old.js"></script>

  <!-- Downloads in parallel; runs in order after parsing -->
  <script src="app.js" defer></script>

  <!-- Downloads in parallel; runs as soon as it arrives -->
  <script src="analytics.js" async></script>

  <!-- Modules are deferred by default -->
  <script type="module" src="main.js"></script>
</head>
AttributeBlocks parsing?Runs whenOrder kept?Use for
noneYesImmediately when reachedYesRarely; legacy code
deferNoAfter parsing, before DOMContentLoadedYesYour app code
asyncOnly while it runsAs soon as downloadedNoIndependent scripts (analytics)
type="module"NoLike defer (or like async with the attribute)YesModern apps with import

Two events tell you where the page is. DOMContentLoaded fires when the HTML is fully parsed and deferred scripts have run: the DOM is ready. load fires later, when images, stylesheets and iframes have all arrived. document.readyState moves from loading to interactive to complete along the way. In the playground your script sits at the end of the body, so it runs while the state is still loading:

Try it: When does my script run?

JavaScript

console.log('1. script runs, readyState =', document.readyState);

document.addEventListener('DOMContentLoaded', () => {
  console.log('2. DOMContentLoaded, readyState =', document.readyState);
});

window.addEventListener('load', () => {
  console.log('3. load, readyState =', document.readyState);
});

console.log('   (still inside the script: listeners are only registered)');

The script runs while the page is still loading. DOMContentLoaded fires when parsing is done, load when every resource has arrived.

DevTools: Elements, Console and Network

Press F12 (or Ctrl+Shift+I, Cmd+Option+I on a Mac) in Chrome, Edge or Firefox.

  • Elements shows the live DOM, not your source. Select a node and type $0 in the Console to get it as a JavaScript object. The Styles pane shows which CSS rules won and which were crossed out.
  • Console runs JavaScript against the current page and shows your logs and errors with the file and line. $('selector') and $$('selector') are DevTools shortcuts for querySelector and querySelectorAll (they only exist in DevTools, not in your code).
  • Network shows every request as a waterfall: what loaded, in what order, how long it took, and the status code. Tick “Disable cache” while developing, and use throttling to feel a slow 3G phone.

Example: a profile card

Your first live page is a Ralfiz Academy mentor card. The HTML describes the structure, the CSS gives it a premium look, and fifteen lines of JavaScript make the Follow button work. Notice three things in the code:

  1. The script finds elements with document.querySelector. It works because the script runs after the HTML above it was parsed, exactly like a deferred script.
  2. The button keeps its state in the DOM itself, in aria-pressed. That attribute also tells screen readers the button is a toggle, and the CSS styles [aria-pressed="true"] differently. One source of truth for behaviour, looks and accessibility.
  3. Text is written with textContent, the safe way to put text on a page (lesson 1.2 explains why).

Try it: Your first live page: a profile card

HTML

<main class="wrap">
  <article class="card" id="profile">
    <div class="cover"></div>
    <div class="avatar" aria-hidden="true">AM</div>
    <h1 class="name">Anjali Menon</h1>
    <p class="role">Front-end mentor · Ralfiz Academy</p>
    <ul class="stats">
      <li><strong>12</strong><span>Courses</span></li>
      <li><strong id="followers">4.8k</strong><span>Followers</span></li>
      <li><strong>4.9</strong><span>Rating</span></li>
    </ul>
    <div class="tags"><span>JavaScript</span><span>DOM</span><span>Accessibility</span></div>
    <div class="actions">
      <button class="btn primary" id="follow" aria-pressed="false">Follow</button>
      <button class="btn">Message</button>
    </div>
  </article>
</main>

CSS

:root { --accent: #4f46e5; --text: #1f2328; --muted: #667085; --line: #e6e8ef; }
body { margin: 0; background: #f4f5fa; color: var(--text); }
.wrap { min-height: 100vh; display: grid; place-items: center; padding: 16px; box-sizing: border-box; }
.card {
  width: 100%; max-width: 340px; background: #fff; border: 1px solid var(--line);
  border-radius: 16px; overflow: hidden; text-align: center; padding-bottom: 20px;
  box-shadow: 0 1px 2px rgba(16,24,40,.04), 0 12px 32px -12px rgba(16,24,40,.18);
}
.cover { height: 84px; background: linear-gradient(135deg, #6366f1, #a855f7 60%, #ec4899); }
.avatar {
  width: 76px; height: 76px; margin: -38px auto 8px; border-radius: 50%;
  display: grid; place-items: center; font-weight: 700; font-size: 24px; color: var(--accent);
  background: #eef2ff; border: 4px solid #fff; box-shadow: 0 4px 12px rgba(79,70,229,.25);
}
.name { font-size: 20px; margin: 0; letter-spacing: -.01em; }
.role { margin: 4px 16px 16px; color: var(--muted); font-size: 14px; }
.stats { list-style: none; display: flex; margin: 0 16px; padding: 12px 0;
  border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
.stats li { flex: 1; display: grid; gap: 2px; }
.stats li + li { border-left: 1px solid var(--line); }
.stats strong { font-size: 18px; }
.stats span { font-size: 12px; color: var(--muted); }
.tags { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; margin: 16px; }
.tags span { font-size: 12px; padding: 4px 10px; border-radius: 999px; background: #f2f4f7; color: #344054; }
.actions { display: flex; gap: 8px; padding: 0 16px; }
.btn {
  flex: 1; font: inherit; font-weight: 600; font-size: 14px; padding: 10px 12px; border-radius: 10px;
  border: 1px solid var(--line); background: #fff; color: var(--text); cursor: pointer;
  transition: background .15s, transform .1s;
}
.btn:hover { background: #f9fafb; }
.btn:active { transform: scale(.98); }
.btn:focus-visible { outline: 3px solid #c7d2fe; outline-offset: 2px; }
.btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn.primary:hover { background: #4338ca; }
.btn.primary[aria-pressed="true"] { background: #eef2ff; color: var(--accent); border-color: #c7d2fe; }

JavaScript

const followBtn = document.querySelector('#follow');
const followersEl = document.querySelector('#followers');
let followers = 4800;

followBtn.addEventListener('click', () => {
  const isFollowing = followBtn.getAttribute('aria-pressed') === 'true';
  followBtn.setAttribute('aria-pressed', String(!isFollowing));
  followBtn.textContent = isFollowing ? 'Follow' : 'Following';
  followers += isFollowing ? -1 : 1;
  followersEl.textContent = (followers / 1000).toFixed(1) + 'k';
  console.log(isFollowing ? 'Unfollowed' : 'Followed', '→ followers:', followers);
});

// This script runs after the HTML above it was parsed, so the card exists.
console.log('Card in the DOM:', document.querySelector('#profile'));
console.log('readyState while this script runs:', document.readyState);

Click Follow a few times and watch the console. Then change the name in the HTML tab and press Run.

Open the HTML tab, change the name or add a fourth tag, and press Run. The page is rebuilt from scratch: new DOM, new CSSOM, new layout.

Common mistakes

1. Selecting elements before they exist. A script in the head without defer runs before the body is parsed.

<!-- Wrong: cartButton is null, addEventListener throws -->
<head><script src="app.js"></script></head>

<!-- Fix: defer (or type="module") -->
<head><script src="app.js" defer></script></head>

2. Using async for scripts that depend on each other. async runs whichever file arrives first. If app.js needs utils.js, it may run first and crash. Use defer on both, in order, or use modules with import.

3. Wrapping everything in DOMContentLoaded and also using defer. It works but adds noise. With defer or modules, the DOM is already parsed when your code runs.

4. Animating layout properties.

/* Wrong: layout + paint on every frame */
.drawer.open { left: 0; }
.drawer { left: -300px; transition: left .3s; }

/* Fix: composite-only animation */
.drawer { transform: translateX(-100%); transition: transform .3s; }
.drawer.open { transform: none; }

5. Trusting “View source” while debugging. View source is the original download. Use the Elements panel to see what the parser and your scripts actually produced.

Interview and real-world notes

  • “Walk me through what happens when I load a page.” Answer in order: request, HTML parsed to DOM, CSS to CSSOM, scripts may block the parser, style, layout, paint, composite, then events like DOMContentLoaded and load.
  • “defer vs async?” Both download in parallel. defer keeps order and waits for parsing; async runs as soon as it can, in any order.
  • “Reflow vs repaint?” Reflow (layout) recalculates geometry and is usually the more expensive; repaint redraws pixels without moving anything.
  • In real projects, bundlers such as Vite output <script type="module"> for you, so your code is deferred by default. Frameworks like Next.js also render HTML on the server so the first paint does not wait for JavaScript.

Key terms

DOM
Document Object Model: the tree of objects the browser builds from HTML. JavaScript reads and changes this tree, not the HTML file.
CSSOM
CSS Object Model: the parsed form of all stylesheets, used to compute each element’s final styles.
Layout (reflow)
The stage that calculates the size and position of every visible box. Changing width, height or font size needs a new layout.
Paint
Filling in pixels for text, colours, borders and shadows, usually into layers.
Composite
Drawing the painted layers on screen in the right order. Changing transform or opacity can often be handled here alone.
defer
A script attribute: download in parallel, run in document order after the HTML is parsed, before DOMContentLoaded.
DOMContentLoaded
An event fired when the HTML is fully parsed and deferred scripts have run. Images may still be loading.
TipInterview favourite: explain the critical rendering path in one breath: HTML → DOM, CSS → CSSOM, style → layout → paint → composite, and say that defer keeps scripts from blocking the parser.
Hands-on lab8 steps · 7 exercise files
Quiz4 exam-style questions with explanations
Practice tests30 and 50-question mocks, scored out of 1000

The lab, quiz and practice tests for this lesson are for enrolled students, with progress saved to your own login.