skip to content

Selection and Traversal APIs

You will learn the modern selection and walking APIs and how they differ in cost and return type. Interviewers use this to check whether you can navigate a real page without a library.

on this pageshow

questions

5

In the browser DOM, what is the difference between document.getElementById('user-menu') and document.querySelector('#user-menu'), and when does the selector form fail on an id that getElementById finds?

level: juniorimportance: must knowfreq 72%

answer

  1. two routes to one id
  2. one takes a string, one a selector
  3. only Document exposes the id method
  4. a legal id is not always a legal selector
  5. CSS.escape or the [id="…"] form

basics

~20 s

Both return the first matching element or null, but getElementById takes a raw id string and exists only on Document, while querySelector parses a CSS selector, so an id such as 3d-view needs CSS.escape or an attribute selector.

solid answer

~40 s

`document.getElementById('user-menu')` takes the id as a literal string, looks it up in the document's internal id map, and returns that element or `null`. `document.querySelector('#user-menu')` parses a **CSS selector**, walks the tree, and returns the first match in tree order or `null`. Two practical differences follow. First, `getElementById` lives only on `Document` and `DocumentFragment`, while `querySelector` is also on every `Element`, so you can scope it to a subtree. Second, an HTML id may be almost any non-empty string with no whitespace, but `#user-menu` is selector syntax: an id like `3d-view` or `a.b` is not a valid id selector, so `querySelector` throws a `SyntaxError` on a value `getElementById` finds happily. Escape it with `CSS.escape(id)`, or use the attribute form `[id="3d-view"]`.

code

javascript · 14 lines
javascript
const el = document.createElement('div');
el.id = '3d-view';
el.textContent = 'ok';
document.body.append(el);

console.log(document.getElementById('3d-view') === el);              // true
console.log(document.querySelector('#' + CSS.escape('3d-view')) === el); // true
console.log(document.querySelector('[id="3d-view"]') === el);        // true

try {
  document.querySelector('#3d-view');
} catch (err) {
  console.log(err.name); // "SyntaxError" — invalid id selector
}

go deeper

for a junior

Know that both calls return one element or null, that getElementById takes the bare id with no '#', and that reading a property off the null result is what throws.

for a middle

Be ready to explain that querySelector parses CSS selector syntax while getElementById does a direct string lookup, and to name CSS.escape or [id="…"] as the fix for ids that are not valid selectors.

for a senior

Show that you treat any selector built from runtime data as an escaping and throw-site problem, and that you scope lookups to a component root rather than reaching into the whole document.

for a principal

Own a house convention: where ids come from data, which lookups may cross a component boundary, and how a missing node surfaces — a silent null or a loud throw at mount time.

## Two different kinds of lookup The DOM offers several ways to reach one element, and the two id-based routes work by different mechanisms. `document.getElementById(id)` is a *direct lookup*. The `id` argument is treated as a plain string, not as syntax. The document maintains an internal mapping from id values to elements, so the call is effectively a hash lookup, and it returns the matching `Element` or `null` when nothing matches. It never throws for a "weird" id, because there is nothing to parse. `document.querySelector(selectors)` is a *selector match*. The argument is a CSS selector list; the browser parses it, then finds the first element in tree order that matches, returning that `Element` or `null`. `#user-menu` is an id selector, which is only one of the things this method can express — `.card > a[href^="/docs"]` is equally valid. ```js document.getElementById('user-menu'); // Element | null document.querySelector('#user-menu'); // Element | null document.querySelector('nav a.active'); // any selector at all ``` ## Where each method lives `getElementById` is defined on `Document` and on `DocumentFragment` (which is why it also works on a shadow root). It is **not** on `Element`. There is no `container.getElementById('x')` — that call throws a `TypeError` because the method does not exist there. `querySelector` and `querySelectorAll` are defined on `Document`, `DocumentFragment`, **and** `Element`. Calling them on an element restricts the returned elements to that element's descendants, which is the usual way to scope a lookup inside a widget: ```js const panel = document.getElementById('settings'); const save = panel.querySelector('button[type="submit"]'); ``` ## The escaping trap HTML says an id must be at least one character long and must contain no ASCII whitespace. Everything else is legal: `3d-view`, `a.b`, `item:1`, `--x`. CSS is stricter. In a selector, an identifier may not start with a digit, and `.` and `:` are meaningful characters, so: ```js const el = document.createElement('div'); el.id = '3d-view'; document.body.append(el); document.getElementById('3d-view'); // the element document.querySelector('#3d-view'); // SyntaxError document.querySelector('#' + CSS.escape('3d-view')); // the element document.querySelector('[id="3d-view"]'); // the element ``` This matters most when the id comes from data — a database key, a slug, a generated value. The moment an id can start with a digit or contain punctuation, string-concatenating it into a selector is a bug waiting for the right record. `CSS.escape(value)` exists exactly for this and is the correct fix; the attribute selector `[id="..."]` is a readable alternative (its value is a quoted string, so only quotes and backslashes need escaping). Remember also that `querySelector` throws `SyntaxError` for *any* malformed selector, not just ids. If a selector is built from user or config input, that throw is part of your error surface. ## Duplicate and missing ids Ids are supposed to be unique in a document, but nothing enforces it at runtime. If two elements share an id, both routes return the **first one in tree order**, and the second is unreachable through either. `document.querySelectorAll('[id="dup"]')` is a quick way to find the duplicates when debugging. When nothing matches, both return `null` — not `undefined`, not an empty collection, and no exception. This is why `document.getElementById('nope').textContent` throws a `TypeError` about reading a property of `null`, one of the most common first-week errors in DOM code. Guard it, or use optional chaining when absence is genuinely acceptable. ## Which one should you use? For a known, hard-coded, well-formed id, both are fine and the choice is style. Modern engines special-case a lone id selector, so the performance argument that used to favour `getElementById` is not something you will measure in real code — pick on clarity, not on micro-benchmarks. Use `getElementById` when the id is a runtime value, because it sidesteps escaping entirely. Use `querySelector` when the lookup is naturally scoped to a subtree, when the condition is more than an id, or when you want one consistent selection API across your code. What you should not do is mix them up: passing a selector such as `'#user-menu'` to `getElementById` looks for an element whose id is literally `#user-menu`, and quietly returns `null`.

  • Can you scope an id lookup to a subtree the way you scope querySelector?
    Not with `getElementById` — it is defined on `Document` and `DocumentFragment` only, so `element.getElementById` is not a function. Scope it with `subtree.querySelector('#' + CSS.escape(id))`, or do the document-level lookup and then verify placement with `subtree.contains(found)`. A shadow root is a `DocumentFragment`, so `shadowRoot.getElementById('x')` does work.
  • What do getElementById and querySelector return when two elements share the same id?
    Both return the first one in tree order; the duplicate is unreachable through either call. Duplicate ids are invalid HTML but nothing throws at runtime, so the symptom is usually "my handler is attached to the wrong element". `document.querySelectorAll('[id="dup"]')` lists every offender, which makes it a useful debugging one-liner.
  • What happens if you pass a malformed selector to querySelector?
    It throws a `SyntaxError` immediately — it does not return `null`. That applies to any malformed selector, not just ids, so any selector assembled from runtime values is a potential throw site. Escape interpolated values with `CSS.escape()`, or prefer an API that takes a raw string, such as `getElementById`.

saying these in an interview costs you the question

  • Says getElementById accepts a CSS selector like '#id'
  • Claims querySelector returns a list of all matches
  • Thinks a missing element makes these calls throw
  • Assumes any legal HTML id is a legal id selector
  • Believes element.getElementById scopes a lookup to a subtree

context

open as a page

In the browser DOM, what does Element.closest('.row') return, and how does it differ from Element.matches('.row')?

level: middleimportance: must knowfreq 58%

basics

~10 s

Element.closest('.row') walks up from the element itself through its ancestors and returns the nearest element matching the selector, or null. Element.matches('.row') only tests that one element and returns a boolean.

open as a page

When you call widget.querySelectorAll('.card .title') on a DOM element, which elements can the '.card' part of that selector match, and what does the :scope pseudo-class change?

level: middleimportance: should knowfreq 44%

basics

~20 s

The selector is matched against the whole tree, and only the results are filtered to descendants of the element you called it on, so '.card' may match an ancestor outside the widget. The :scope pseudo-class anchors the selector to that element.

open as a page

A dropdown closes itself with a document-level check `if (!panel.contains(clickedNode)) close()`, and it wrongly closes when the user clicks a control inside the panel that re-renders its contents. Why can panel.contains(clickedNode) be false for a node that was inside the panel, and how do you make the containment check reliable?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Node.contains() reports the tree as it is at call time. If the clicked node was removed from the DOM by an earlier re-render, it is now detached, so contains() returns false. Decide containment while the node is still connected, or check isConnected first.

open as a page

How do you decide which selectors a shared widget's JavaScript uses to find its own DOM nodes, so that a CSS or markup refactor cannot silently break its behaviour?

level: principalimportance: nice to knowfreq 28%

basics

~20 s

Give behaviour its own stable hooks — dedicated data-* attributes rather than styling class names — scope every query to the widget root, avoid structural selectors, and fail loudly when a required node is missing instead of silently doing nothing.

open as a page