Creating and Updating DOM
Mental model: Build the nodes off-document, then attach them once. The browser only has to do expensive work when the tree the user can see changes.
Level: intermediate · about 15 minutes
You have a hundred product names from an API and an empty <ul>. The naive version appends a hundred times, and the browser recalculates layout each time. The good version builds the whole list in memory and touches the live document once. The code is barely longer.
const li = document.createElement('li');
li.textContent = 'Tea'; // text, never markup
li.classList.add('item');
document.querySelector('#cart').append(li);The four lines that create and attach a node.
A new node is not in the document until you attach it. Until then it exists in memory with no parent, nothing renders, and nothing costs layout. That gap is where all the useful patterns in this lesson live.
The insertion methods
| Call | Puts the node | Takes strings? | Takes several? |
|---|---|---|---|
parent.append(x) | last inside parent | yes (as text) | yes |
parent.prepend(x) | first inside parent | yes (as text) | yes |
el.before(x) | just before el | yes (as text) | yes |
el.after(x) | just after el | yes (as text) | yes |
el.replaceWith(x) | in place of el | yes (as text) | yes |
el.remove() | nowhere, detaches el | n/a | n/a |
parent.replaceChildren(...x) | as the entire contents | yes (as text) | yes |
parent.appendChild(x) | last inside parent | no, throws | no, one node |
const first = list.children[0];
list.append(first); // moved to the end, not copied
const copy = first.cloneNode(true); // true = deep, include descendants
list.append(copy); // now there really are two
list.replaceChildren(); // empty it, listeners on the old nodes go with themMoving a node needs no removal step. Appending an attached node relocates it.
textContent, innerHTML, innerText
const untrusted = '<img src=x onerror="steal()">';
if (typeof document === 'undefined') {
console.log('In a browser:');
console.log('el.textContent = untrusted -> the characters are shown, no element is created');
console.log('el.innerHTML = untrusted -> a real <img> is parsed, onerror fires, you are owned');
} else {
const safe = document.createElement('div');
safe.textContent = untrusted;
console.log('textContent children:', safe.children.length); // -> 0
const unsafe = document.createElement('div');
unsafe.innerHTML = untrusted;
console.log('innerHTML children:', unsafe.children.length); // -> 1
}The same string, three destinations, three very different outcomes.
| Property | Reads | Writes | Cost |
|---|---|---|---|
textContent | all text, including hidden elements | text only, always safe | cheap |
innerText | only rendered text, respects CSS | text, but forces layout on read | expensive to read |
innerHTML | serialised markup of the children | parses markup, can inject scripts | reparses everything |
outerHTML | the element and its children | replaces the element itself | reparses everything |
const el = document.createElement('div');
el.innerHTML = '<script>alert(1)<\/script>';
// does the alert fire?A literal <script> from innerHTML is parsed but not run, which fools people into thinking innerHTML is safe. It is not: <img src=x onerror=...>, <svg onload=...> and <iframe srcdoc=...> all execute immediately. The defence is not to filter tags, it is to stop parsing untrusted strings as markup.
String concatenation, unsafe
list.innerHTML = items
.map((i) => `<li>${i.name}</li>`)
.join('');
// one item named
// <img src=x onerror=fetch(...)>
// and the page belongs to
// someone else
Nodes, safe by construction
const frag = new DocumentFragment();
for (const i of items) {
const li = document.createElement('li');
li.textContent = i.name;
frag.append(li);
}
list.replaceChildren(frag);The safe version is also the faster one for large lists, because the fragment is assembled outside the document. When you genuinely need to render rich markup from a string, sanitise it first with a maintained library, or use the setHTML sanitiser API where it is available.
DocumentFragment: a bag with no wrapper
A **DocumentFragment** is a lightweight parentless container. You append into it as much as you like, and when you insert the fragment its children move into the target and the fragment itself disappears. One document mutation instead of a hundred, and no extra <div> in your markup.
hundred appends one append
----------------- ----------
li -> #cart (layout) li -> frag (free)
li -> #cart (layout) li -> frag (free)
li -> #cart (layout) li -> frag (free)
... x100 ... x100
frag -> #cart (layout once)
function renderList(container, items) {
const frag = new DocumentFragment();
for (const item of items) {
const li = document.createElement('li');
li.textContent = item.name;
li.dataset.id = item.id;
frag.append(li);
}
container.replaceChildren(frag); // one visible mutation
}Building off-document, attaching once.
The <template> element
A <template> in your HTML is parsed but inert: its contents are not rendered, images do not load, scripts do not run. You clone it whenever you need a copy. It keeps your markup in the HTML file, where a designer can find it, instead of in a JavaScript string.
// <template id="row">
// <li class="row"><span class="name"></span><button>Remove</button></li>
// </template>
const tpl = document.querySelector('#row');
function makeRow(item) {
const node = tpl.content.cloneNode(true); // true, or you get an empty fragment
node.querySelector('.name').textContent = item.name;
node.querySelector('li').dataset.id = item.id;
return node; // a fragment, ready to append
}Clone, fill, attach. Note content and the deep clone.
Reflow, repaint and layout thrashing
- Write, write, write Style and structure changes are queued. The browser is happy to batch them.
- Then read Reading a geometric property forces the browser to flush the queue and recalculate layout right now.
- Never alternate Read, write, read, write in a loop makes the browser recalculate on every iteration. That is layout thrashing.
Thrashing: N layouts
for (const row of rows) {
const h = row.offsetHeight; // read
row.style.height = h + 4 + 'px'; // write
}
Batched: one layout
const heights = rows.map(
(r) => r.offsetHeight); // all reads
rows.forEach((r, i) => {
r.style.height = heights[i] + 4 + 'px';
}); // all writesThe properties that force layout are the ones that describe geometry: offsetTop, offsetHeight, clientWidth, scrollTop, getBoundingClientRect() and getComputedStyle(). Read them all first, then write.
reflow (layout)- recompute positions and sizes. The expensive one
repaint- redraw pixels without moving anything, for example a colour change
composite- move existing layers on the GPU.
transformandopacityonly. The cheap one off-document work- costs nothing, because there is nothing to lay out
Try it yourself
Render into a fake document
const doc = {
createElement: (tag) => ({ tag, textContent: '', attrs: {}, children: [] }),
createFragment: () => ({ tag: '#fragment', children: [] }),
};
let appendCount = 0;
const container = { tag: 'ul', children: [], append(node) { appendCount++; this.children.push(node); } };
const items = ['Tea', 'Coffee', 'Cocoa'];
for (const name of items) {
const li = doc.createElement('li');
li.textContent = name;
container.append(li);
}
console.log('appends:', appendCount); // -> 3
console.log(container.children.map((c) => c.textContent)); // -> [ 'Tea', 'Coffee', 'Cocoa' ]
Add a class to each row. Then count how many times append touches the container and try to reduce it to one.
What escaping actually does
const escape = (s) => String(s).replace(/[&<>"]/g, (ch) => ({
'&': '&', '<': '<', '>': '>', '"': '"',
}[ch]));
const payloads = [
'Ada Lovelace',
'<img src=x onerror="steal()">',
'"><script>alert(1)</script>',
'Tea & Coffee',
];
for (const p of payloads) console.log(escape(p));
Add the single quote to the map. Then try to find a payload that survives, and notice you cannot once every angle bracket is gone.
Exercises
Escape before you interpolate
Write escapeHtml(value) that returns a string with the five dangerous characters replaced by entities: & becomes &, < becomes <, > becomes >, " becomes " and ' becomes '. Non-string input is converted with String() first. & must be replaced first so entities are not double-escaped incorrectly.
Batch the render into one mutation
Write renderList(doc, container, items). doc has createElement(tag) and createFragment(). Each item is { id, name }. Build one li per item with textContent set to name and attrs['data-id'] set to String(id), collect them into a fragment, and call container.append(fragment) exactly once, whatever the item count. Return the fragment.
Check yourself
- You assign a username from an API to
el.innerHTML. The username is<img src=x onerror="fetch(evil)">. What happens? - The image fails to load and the onerror handler runs — Inline event handler attributes execute normally. Only literal
<script>elements are skipped, which is exactly why "script tags do not run" is such a dangerous half-truth.textContentwould have shown the characters harmlessly. - What does
list.append(list.children[0])do when the list has three items? - Moves the first item to the end, still three items — A node can only be in one place, so inserting an attached node moves it. If you want a copy you have to ask for one with
cloneNode(true). - What is logged?
- 0 —
cloneNode()defaults to a shallow copy, so you get an empty fragment. You needcloneNode(true). It fails silently, rendering nothing, which is why it is such a common half hour of confusion. - Which loop forces the browser to recalculate layout on every iteration?
rows.forEach((r) => { r.style.height = r.offsetHeight + 1 + 'px'; })— ReadingoffsetHeightafter a write forces a synchronous layout so the number is accurate. Alternating read and write per row is layout thrashing. Collect every measurement first, then apply every change.
Common mistakes
- Interpolating user data into an
innerHTMLstring. It is the most common XSS bug in front-end code. - Believing
innerHTMLis safe because<script>does not execute.onerrorandonloaddo. - Cloning a
<template>instead of its.content, or forgetting thetrueincloneNode(true). - Appending inside a loop directly to the live document when the list is large.
- Alternating geometry reads and style writes, which forces one layout per iteration.
- Using
innerHTML = ''to empty a container instead ofreplaceChildren().
Takeaways
- A created node costs nothing until it is attached, so do your building off-document.
append,prepend,before,after,replaceWithandreplaceChildrencover every insertion you need.textContentis the safe default. EveryinnerHTMLwith dynamic data needs a justification.- A
DocumentFragmentdissolves on insert, giving you one mutation and no wrapper element. <template>keeps markup in the HTML, but you must clone.contentdeeply.- Batch reads then writes. Geometry reads flush pending layout and cost you a reflow.