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 saysdocument.querySelector('#cart')getsnull, 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
leftandwidthsixty times a second instead oftransform. - “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:
| Stage | What happens | Triggered again by |
|---|---|---|
| Style | Match selectors to elements and compute final values (the render tree skips display:none elements) | Class or attribute changes, new elements |
| Layout | Calculate each box’s size and position | width, height, padding, font-size, adding text |
| Paint | Draw text, colours, borders and shadows into layers | color, background, box-shadow |
| Composite | Stack the layers on screen, often on the GPU | transform, 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.
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>
| Attribute | Blocks parsing? | Runs when | Order kept? | Use for |
|---|---|---|---|---|
| none | Yes | Immediately when reached | Yes | Rarely; legacy code |
defer | No | After parsing, before DOMContentLoaded | Yes | Your app code |
async | Only while it runs | As soon as downloaded | No | Independent scripts (analytics) |
type="module" | No | Like defer (or like async with the attribute) | Yes | Modern 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
$0in 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 forquerySelectorandquerySelectorAll(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:
- 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. - 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. - 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
DOMContentLoadedandload. - “
defervsasync?” Both download in parallel.deferkeeps order and waits for parsing;asyncruns 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.