Templates, cloning and efficient rendering

Use the template element for inert markup, clone it into a fragment, and render lists without rebuilding the whole DOM on every change.

The template element

<template id="row-template">
  <li class="row">
    <img class="row__avatar" alt="" width="32" height="32">
    <span class="row__name"></span>
    <button class="row__remove" type="button">Remove</button>
  </li>
</template>
const tpl = document.querySelector('#row-template');
tpl.content;                    // a DocumentFragment - the real markup store
tpl.content.childNodes.length;  // 1

// clone, fill, insert
const node = tpl.content.cloneNode(true);         // deep
const li = node.querySelector('.row');
li.dataset.id = user.id;
li.querySelector('.row__name').textContent = user.name;
li.querySelector('.row__avatar').src = user.avatar;

list.append(node);
  • A template's content lives in a separate document fragment: it is parsed, but not rendered, and its images are not fetched.
  • Scripts inside a template do not run, and styles inside it do not apply until it is cloned into the document.
  • content is a fragment, so append the result of cloneNode, never content itself - appending it moves the nodes out of the template and it can only be used once.
  • cloneNode(true) copies the subtree; the default shallow clone returns an empty element.
⚠️
Cloning tpl.content instead of a copy of it consumes the template. The first render works and every later render produces nothing, which looks like a rendering bug rather than a mistake about node ownership.

Batching with a fragment

function render(users) {
  const tpl = document.querySelector('#row-template');
  const fragment = document.createDocumentFragment();

  for (const user of users) {
    const node = tpl.content.cloneNode(true);
    node.querySelector('.row').dataset.id = user.id;
    node.querySelector('.row__name').textContent = user.name;
    fragment.append(node);          // no layout work yet
  }

  // one mutation, one reflow
  list.replaceChildren(fragment);
}
// the expensive alternative: one insert per row, each triggering work
for (const user of users) {
  list.append(makeRow(user));       // 500 inserts, 500 layout invalidations
}

// and the worst version: rebuilding innerHTML in a loop
list.innerHTML = '';
users.forEach((u) => { list.innerHTML += makeRowHtml(u); });   // reparses everything
ApproachMutationsNote
innerHTML += in a loopnReparses the whole subtree each time
append per nodenLayout work per insert
Fragment then one replaceChildren1The default for list rendering
insertAdjacentHTML once1Fast, but parses HTML you must escape

Updating without a full rebuild

// keyed updates: keep existing nodes, change only what differs
function update(users) {
  const byId = new Map(
    [...list.children].map((el) => [el.dataset.id, el])
  );

  for (const user of users) {
    let el = byId.get(String(user.id));
    if (!el) {
      el = buildRow(user);
      list.append(el);
    } else {
      el.querySelector('.row__name').textContent = user.name;
      byId.delete(String(user.id));       // handled
    }
  }

  // whatever is left in the map is no longer in the data
  for (const el of byId.values()) el.remove();
}
  1. Key rows by a stable id, never by index - indices change when an item is removed and produce confusing updates.
  2. Change text and attributes in place; only create a node when there is no existing one.
  3. Delete by difference rather than by clearing the container, so the scroll position and focus survive.
  4. If a full rebuild is simpler and the list is short, do the full rebuild. Clarity beats a reconciliation you cannot debug.
  5. Once the update logic has three special cases, that is the signal to reach for a framework rather than to add a fourth.
// preserving focus across a re-render
const activeId = document.activeElement?.dataset?.id;

update(users);

if (activeId) {
  list.querySelector('[data-id="' + activeId + '"] .row__name')?.focus();
}

FAQ

Is template faster than innerHTML?
Yes, and more importantly it is safer. The template is parsed once, and each render is a clone rather than a parse of an HTML string - so there is no interpolation step where user data could be read as markup. Use innerHTML only for markup you fully control.
Do I need a framework for list rendering?
Not for lists that are short, replaced wholesale or updated rarely. Keyed reconciliation by hand is worth it when the list is long or the user's scroll and focus must survive updates; beyond that, a framework earns its weight.

Observing changes with MutationObserver Performance and memory in DOM code

Last refreshed 2026-09-18.