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

CallPuts the nodeTakes strings?Takes several?
parent.append(x)last inside parentyes (as text)yes
parent.prepend(x)first inside parentyes (as text)yes
el.before(x)just before elyes (as text)yes
el.after(x)just after elyes (as text)yes
el.replaceWith(x)in place of elyes (as text)yes
el.remove()nowhere, detaches eln/an/a
parent.replaceChildren(...x)as the entire contentsyes (as text)yes
parent.appendChild(x)last inside parentno, throwsno, 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 them

Moving 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.

PropertyReadsWritesCost
textContentall text, including hidden elementstext only, always safecheap
innerTextonly rendered text, respects CSStext, but forces layout on readexpensive to read
innerHTMLserialised markup of the childrenparses markup, can inject scriptsreparses everything
outerHTMLthe element and its childrenreplaces the element itselfreparses 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

  1. Write, write, write Style and structure changes are queued. The browser is happy to batch them.
  2. Then read Reading a geometric property forces the browser to flush the queue and recalculate layout right now.
  3. 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 writes

The 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. transform and opacity only. 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) => ({
  '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;',
}[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 &amp;, < becomes &lt;, > becomes &gt;, " becomes &quot; and ' becomes &#39;. 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. textContent would 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 need cloneNode(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'; }) — Reading offsetHeight after 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 innerHTML string. It is the most common XSS bug in front-end code.
  • Believing innerHTML is safe because <script> does not execute. onerror and onload do.
  • Cloning a <template> instead of its .content, or forgetting the true in cloneNode(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 of replaceChildren().

Takeaways

  • A created node costs nothing until it is attached, so do your building off-document.
  • append, prepend, before, after, replaceWith and replaceChildren cover every insertion you need.
  • textContent is the safe default. Every innerHTML with dynamic data needs a justification.
  • A DocumentFragment dissolves on insert, giving you one mutation and no wrapper element.
  • <template> keeps markup in the HTML, but you must clone .content deeply.
  • Batch reads then writes. Geometry reads flush pending layout and cost you a reflow.