Prologue: "Dev Tools Looked Strange"
First time web dev, tried console.log(document):
console.log(document);
// Output:
#document
html
head
body
div
"Wait, my HTML shows as a tree?"
HTML file looked like:
<html><head>...</head><body>...</body></html>
Browser showed it like family tree. Why? I wrote angle brackets and tags (just text), but the console showed a hierarchy like an org chart in Excel.
"What happened?"
Why I Studied This
Tried modifying HTML with JavaScript:
document.getElementById("box").style.color = "red";
Worked, but didn't know the principle. Just copy-pasted from Stack Overflow and used it.
What's document? How does getElementById find HTML? Does it grep through the HTML file? Or is the HTML loaded entirely into memory somewhere?
Senior: "That's DOM API. Converts HTML to object for manipulation."
Me: "Object? HTML becomes object? HTML is a .html file, how can it be an object?"
Senior smiled. "Browser reads HTML file and builds tree structure in memory. That's the DOM."
Still didn't get it. "But I wrote <div>Hello</div> in HTML. How does that become an object? Is the object stored in the file?"
What Confused Me
- How does text HTML become object?
- Is DOM JavaScript? HTML? Browser?
- What's Virtual DOM? How's it different from Real DOM?
- Why are Reflow and Repaint slow?
Most importantly: "Can't I just edit HTML?" Why not open the HTML file in a text editor and change it? Why manipulate DOM with JavaScript?
At first, I thought the HTML file itself changed in real-time. I thought document.getElementById("box").textContent = "World" would automatically modify the .html file on the server. Obviously that wasn't true, and after some trial and error, I realized: "Oh, HTML and DOM are different things."
The Aha Moment: "Blueprint vs Building"
Senior's analogy:
"HTML is blueprint (diagram).
<div id="box">Hello</div>Just text. Like paper. Or more precisely, like a CAD file. Like a
.dwgfile drawn by an architect. You can't live in it.Browser reads this blueprint and builds building (DOM tree) in memory.
{ tagName: 'div', id: 'box', textContent: 'Hello', style: { color: 'black' }, children: [] }JavaScript remodels the building. Blueprint (HTML file) unchanged. But current building (DOM) changes. Painting walls, moving furniture, adding rooms."
"Oh, HTML is initial design, DOM is real-time state!"
This analogy clicked immediately. HTML file is just a text file with .html extension, browser reads it and builds a tree in memory. JavaScript manipulates that tree.
So document.getElementById doesn't open a file, it searches the tree in memory. style.color = "red" doesn't modify the HTML file, it changes an object property in memory.
1. HTML vs DOM: Blueprint and Building
HTML (Static, Text)
<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
<title>Test</title>
</head>
<body>
<div id="app">Hello</div>
</body>
</html>
- Saved as file (
.html) - Original blueprint delivered to browser
- Unchanging
HTML is serialized text. String of tags and angle brackets. UTF-8 encoded byte stream. What gets transmitted over network, saved to disk, managed in Git—this text file.
I didn't understand this at first. "Doesn't HTML show on screen?" But the HTML file itself doesn't appear on screen. Browser must read this file and build something for it to appear.
DOM (Dynamic, Object Tree)
Browser reads HTML, creates in-memory:
#document
└─ html
├─ head
│ └─ title ("Test")
└─ body
└─ div#app ("Hello")
- Living object in memory
- Modifiable by JavaScript
- Screen shows DOM
DOM is parsed tree structure. Browser reads HTML text character by character, splits into tokens, creates nodes from tokens, assembles nodes into tree. This process is parsing.
My thought when I first understood this: "Oh, like a compiler." Just as a C compiler reads source code and builds an AST (Abstract Syntax Tree), browser reads HTML and builds a DOM Tree.
So DOM only exists in memory. Not saved to file. Disappears when you close browser. Refresh rebuilds DOM by re-reading HTML file.
2. DOM Tree Structure: Like a Family Tree
<html>
<head>
<title>Example</title>
</head>
<body>
<div id="container">
<h1>Title</h1>
<p>Content</p>
</div>
</body>
</html>
DOM Tree:
document (Document node)
└─ html (Element node)
├─ head
│ └─ title
│ └─ "Example" (Text node)
└─ body
└─ div#container
├─ h1
│ └─ "Title"
└─ p
└─ "Content"
My thought when I first saw this tree: "Wait, this is the same Tree from data structures class?"
Correct. DOM is a tree data structure. Has root node (document), each node has parent and children, sibling relationships too. That's why DOM API has methods like parentNode, childNodes, nextSibling.
Node Types
- Document node: Top level (
document) - Element node: HTML tags (
div,p,span) - Text node: Text content
- Attribute node: Attributes (
id="app",class="box") - Comment node: Comments (
<!-- comment -->)
I found it interesting that Text nodes exist separately. Looking at <p>Hello</p>, the p tag is an Element node, and Hello is a child Text node. So changing p.textContent actually changes the Text node's value.
I didn't know about Comment nodes at first either. "Aren't comments only in code?" But comments also appear as nodes in DOM tree. So iterating with childNodes includes comments too.
3. DOM API: JavaScript Manipulation
Selecting Elements
// By ID
const box = document.getElementById("box");
// CSS selector
const boxes = document.querySelectorAll(".box");
// Tag name
const divs = document.getElementsByTagName("div");
// Class name
const buttons = document.getElementsByClassName("btn");
// First match
const firstBox = document.querySelector(".box");
I didn't understand the difference between getElementById and querySelector at first. "They both find elements, why two?" Turns out there's a performance difference.
getElementById uses an internal ID hashmap in the browser, finding in O(1). Meanwhile querySelector parses CSS selector and traverses DOM tree, relatively slower. Of course both are fast enough in modern browsers, but if you can find by ID, getElementById is slightly more efficient.
Modifying Content
const box = document.getElementById("box");
// Change text
box.textContent = "New Text";
// Change HTML
box.innerHTML = "<span>Bold</span>";
// Change attribute
box.setAttribute("data-id", "123");
// Add/remove class
box.classList.add("active");
box.classList.remove("hidden");
box.classList.toggle("visible");
The difference between textContent and innerHTML confused me at first. Both change content, what's different?
Turns out textContent handles pure text only, innerHTML does HTML parsing. So putting <script> in innerHTML can enable XSS attacks. (Though modern browsers don't execute scripts inserted via innerHTML.)
Changing Styles
box.style.color = "red";
box.style.fontSize = "20px";
box.style.backgroundColor = "#f0f0f0";
// Must use camelCase
// CSS: background-color
// JS: backgroundColor
CSS property names changing to camelCase in JavaScript felt inconvenient at first. Having to write background-color as backgroundColor. Why can't I just use string like style["background-color"]? Turns out that works too:
box.style["background-color"] = "red";
But most code uses camelCase. Because box.style.backgroundColor is easier to type and IDE autocomplete works better.
Adding/Removing Elements
// Create
const newDiv = document.createElement("div");
newDiv.textContent = "I'm new!";
newDiv.className = "box";
// Append
document.body.appendChild(newDiv);
// Insert at specific position
const parent = document.getElementById("container");
const firstChild = parent.firstChild;
parent.insertBefore(newDiv, firstChild);
// Remove
box.remove();
// Or
parent.removeChild(box);
The difference between appendChild and insertBefore confused me at first. appendChild adds to the end, insertBefore adds before specific node.
But there's no insertAfter. Because you can use nextSibling:
// insertAfter implementation
function insertAfter(newNode, referenceNode) {
referenceNode.parentNode.insertBefore(newNode, referenceNode.nextSibling);
}
When I first saw this pattern, I thought "clever." Inserting before nextSibling is effectively inserting after.
4. My Junior Mistake: Why Was It So Slow?
Code I wrote as a junior:
// ❌ Very Bad Example
for (let i = 0; i < 1000; i++) {
const div = document.createElement("div");
div.textContent = i;
document.body.appendChild(div); // Touches Real DOM 1000 times!
}
Result: Browser stuttered. Chrome DevTools Performance tab showed Rendering taking 80% of time.
Senior: "DOM manipulation is expensive. Do it 1000 times, of course it's slow."
Me: "Why? Just adding objects to memory?"
Senior: "DOM manipulation makes browser redraw screen. Calculate layout, paint pixels. That's expensive."
Why Is It Slow?
Every time appendChild runs, browser does:
- Reflow: Recalculate layout (position and size of all elements)
- Repaint: Redraw screen (pixel rendering)
1000 loops = 1000 Reflows & 1000 Repaints!
I initially thought "why is adding objects slow?" Turns out DOM manipulation isn't just memory manipulation. Browser re-runs rendering pipeline every time DOM changes.
Rendering Pipeline:
- Build DOM Tree
- Build CSSOM Tree (CSS Object Model)
- Build Render Tree (combine DOM + CSSOM)
- Layout calculation (exact position and size of each element)
- Paint (draw pixels)
- Composite (combine layers)
Every appendChild re-executes steps 4-6. Repeat 1000 times, naturally slow.
Solution: DocumentFragment
// ✅ Good Example
const fragment = document.createDocumentFragment(); // Virtual container
for (let i = 0; i < 1000; i++) {
const div = document.createElement("div");
div.textContent = i;
fragment.appendChild(div); // Memory only. No Reflow.
}
document.body.appendChild(fragment); // Touches Real DOM ONCE!
Result: Blazing fast. Performance tab now showed Rendering under 5%.
DocumentFragment is a virtual container existing only in memory. Not attached to DOM tree, so manipulating it doesn't trigger Reflow. And when you appendChild(fragment), the Fragment itself disappears and only child nodes get added to DOM.
After learning this pattern, I realized: "Oh, that's why React is needed." React uses Virtual DOM to batch changes and apply to Real DOM all at once. Like automatic Fragment usage.
5. Reflow vs Repaint: Core of Performance
Reflow (Layout Recalculation)
When DOM structure or size changes:
// Triggers Reflow
element.style.width = "100px";
element.style.height = "200px";
element.style.display = "none";
element.style.padding = "10px";
element.classList.add("big");
// Even reading triggers Reflow
const height = element.offsetHeight; // Forced Synchronous Layout!
const width = element.getBoundingClientRect().width;
Cost: Very expensive (recalculate entire layout)
When Reflow occurs, browser recalculates layout tree. Not just the element, but potentially parent, children, sibling positions and sizes too.
For example, changing parent's width affects children's width. Especially with flex or grid layout, one element change can affect entire layout.
I found it surprising that reading offsetHeight triggers Reflow. "It's just reading, why expensive?" Turns out browser uses Lazy Evaluation. Multiple DOM manipulations don't immediately calculate layout, deferred until actually drawing to screen.
But reading offsetHeight requires layout calculation right now. So browser immediately executes Reflow. This is called Forced Synchronous Layout.
Repaint (Redrawing Screen)
When only color or background changes:
// Only Repaint (no Reflow)
element.style.color = "red";
element.style.backgroundColor = "#fff";
element.style.visibility = "hidden"; // differs from display: none
element.style.outline = "1px solid blue";
Cost: Cheaper than Reflow but still expensive
Repaint skips layout calculation and only re-executes Paint stage. Re-painting pixels.
The difference between visibility: hidden and display: none comes from this:
display: none: Element removed from layout → Triggers Reflowvisibility: hidden: Element occupies space but invisible → Only Repaint
So if performance matters when hiding elements, visibility: hidden is slightly more efficient. Though only useful when maintaining layout.
CSSOM: CSS Is Object Model Too
DOM alone can't render screen. No style information. Browser also parses CSS files to build CSSOM (CSS Object Model).
/* style.css */
body {
font-size: 16px;
}
.box {
width: 100px;
height: 100px;
background-color: red;
}
CSSOM Tree:
body
font-size: 16px
.box
width: 100px
height: 100px
background-color: red
Then combines DOM and CSSOM to build Render Tree. Render Tree only includes elements that will actually be drawn on screen. For example, elements with display: none aren't included in Render Tree.
I initially thought "style information is all in DOM," but turns out DOM and CSSOM are separated. So style.color = "red" in JavaScript doesn't change CSSOM, it changes element's inline style.
Optimization Tips
// ❌ Bad (3 Reflows)
element.style.width = "100px";
element.style.height = "100px";
element.style.margin = "10px";
// ✅ Good (1 Reflow)
element.style.cssText = "width: 100px; height: 100px; margin: 10px;";
// Or
element.className = "box-large"; // Define all in CSS
// ✅ Better (separate reads/writes)
const height = element1.offsetHeight; // read
const width = element2.offsetWidth; // read
element1.style.height = height + 10 + "px"; // write
element2.style.width = width + 10 + "px"; // write
Mixing reads and writes triggers Forced Synchronous Layout every time:
// ❌ Worst case (2 Forced Reflows)
element1.style.width = "100px"; // write
const height = element1.offsetHeight; // read → Forced Reflow!
element2.style.width = "200px"; // write
const width = element2.offsetWidth; // read → Forced Reflow!
Browser tries to batch "write" operations for later processing, but "read" in between forces immediate calculation.
So reads first, writes later is better for performance.
6. Virtual DOM: React's Secret Weapon
When I learned React, I wondered:
"How does
setStaterender so fast?"
The Problem with Real DOM
// Update 1000 items
items.forEach(item => {
const element = document.getElementById(item.id);
element.textContent = item.name; // 1000 DOM manipulations!
});
This code triggers 1000 Repaints. Even Reflows if element size changes.
I initially thought "1000 times is like 0.1 seconds, that's fine?" but actually much slower. Each DOM manipulation runs browser's rendering pipeline.
React's Solution: Virtual DOM
What React does:
-
Virtual DOM (Fake DOM) creation
// JavaScript object in memory { type: 'div', props: { id: 'app' }, children: ['Hello'] } -
Apply changes only to Virtual DOM
setState({ text: 'World' }) → Updates Virtual DOM only (doesn't touch Real DOM) -
Diffing (compare differences)
Old Virtual DOM: { children: ['Hello'] } New Virtual DOM: { children: ['World'] } → Difference: only text changed -
Apply minimal changes to Real DOM
// Only 1 Real DOM modification element.textContent = 'World';
Result: Minimize DOM manipulation count → Fast!
Virtual DOM is a Pure JavaScript Object. Doesn't use DOM API at all. So manipulation is extremely fast. Changing object properties takes nanoseconds.
React maintains two Virtual DOMs:
- Current Tree: State currently shown on screen
- Work-in-Progress Tree: State to be shown in next render
Calling setState updates Work-in-Progress Tree. Then Reconciliation algorithm compares both trees to find differences.
Found differences get applied to Real DOM. This is called Commit Phase.
I initially thought "Virtual DOM isn't always faster, right?" Virtual DOM also compares JavaScript objects, so with large trees it could be slow.
Correct. Virtual DOM isn't always faster than Real DOM. But usually faster because:
- JavaScript object comparison much faster than DOM manipulation
- Batch updates minimize Reflow count
- Developers don't need to worry about optimization
7. Events and DOM: Puppet Strings
Event Listeners
const button = document.getElementById("btn");
button.addEventListener("click", () => {
console.log("Clicked!");
});
// Remove event
button.removeEventListener("click", handleClick);
When I first learned event listeners, I thought: "How's this different from HTML's onclick attribute?"
<!-- In HTML -->
<button onclick="handleClick()">Click</button>
<!-- In JavaScript -->
<button id="btn">Click</button>
<script>
document.getElementById("btn").addEventListener("click", handleClick);
</script>
Turns out addEventListener is much better:
- Multiple registrations:
onclickallows one,addEventListenerallows multiple - Removable: Can remove with
removeEventListener - Options control: Use options like
capture,once,passive
Especially the passive option was interesting when I learned about it:
element.addEventListener("touchstart", handleTouch, { passive: true });
Setting passive: true tells browser "this event handler won't use preventDefault()." Then browser can immediately start scrolling, improving performance.
Event Bubbling
<div id="outer">
<div id="inner">
<button id="btn">Click</button>
</div>
</div>
Event propagation when button clicked:
button (click!) → inner → outer → document
When I first learned event bubbling, I thought "why is this needed?" Can't events just fire on the clicked element?
Turns out bubbling enables event delegation. And convenient for common handling across multiple elements.
// Check bubbling
document.getElementById("outer").addEventListener("click", () => {
console.log("Outer clicked");
});
document.getElementById("inner").addEventListener("click", () => {
console.log("Inner clicked");
});
document.getElementById("btn").addEventListener("click", () => {
console.log("Button clicked");
});
// Button click output:
// Button clicked
// Inner clicked
// Outer clicked
To stop bubbling, use stopPropagation():
document.getElementById("btn").addEventListener("click", (e) => {
e.stopPropagation();
console.log("Button clicked");
});
// Now only "Button clicked" outputs
Event Delegation (Performance Hack)
My mistake code:
// ❌ Bad (separate listener for each of 1000 buttons)
const buttons = document.querySelectorAll(".btn");
buttons.forEach(btn => {
btn.addEventListener("click", handleClick); // 1000 listeners!
});
Problems:
- Memory waste (1000 function objects)
- Dynamically added buttons have no events
- Slow initial rendering
Improvement:
// ✅ Good (only 1!)
document.body.addEventListener("click", (e) => {
if (e.target.classList.contains("btn")) {
handleClick(e);
}
});
Possible thanks to bubbling! Button click bubbles up to body, so catching once at body is enough.
When I first learned this pattern, I thought "this is really clever." A pattern from jQuery days, still valid today.
Especially useful when dynamically adding elements:
// Using event delegation
document.body.addEventListener("click", (e) => {
if (e.target.classList.contains("delete-btn")) {
e.target.parentElement.remove();
}
});
// Later added buttons automatically work
const newBtn = document.createElement("button");
newBtn.className = "delete-btn";
document.body.appendChild(newBtn);
Without event delegation, would need to re-register event listeners every appendChild.
Event Flow: Capture and Bubble
Events actually propagate in two phases:
- Capture Phase:
document→outer→inner→button - Bubble Phase:
button→inner→outer→document
By default addEventListener operates in Bubble Phase. To operate in Capture Phase:
element.addEventListener("click", handleClick, { capture: true });
// Or
element.addEventListener("click", handleClick, true);
I initially thought "when do I use Capture?" but rarely used in practice. Only useful in special cases. For example, when parent needs to catch and block child events first:
// Block all clicks
document.body.addEventListener("click", (e) => {
e.stopPropagation();
console.log("No clicks allowed!");
}, { capture: true });
8. Shadow DOM: Encapsulation Secret
Shadow DOM is core technology of Web Components. Creates hidden DOM tree inside DOM tree.
const host = document.getElementById("host");
const shadowRoot = host.attachShadow({ mode: "open" });
shadowRoot.innerHTML = `
<style>
p { color: red; }
</style>
<p>Text inside Shadow DOM</p>
`;
Styles inside Shadow DOM don't leak out. And outside styles don't come in. Perfect encapsulation.
I initially thought "how's this different from iframe?" but completely different:
- iframe: Separate document, separate window
- Shadow DOM: Same document, only DOM tree separated
Shadow DOM is also used in browser built-in elements. For example, <video> tag's play button is implemented with Shadow DOM. Can see it in DevTools with "Show user agent shadow DOM" option enabled.
<video controls>
#shadow-root
<div class="controls">
<button class="play"></button>
<button class="pause"></button>
</div>
</video>
Rarely need to use Shadow DOM directly in practice, but useful when building libraries. No worries about style conflicts.
9. Summary Checklist
| Concept | Explanation |
|---|---|
| HTML | Text file (blueprint) |
| DOM | Object tree in memory (real-time state) |
| CSSOM | Object tree parsed from CSS |
| Render Tree | Combined DOM + CSSOM |
| Manipulation | JavaScript modifies with DOM API |
| Reflow | Layout recalculation (structure/size change) |
| Repaint | Redraw pixels (color change) |
| Optimization | Fragment, Virtual DOM, Event Delegation |
| Virtual DOM | JS object tree used by React etc. |
| Shadow DOM | Encapsulated DOM subtree |
Final Thought: "Browser Internals"
Before understanding DOM:
- "JavaScript just magically changes HTML."
- "Why so slow? Browser problem?"
- "Why need React? Vanilla JS works fine?"
After understanding DOM:
- "HTML is text, browser parses into tree, JavaScript manipulates that tree."
- "DOM manipulation triggers Reflow/Repaint, so minimize it."
- "React's Virtual DOM batch-processes Real DOM manipulation."
Now I understand why:
document.getElementByIdis fast (hashmap lookup)innerHTML +=in a loop is death (re-parse + Reflow every time)- React is necessary for complex apps
HTML is the Blueprint. DOM is the Building. JavaScript is the Construction Team.
And CSSOM is interior design blueprint, Render Tree is completed building, Reflow is structural renovation, Repaint is painting.
This analogy clicked for me.