DOM Manipulation and Browser APIs Questions
Programming the browser platform directly, without a framework: the Document Object Model, node selection and traversal (live vs static collections), content and attribute mutation (innerHTML vs textContent, attributes vs properties, DocumentFragment, cloning), the event model (capture, target, bubble, delegation, preventDefault vs stopPropagation, passive and once listeners, custom events, pointer, touch and keyboard events, drag and drop), and web-platform APIs (IntersectionObserver, MutationObserver, requestAnimationFrame and requestIdleCallback, Web Storage and IndexedDB and choosing between them, postMessage, BroadcastChannel and cross-tab coordination, fetch error handling with AbortController, Shadow DOM and Web Components). Includes keyboard and screen-reader operable vanilla widgets (modals, dropdowns, accordions, date pickers, carousels, data grids) with focus management and route-change focus, the DOM-level mechanics of reflow and layout thrash, listener and detached-node memory leaks, and writing vanilla JavaScript to build interactive widgets. Ground covered elsewhere: the event loop and promise mechanics, profiling-driven page speed work, form-validation and component-state patterns, application-level security, and rebuilding standard APIs from scratch.
Given the following HTML:
<ul id="menu"> <li class="item" data-id="1">Home</li> <li class="item" data-id="2">About</li> <li class="item" data-id="3">Contact</li> </ul>Write three JavaScript statements that: (1) select the <ul> by id, (2) select all <li> elements as a NodeList, and (3) select the second <li> using a CSS selector. After the code, briefly explain when you would prefer querySelector/querySelectorAll over getElementById/getElementsByClassName in real code.
Sample Answer
Direct answer
const menu = document.getElementById('menu'); // (1) the <ul>
const items = document.querySelectorAll('#menu li'); // (2) every <li>, as a NodeList
const second = document.querySelector('#menu li:nth-child(2)'); // (3) the second <li> ("About")
querySelector and querySelectorAll take any CSS selector, so the same string you would write in a stylesheet (or in a Playwright or Selenium locator, the lookup call test-automation tools use to find an element) works. getElementById and getElementsByClassName take only a bare name. Prefer the querySelector pair when the question is about structure or attributes, one API covers everything, and the result is a static snapshot (a list that keeps the elements found at that moment and does not change when the page changes later). Keep getElementById for a plain id lookup, and use getElementsByClassName only when you want a live view (a collection the browser keeps up to date as matching elements are added or removed).
Run it
// select_three.mjs
import { chromium, firefox, webkit } from 'playwright';
import assert from 'node:assert/strict';
const html = `<ul id="menu">
<li class="item" data-id="1">Home</li>
<li class="item" data-id="2">About</li>
<li class="item" data-id="3">Contact</li>
</ul>`;
for (const [name, type] of Object.entries({ chromium, firefox, webkit })) {
const browser = await type.launch();
const page = await browser.newPage();
await page.setContent(html);
const r = await page.evaluate(() => {
const menu = document.getElementById('menu'); // (1) the <ul>
const items = document.querySelectorAll('#menu li'); // (2) all <li> as a NodeList
const second = document.querySelector('#menu li:nth-child(2)'); // (3) second <li>
return {
menu: menu.tagName, items: items.length, kind: items.constructor.name,
second: second.textContent, secondByData: document.querySelector('li[data-id="2"]').textContent,
missing: document.querySelector('.nope'), emptyList: document.querySelectorAll('.nope').length,
sameAsClassName: document.getElementsByClassName('item')[1] === second,
};
});
console.log(name, JSON.stringify(r));
assert.deepEqual([r.menu, r.items, r.kind, r.second], ['UL', 3, 'NodeList', 'About']);
assert.equal(r.missing, null);
assert.equal(r.emptyList, 0);
assert.ok(r.sameAsClassName);
await browser.close();
}
Output (Chromium; Firefox and WebKit print the same values):
chromium {"menu":"UL","items":3,"kind":"NodeList","second":"About","secondByData":"About","missing":null,"emptyList":0,"sameAsClassName":true}
menu is the UL; the NodeList has 3 entries; the second item's text is About. li[data-id="2"] selects the same element by attribute, and getElementsByClassName('item')[1] is the identical node (sameAsClassName: true).
Static versus live, and escaping an id
// static_vs_live.mjs
import { chromium, firefox, webkit } from 'playwright';
import assert from 'node:assert/strict';
const html = `<ul id="menu">
<li class="item" data-id="1">Home</li>
<li class="item" data-id="2">About</li>
<li class="item" data-id="3">Contact</li>
</ul>`;
for (const [name, type] of Object.entries({ chromium, firefox, webkit })) {
const browser = await type.launch();
const page = await browser.newPage();
await page.setContent(html);
const r = await page.evaluate(() => {
const snapshot = document.querySelectorAll('.item'); // static NodeList
const live = document.getElementsByClassName('item'); // live HTMLCollection
const before = [snapshot.length, live.length];
const li = document.createElement('li');
li.className = 'item';
document.getElementById('menu').append(li);
const after = [snapshot.length, live.length];
let bad = null;
try { document.querySelector('#1st'); } catch (e) { bad = e.name; }
const id = '1st';
return { before, after, bad, escaped: CSS.escape(id), fixed: document.querySelector('#' + CSS.escape(id)) };
});
console.log(name, JSON.stringify(r));
assert.deepEqual(r.before, [3, 3]);
assert.deepEqual(r.after, [3, 4]);
assert.equal(r.bad, 'SyntaxError');
assert.equal(r.escaped, '\\31 st');
assert.equal(r.fixed, null);
await browser.close();
}
Output (Chromium; Firefox and WebKit print the same values):
chromium {"before":[3,3],"after":[3,4],"bad":"SyntaxError","escaped":"\\31 st","fixed":null}
After one <li class="item"> is appended, the querySelectorAll NodeList still has 3 entries and the live getElementsByClassName collection has 4. The unescaped #1st throws a SyntaxError; the escaped selector parses and returns null because no element has that id. (The \\31 st in the JSON output is the 6-character string \31 st, backslash included.)
When to use which
| Need | Use | Why |
|---|---|---|
| Element with a known id | getElementById('menu') | Direct lookup, no selector syntax to get wrong; an id with odd characters needs no escaping |
Nested or attribute-based match (#menu li[data-id="2"], :nth-child(2)) | querySelector | getElementsBy* cannot express it |
| All matches, then loop and modify | querySelectorAll | Static NodeList with forEach, safe to mutate while looping |
| Zero matches | either querySelector (gives null) or querySelectorAll (gives an empty NodeList) | Check null before using the single result; the run shows missing: null, emptyList: 0 |
| A view that follows later DOM changes | getElementsByClassName | Live HTMLCollection |
Details that trip people
:nth-child(2)counts every child element position, regardless of tag or class; whitespace text between the<li>tags does not count.:nth-of-typecounts among siblings of the same tag.- A selector string that is invalid throws a
SyntaxError(MDN). A CSS identifier cannot start with a digit, sodocument.querySelector('#1st')throws;CSS.escape('1st')returns\31 st, which makes'#' + CSS.escape(id)a valid selector for any id, including one typed by a user. The section above runs both. NodeListhasforEachbut notmap; wrap withArray.fromwhen you need array methods.- Selecting by
data-attributes ([data-id="2"]) is more stable than by position when items can be reordered.
Running the code
mkdir demo && cd demo
echo '{"type":"module"}' > package.json # save the listings as select_three.mjs and static_vs_live.mjs here
docker run --rm -v "$PWD":/w -w /w mcr.microsoft.com/playwright:v1.48.2-jammy \
sh -c 'npm i playwright@1.48.2 && node select_three.mjs && node static_vs_live.mjs'
The image ships Chromium, Firefox and WebKit, so one run covers all three. The listing ends with assert calls, so a browser that disagrees makes the script exit with an error.
Walk through the ways to select elements in the DOM and what each returns. Which results are live and which are static, and how could that difference bite you in a loop?
Sample Answer
Direct answer
Every selection method answers "which elements match", but they differ in what they return and whether the result keeps updating. getElementById returns one element or null. querySelector returns the first match or null. getElementsByClassName, getElementsByTagName and the children property return a live HTMLCollection: it re-evaluates against the document every time you read it. querySelectorAll returns a static NodeList: a snapshot taken at the call. A snapshot is a fixed copy of the matches as they were at that instant; later changes to the page do not add to it or remove from it, whereas a live collection is a view that always reflects the page as it is now. The trap in a loop is that changing the DOM while walking a live collection changes the collection under you, so the loop skips items.
The methods and what they return
| Method | Returns | Live? | Typical use |
|---|---|---|---|
document.getElementById('x') | Element or null | n/a | Known unique id; document-level only |
el.querySelector(sel) | first matching Element or null | n/a | One element, any CSS selector |
el.querySelectorAll(sel) | NodeList | No, static | Many elements, then iterate or modify |
el.getElementsByClassName(c) | HTMLCollection | Yes | Rarely: when you want a view that tracks the page |
el.getElementsByTagName(t) | HTMLCollection | Yes | Same |
el.children | HTMLCollection of child elements | Yes | Walking direct children |
el.childNodes | NodeList of child nodes, text included | Yes (a NodeList can be live) | Walking text and comment nodes too |
A NodeList from querySelectorAll has forEach; an HTMLCollection does not, and neither has map or filter until you wrap it with Array.from(...). MDN documents querySelectorAll as returning "a static (not live) NodeList" and getElementsByClassName as a live HTMLCollection ("Be aware of this for iteration purposes"). An invalid selector string makes querySelector* throw a SyntaxError; the getElementsBy* methods take a plain name, not a selector.
Relative performance: choose by semantics rather than speed. An id lookup is a lookup by name, while a selector query has to test candidate elements against the selector, and querySelectorAll does that matching once, up front, for the whole result. For ordinary pages neither is worth micro-optimizing, and a live collection costs you correctness (the trap in the next section) before it saves you anything.
Worked example: the loop that skips items
The page has four <li class="item">. The script checks the return types, appends a fifth item, then removes the class item from every element in a loop, once over the live collection and once over a static snapshot. Two lines may look unfamiliar: .constructor.name reads the name of the class an object was built from, which is how the script prints HTMLCollection versus NodeList; and Object.assign(document.createElement('li'), { className: 'item', textContent: '5' }) creates a new <li> and copies those two properties onto it in one expression, so it has class item and text 5 before append puts it in the list.
// live_static.mjs
import { chromium, firefox, webkit } from 'playwright';
import assert from 'node:assert/strict';
const html = `<ul id="list"><li class="item">1</li><li class="item">2</li>
<li class="item">3</li><li class="item">4</li></ul>`;
for (const [name, type] of Object.entries({ chromium, firefox, webkit })) {
const browser = await type.launch();
const page = await browser.newPage();
await page.setContent(html);
const r = await page.evaluate(() => {
const kinds = {
byId: document.getElementById('list').constructor.name,
byClass: document.getElementsByClassName('item').constructor.name,
byTag: document.getElementsByTagName('li').constructor.name,
qs: document.querySelector('.item').constructor.name,
qsa: document.querySelectorAll('.item').constructor.name,
children: document.getElementById('list').children.constructor.name,
childNodes: document.getElementById('list').childNodes.constructor.name,
};
const list = document.getElementById('list');
const live = document.getElementsByClassName('item');
const snap = document.querySelectorAll('.item');
const before = [live.length, snap.length];
list.append(Object.assign(document.createElement('li'), { className: 'item', textContent: '5' }));
const after = [live.length, snap.length];
// The trap: removing the class while walking a live collection
for (let i = 0; i < live.length; i++) live[i].classList.remove('item');
const leftAfterLiveLoop = live.length;
// Same loop over a static snapshot
list.querySelectorAll('li').forEach((li) => li.classList.add('item'));
for (const el of document.querySelectorAll('.item')) el.classList.remove('item');
const leftAfterStaticLoop = document.getElementsByClassName('item').length;
return { kinds, before, after, leftAfterLiveLoop, leftAfterStaticLoop };
});
console.log(name, JSON.stringify(r));
assert.deepEqual(r.before, [4, 4]);
assert.deepEqual(r.after, [5, 4]);
assert.equal(r.leftAfterLiveLoop, 2); // 5 items, every other one skipped (3 removed)
assert.equal(r.leftAfterStaticLoop, 0);
await browser.close();
}
Output (Chromium; Firefox and WebKit print the same values):
chromium {"kinds":{"byId":"HTMLUListElement","byClass":"HTMLCollection","byTag":"HTMLCollection","qs":"HTMLLIElement","qsa":"NodeList","children":"HTMLCollection","childNodes":"NodeList"},"before":[4,4],"after":[5,4],"leftAfterLiveLoop":2,"leftAfterStaticLoop":0}
- After the append, the live collection reports 5 but the snapshot still holds the original 4 (
"before":[4,4],"after":[5,4]). - In the live loop, index
0loses its class and leaves the collection, so every later item shifts down one slot whileimoves up. With 5 items the loop touched positions holding items 1, 3 and 5 and left 2 and 4 behind:leftAfterLiveLoopis 2. - The loop over
querySelectorAllvisits all five:leftAfterStaticLoopis 0.
Fixes and where the gotcha bites
- Iterate a static source:
querySelectorAll, or copy first withArray.from(live)or[...live]. - Or walk a live collection backwards (
for (let i = live.length - 1; i >= 0; i--)) when you only remove: removing the item atishifts only items you have already passed. - Adding matching elements while looping a live collection grows the length your loop condition re-reads, so the loop's own end moves. Take a snapshot first.
NodeListis not always static:childNodesis live too. Do not infer "static" from the type name; read the method's documentation.- A snapshot is also the safer default in UI test and automation scripts, where the page may re-render between two reads.
Running the code
mkdir demo && cd demo
echo '{"type":"module"}' > package.json # save the listing as live_static.mjs here
docker run --rm -v "$PWD":/w -w /w mcr.microsoft.com/playwright:v1.48.2-jammy \
sh -c 'npm i playwright@1.48.2 && node live_static.mjs'
The image ships Chromium, Firefox and WebKit, so one run covers all three. The listing ends with assert calls, so a browser that disagrees makes the script exit with an error.
That is every published DOM Manipulation and Browser APIs question for Test Automation Engineer so far. Browse the other topics in this category, or practice this one interactively.