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.
Design a drag-and-drop list that someone can also operate by keyboard and with a screen reader. How do you handle keyboard reordering, focus, and announcements alongside pointer and touch input?
Sample Answer
Direct answer
Make the drag a second input method on top of a list that already works from the keyboard. Each row gets a real <button> handle. Pressing Space on it picks the row up, Up and Down arrows move it, Space drops it and Escape cancels. Every state change is spoken through an aria-live region (a container whose text changes are announced by screen readers without moving focus), the picked-up state is exposed as aria-pressed (an attribute that makes a button a toggle with an on/off state a screen reader reports) on the handle, and focus is put back on the handle after every DOM move because moving a focused node loses focus in all three engines tested below. Pointer, touch and keyboard all call the same pickUp, placeAt and release functions, so they cannot drift apart.
Design
| Concern | Choice | Reason |
|---|---|---|
| Control to focus | A <button class="handle"> per row, accessible name "Reorder Plan" | A native button is focusable and in the tab order with no extra ARIA; the name says what it does and which item |
| Instructions | A visible paragraph, linked with aria-describedby on every handle | aria-describedby points at the element holding the instructions; its text becomes the handle's accessible description (the extra help a screen reader reads after the control's accessible name, which is the label it announces, here "Reorder Plan"); sighted users see the same text |
| Picked-up state | aria-pressed="true" on the handle plus a visible outline | Toggle state the screen reader reports. MDN marks aria-grabbed, the older drag attribute, as deprecated, so it is not used |
| Moving | Arrow keys while picked up, preventDefault() so the page does not scroll | One step per press; each press announces the new position |
| Drop and cancel | Space or Enter drops, Escape restores the original position, Tab away cancels | A reorder can never be left half done when focus leaves |
| Announcements | One visually hidden <div aria-live="polite" aria-atomic="true"> (aria-atomic="true" makes the reader speak the whole region text each time, not only the changed part) that exists, empty, in the markup from page load | MDN shows aria-live set on an empty element that is later updated with a brief announcement, and separately notes that screen readers buffer content at page load, so content added after the initial accessibility tree is built may not be noticed (MDN does not state that the region itself must be in the markup at load; starting from an element already present and only changing its text is the conservative way to follow that advice); polite waits for the next graceful opportunity instead of interrupting, whereas assertive can potentially clear the speech queue |
| Focus after a move | Re-call handle.focus() if the move dropped it | See the measurement below |
| Pointer and touch | Pointer events on the same handle, touch-action: none on the handle only, setPointerCapture | One code path for mouse, pen and finger; the rest of the row still scrolls the page |
The W3C ARIA Authoring Practices Guide (APG, the W3C's catalogue of accessible widget patterns) offers a rearrangeable listbox example (a listbox is a single-select or multi-select list of options, role="listbox") that solves the same problem with a different input design: Up and Down buttons, with the shortcuts Alt + Up Arrow and Alt + Down Arrow, move the focused option, focus stays on the moved option, and live regions confirm completed actions. That is the right choice when the list is a listbox. The picked-up mode above is the choice when a pointer drag and a keyboard move should mean the same thing (pick up, move, drop, cancel).
The widget and its checks
The harness builds the widget, drives it with the real keyboard and mouse (plus one synthetic pointercancel) in Chromium, Firefox and WebKit, and fails the process if any check fails:
The state the script keeps is one variable, grab: null when nothing is picked up, otherwise { li, handle, from } (the row, its handle and the position it started from, which Escape restores). While the script itself moves a row, grab.moving is true. Moving a focused node with insertBefore can make the browser fire focusout on the handle, and the focusout listener cancels the pick-up when the user tabs away; without the moving flag it would cancel the pick-up in the middle of the script's own move. hadFocus records whether the handle owned focus before the move so that focus is restored only when it was lost.
import { chromium, firefox, webkit } from 'playwright';
const page_html = `<!doctype html><meta charset=utf-8>
<style>
body{font:16px sans-serif} ul{list-style:none;margin:0;padding:0;width:260px}
li{display:flex;gap:8px;align-items:center;height:44px;border-bottom:1px solid #999;background:#fff}
.handle{min-width:44px;min-height:44px;touch-action:none;cursor:grab}
.handle[aria-pressed=true]{outline:3px solid #06c}
.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
</style>
<p id=help>Press Space to pick up, Up and Down arrows to move, Space to drop, Escape to cancel.</p>
<ul id=tasks aria-label="Release tasks">
<li><span>Plan</span><button class=handle aria-describedby=help aria-pressed=false>Reorder Plan</button></li>
<li><span>Code</span><button class=handle aria-describedby=help aria-pressed=false>Reorder Code</button></li>
<li><span>Test</span><button class=handle aria-describedby=help aria-pressed=false>Reorder Test</button></li>
<li><span>Ship</span><button class=handle aria-describedby=help aria-pressed=false>Reorder Ship</button></li>
</ul>
<div id=live class=sr-only aria-live=polite aria-atomic=true></div>
<script>
const list = document.getElementById('tasks'), live = document.getElementById('live');
window.said = []; window.focusLostOnMove = 0;
const say = msg => { live.textContent = msg; said.push(msg); };
const rows = () => [...list.children];
const name = li => li.firstElementChild.textContent;
const pos = li => rows().indexOf(li) + 1;
let grab = null; // { li, handle, from } while an item is picked up
function pickUp(handle) {
const li = handle.parentElement;
grab = { li, handle, from: pos(li) };
handle.setAttribute('aria-pressed', 'true');
say('Picked up ' + name(li) + ', position ' + pos(li) + ' of ' + rows().length + '.');
}
function release(announce) {
grab.handle.setAttribute('aria-pressed', 'false');
if (announce) say('Dropped ' + name(grab.li) + ' at position ' + pos(grab.li) + ' of ' + rows().length + '.');
grab = null;
}
function placeAt(li, index) { // index is 0-based; one DOM move, then put focus back if the move lost it
const ref = rows().filter(r => r !== li)[index] || null;
const handle = li.querySelector('.handle'), hadFocus = document.activeElement === handle;
grab.moving = true;
list.insertBefore(li, ref);
if (hadFocus && document.activeElement !== handle) { focusLostOnMove++; handle.focus(); }
grab.moving = false;
}
list.addEventListener('keydown', e => {
const handle = e.target.closest('.handle'); if (!handle) return;
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
grab ? release(true) : pickUp(handle);
} else if (grab && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
e.preventDefault(); // keep the page from scrolling
const to = pos(grab.li) - 1 + (e.key === 'ArrowUp' ? -1 : 1);
if (to < 0 || to >= rows().length) return say(name(grab.li) + ' is already ' + (to < 0 ? 'first' : 'last') + '.');
placeAt(grab.li, to);
say(name(grab.li) + ', position ' + pos(grab.li) + ' of ' + rows().length + '.');
} else if (grab && e.key === 'Escape') {
placeAt(grab.li, grab.from - 1);
const li = grab.li; release(false);
say('Cancelled. ' + name(li) + ' is back at position ' + pos(li) + '.');
}
});
list.addEventListener('focusout', e => { // tabbing away drops the pick-up instead of leaving it dangling
if (grab && !grab.moving && e.target === grab.handle) {
const li = grab.li; placeAt(li, grab.from - 1); release(false);
say('Cancelled. ' + name(li) + ' is back at position ' + pos(li) + '.');
}
});
// Pointer path: same helpers, so mouse, pen and finger behave like the keyboard.
list.addEventListener('pointerdown', e => {
const handle = e.target.closest('.handle'); if (!handle || grab) return;
handle.setPointerCapture(e.pointerId); pickUp(handle);
});
list.addEventListener('pointermove', e => {
if (!grab || !(e.buttons || e.pointerType === 'touch')) return;
const over = document.elementFromPoint(e.clientX, e.clientY)?.closest('li');
if (over && over !== grab.li && list.contains(over)) { const to = rows().indexOf(over); placeAt(grab.li, to); say(name(grab.li) + ', position ' + pos(grab.li) + ' of ' + rows().length + '.'); }
});
list.addEventListener('pointerup', () => { if (grab) release(true); });
list.addEventListener('pointercancel', () => { if (grab) { placeAt(grab.li, grab.from - 1); release(false); say('Cancelled.'); } });
</script>`;
let failures = 0;
const check = (label, ok) => { if (!ok) failures++; console.log(` ${ok ? 'PASS' : 'FAIL'} ${label}`); };
for (const [name, type] of [['chromium', chromium], ['firefox', firefox], ['webkit', webkit]]) {
console.log(name);
const browser = await type.launch();
const page = await browser.newPage();
await page.setContent(page_html);
const order = () => page.evaluate(() => [...document.querySelectorAll('#tasks li > span')].map(s => s.textContent).join(' '));
const said = () => page.evaluate(() => said.slice());
const focused = () => page.evaluate(() => document.activeElement.textContent);
await page.focus('li:nth-child(1) .handle');
await page.keyboard.press('Space');
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowDown');
await page.keyboard.press('Space');
check('keyboard: Plan moved down twice -> "Code Test Plan Ship"', await order() === 'Code Test Plan Ship');
check('keyboard: focus is still on the Plan handle', await focused() === 'Reorder Plan');
console.log(' announcements:', JSON.stringify(await said()));
console.log(' moves that lost focus without the refocus line:', await page.evaluate(() => focusLostOnMove));
await page.keyboard.press('Space'); // pick up Plan (position 3)
await page.keyboard.press('ArrowUp');
await page.keyboard.press('Escape');
check('Escape restores the original position', await order() === 'Code Test Plan Ship');
check('Escape announces the cancel', (await said()).at(-1) === 'Cancelled. Plan is back at position 3.');
await page.keyboard.press('Space');
await page.keyboard.press('Tab');
check('Tab while picked up cancels', (await said()).at(-1).startsWith('Cancelled.') && await page.evaluate(() => document.querySelector('[aria-pressed=true]') === null));
await page.focus('li:nth-child(1) .handle');
await page.keyboard.press('Space');
await page.keyboard.press('ArrowUp');
check('top edge: no move, says "already first"', (await said()).at(-1) === 'Code is already first.' && await order() === 'Code Test Plan Ship');
await page.keyboard.press('Space');
const rowY = async n => { const b = await page.locator('#tasks li').nth(n - 1).boundingBox(); return b.y + b.height / 2; };
const handle = await page.locator('#tasks li').nth(3).locator('.handle').boundingBox();
const x = handle.x + handle.width / 2;
const [y4, y2, y1] = [await rowY(4), await rowY(2), await rowY(1)]; // row centres, measured before the drag
await page.mouse.move(x, y4);
await page.mouse.down();
await page.mouse.move(x, y2, { steps: 6 });
await page.mouse.move(x, y1, { steps: 6 });
await page.mouse.up();
check('mouse: Ship dragged to the top -> "Ship Code Test Plan"', await order() === 'Ship Code Test Plan');
check('mouse: drop is announced', (await said()).at(-1) === 'Dropped Ship at position 1 of 4.');
// pointercancel (the browser takes the gesture away, e.g. a touch claimed for scrolling) must restore the order
const before = await order();
const planY = await rowY(4);
await page.mouse.move(x, planY);
await page.mouse.down();
await page.mouse.move(x, y2, { steps: 6 });
const moved = await order();
await page.evaluate(() => document.getElementById('tasks').dispatchEvent(new PointerEvent('pointercancel', { bubbles: true })));
check('pointercancel: drag really moved Plan first', moved !== before);
check('pointercancel restores the order and announces it', await order() === before && (await said()).at(-1) === 'Cancelled.' && await page.evaluate(() => document.querySelector('[aria-pressed=true]') === null));
await page.mouse.up();
await browser.close();
}
console.log(failures === 0 ? 'ALL CHECKS PASSED' : failures + ' CHECK(S) FAILED');
process.exit(failures ? 1 : 0);
A full keyboard session from the first check, key by key (four rows, Plan first):
| Key | State after | Announcement |
|---|---|---|
| Space | grab set, aria-pressed=true | "Picked up Plan, position 1 of 4." |
| ArrowDown | Plan below Code, order Code Plan Test Ship, refocused | "Plan, position 2 of 4." |
| ArrowDown | order Code Test Plan Ship, refocused | "Plan, position 3 of 4." |
| Space | grab cleared, aria-pressed=false | "Dropped Plan at position 3 of 4." |
The other exits from the picked-up state are Escape (restore the start position, announce "Cancelled. Plan is back at position 3.") and Tab (the handle loses focus, focusout does the same restore).
Printed output (the same on 10 consecutive runs):
chromium
PASS keyboard: Plan moved down twice -> "Code Test Plan Ship"
PASS keyboard: focus is still on the Plan handle
announcements: ["Picked up Plan, position 1 of 4.","Plan, position 2 of 4.","Plan, position 3 of 4.","Dropped Plan at position 3 of 4."]
moves that lost focus without the refocus line: 2
PASS Escape restores the original position
PASS Escape announces the cancel
PASS Tab while picked up cancels
PASS top edge: no move, says "already first"
PASS mouse: Ship dragged to the top -> "Ship Code Test Plan"
PASS mouse: drop is announced
PASS pointercancel: drag really moved Plan first
PASS pointercancel restores the order and announces it
firefox
PASS keyboard: Plan moved down twice -> "Code Test Plan Ship"
PASS keyboard: focus is still on the Plan handle
announcements: ["Picked up Plan, position 1 of 4.","Plan, position 2 of 4.","Plan, position 3 of 4.","Dropped Plan at position 3 of 4."]
moves that lost focus without the refocus line: 2
PASS Escape restores the original position
PASS Escape announces the cancel
PASS Tab while picked up cancels
PASS top edge: no move, says "already first"
PASS mouse: Ship dragged to the top -> "Ship Code Test Plan"
PASS mouse: drop is announced
PASS pointercancel: drag really moved Plan first
PASS pointercancel restores the order and announces it
webkit
PASS keyboard: Plan moved down twice -> "Code Test Plan Ship"
PASS keyboard: focus is still on the Plan handle
announcements: ["Picked up Plan, position 1 of 4.","Plan, position 2 of 4.","Plan, position 3 of 4.","Dropped Plan at position 3 of 4."]
moves that lost focus without the refocus line: 2
PASS Escape restores the original position
PASS Escape announces the cancel
PASS Tab while picked up cancels
PASS top edge: no move, says "already first"
PASS mouse: Ship dragged to the top -> "Ship Code Test Plan"
PASS mouse: drop is announced
PASS pointercancel: drag really moved Plan first
PASS pointercancel restores the order and announces it
ALL CHECKS PASSED
What the run shows:
- Keyboard order, announcements and cancel behaviour are identical in all three engines.
- The line "moves that lost focus without the refocus line: 2" is the important measurement. Both arrow presses moved a focused node with
insertBefore, and in all three enginesdocument.activeElementwas no longer the handle after the move. Without the singlehandle.focus()call, the next arrow press would go to the page and a screen reader user would hear nothing. A keyboard path that is never run with focus checks would miss this. - The announcement list is the text a live region would receive. Whether a particular screen reader speaks it, and in what wording, depends on the screen reader and browser pair and is not something this harness can establish. Check the live region with NVDA + Firefox, JAWS + Chrome and VoiceOver + Safari before shipping.
Pointer and touch alongside the keyboard
- Tab order is the sequence in which Tab visits focusable elements; because each handle is a native button, the four handles are four Tab stops in document order, and no
tabindexis needed. - The handle has
touch-action: none, so a finger that starts on it is delivered as pointer events instead of being claimed for page scroll. The rest of the row keepstouch-action: auto. - The pointer path announces too: picking up and every row it passes, and the final drop. Sighted pointer users get the visual; screen reader users who use a pointing device or touch exploration (the mode where a finger sliding over the screen makes the screen reader read what is under it) get the text.
pointercancel(the browser taking the gesture away, for example a touch claimed for scrolling) restores the original order, mirroring Escape. The harness drives a real mouse and sends a syntheticpointercancelevent for that check; it does not drive a physical touchscreen.- Do not rely on pointer gestures as the only way to reorder for screen reader users on a phone, who have no arrow keys. Add "Move up" and "Move down" buttons per row (APG's rearrangeable listbox uses buttons for this) and announce the same position text after each press.
Trade-offs and pitfalls
- Reordering by swapping the DOM node and refocusing is simple and robust for tens of rows. For hundreds, keep the keyboard model but add Home and End (move to top or bottom) so nobody presses an arrow 300 times.
- A live region that announces every pointer-passed row can be chatty. If it is, announce only on drop for pointer drags and keep per-step announcements for the keyboard.
- Hiding the instructions in a tooltip makes them invisible to keyboard users; keep them on the page.
- Avoid
aria-live="assertive"here: it can cut off the screen reader mid-word on each arrow press. - Colour alone must not mark the picked-up row. The harness sets
aria-pressed, and the outline is a second cue.
Running the code
mkdir demo && cd demo && echo '{"type":"module"}' > package.json
npm i playwright@1.48.2
# save the listing above as sortable-a11y.mjs
docker run --rm -v "$PWD":/w -w /w mcr.microsoft.com/playwright:v1.48.2-jammy node sortable-a11y.mjs
Exit code 0 means every check passed. The container has all three browser engines preinstalled.
You need to insert a block of markup into a page from a string. Compare building it with createElement and appendChild, insertAdjacentHTML, and assigning innerHTML. What differs in parsing cost, script handling, preserved listeners, and safety, and when would you choose each?
Sample Answer
Direct answer
All three put new nodes into the page, but they differ in what they do to the nodes already there. createElement plus appendChild builds nodes directly through the DOM API with no HTML parser involved, so values stay data and cannot become markup. insertAdjacentHTML(position, string) parses the string and inserts only the result, leaving existing siblings untouched. innerHTML = string parses the string and replaces every child of the element, so existing children, and any listeners attached to them, are discarded. Use createElement (or a <template> clone) for anything containing dynamic data, insertAdjacentHTML to append a trusted static fragment next to existing content, and innerHTML only to replace a container's whole content with trusted markup.
Comparison
createElement + appendChild | insertAdjacentHTML | innerHTML = | |
|---|---|---|---|
| Parsing | None. Each element, attribute and text node is a separate DOM call | HTML fragment parser (the browser's parser for a piece of markup rather than a whole page) runs over the new string only | Parser runs over the new string; old children are torn down first |
| Existing children | Untouched | Untouched (MDN: it "does not corrupt the existing elements") | Replaced. innerHTML += re-serializes and re-parses all existing content too |
| Listeners on existing children | Kept | Kept | Lost: the old nodes are gone and the new copies have no listeners |
<script> in the string | A script element made with createElement does run when inserted | Parsed script does not run | Parsed script does not run (MDN) |
Handler attributes (onerror=) in the string | Nothing to run from text: textContent never creates elements, so markup characters stay literal text. A handler attribute set with setAttribute('onclick', value) does run, so keep attribute names fixed in your code | Run | Run |
| Safety with untrusted data | Safe when untrusted values go only through textContent and into harmless attributes such as title or data-*. Not safe if user data picks an attribute name (setAttribute('onclick', value) makes the value run as code) or a URL (href or src set to a javascript: URL can run code) | Injection sink, meaning a property where a string becomes live markup (MDN warns it is an XSS vector; XSS, cross-site scripting, is an attacker getting their script to run inside your page) | Injection sink |
| Position control | append, before, insertBefore, ... | Four positions: beforebegin, afterbegin, beforeend, afterend | Whole content only |
Parsing cost: the parser path hands the whole string to the browser's HTML parser in one call, and its work grows with the length of the string. The createElement path skips the parser but costs one JavaScript-to-DOM call per node and attribute, which adds up for a big static block. For a handful of nodes neither is worth optimizing, so choose on safety and on what happens to existing children. For big repeated structures, parse once into a <template> (an HTML element whose contents are stored inert, not rendered or run) and call template.content.cloneNode(true) per item to get a fresh copy of that content.
Run: what survives, what runs
The host div holds one button with a click listener. The script inserts a string containing a button, a broken <img onerror> and a <script>, using insertAdjacentHTML, then does innerHTML += ..., then builds nodes with createElement.
// insertion.mjs
import { chromium, firefox, webkit } from 'playwright';
import assert from 'node:assert/strict';
const html = `<div id="host"><button id="old">old</button></div>`;
const markup = '<button class="new">new</button><img src="x:bad" onerror="window.pwned++">' +
'<script>window.ranScript++<\/script>';
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((markup) => {
window.pwned = 0; window.ranScript = 0; window.oldClicks = 0;
const host = document.getElementById('host');
const oldBtn = document.getElementById('old');
oldBtn.addEventListener('click', () => window.oldClicks++);
const out = {};
// 1. insertAdjacentHTML: parses only the new string, existing children untouched
host.insertAdjacentHTML('beforeend', markup);
out.adjacentSameOldNode = document.getElementById('old') === oldBtn;
document.getElementById('old').click();
out.adjacentOldListener = window.oldClicks;
// 2. innerHTML +=: serializes and re-parses everything, old nodes are replaced
host.innerHTML += '<i></i>';
out.innerHtmlSameOldNode = document.getElementById('old') === oldBtn;
document.getElementById('old').click();
out.innerHtmlOldListener = window.oldClicks; // unchanged: the new button has no listener
// 3. createElement + appendChild: no parser, text is text, a created script does run
const b = document.createElement('button');
b.textContent = '<img src=x onerror=alert(1)>';
host.appendChild(b);
out.createElementTextIsLiteral = b.children.length === 0;
const s = document.createElement('script');
s.textContent = 'window.ranScript += 10';
host.appendChild(s);
out.createdScriptRan = window.ranScript >= 10;
return out;
}, markup);
await page.waitForFunction(() => window.pwned >= 1); // onerror of a broken image is asynchronous
const flags = await page.evaluate(() => ({ pwned: window.pwned, ranScript: window.ranScript }));
console.log(name, JSON.stringify({ ...r, ...flags }));
assert.equal(r.adjacentSameOldNode, true);
assert.equal(r.adjacentOldListener, 1);
assert.equal(r.innerHtmlSameOldNode, false);
assert.equal(r.innerHtmlOldListener, 1);
assert.equal(r.createElementTextIsLiteral, true);
assert.equal(flags.ranScript, 10); // only the createElement script ran; the parsed one did not
assert.ok(flags.pwned >= 1); // the markup string still ran code through the onerror attribute
await browser.close();
}
Output (Chromium; Firefox and WebKit print the same values):
chromium {"adjacentSameOldNode":true,"adjacentOldListener":1,"innerHtmlSameOldNode":false,"innerHtmlOldListener":1,"createElementTextIsLiteral":true,"createdScriptRan":true,"pwned":2,"ranScript":10}
adjacentSameOldNode: trueandadjacentOldListener: 1: afterinsertAdjacentHTML, the original button is the same node and its click listener still fires once.innerHtmlSameOldNode: falseandinnerHtmlOldListener: 1: afterinnerHTML +=the element with idoldis a different node. Clicking it adds nothing to the count because the new copy has no listener (the count stays at 1).createElementTextIsLiteral: true: text with markup characters assigned throughtextContentcreates zero child elements.ranScript: 10: only the script made bycreateElementexecuted (it added 10). The scripts that arrived inside strings did not run. Theonerrorhandler did run (pwnedis 2: one broken image from theinsertAdjacentHTMLstring and a second created wheninnerHTML +=re-parsed the content), so blocking parsed scripts is not a security boundary. Thesrc="x:bad"image is the standard way to show this: it cannot load, the browser fires itserrorevent, and theonerrorattribute runs, with no<script>tag involved.
Choosing
- User or server data in the content:
createElementandtextContent(orsetAttributefor harmless attributes such astitleordata-*), or a template clone with values filled in as text. No HTML escaping to forget for text; for URLs allow onlyhttp:andhttps:, and never take an attribute name from user data. - Trusted static block appended to a live container:
insertAdjacentHTML('beforeend', html). It preserves siblings and avoids+=. - Replace everything with trusted markup:
innerHTML = html, accepting that you lose child listeners (delegate from the container to avoid that). - If markup must come from untrusted sources, sanitize it (strip everything outside an allowed list of tags and attributes with a maintained library), and let a Content Security Policy (a response header that limits which scripts and resources the page may run) with Trusted Types (a browser feature that makes these sinks reject plain strings unless they pass through a policy you wrote) enforce that raw strings never reach these sinks, as MDN recommends.
Running the code
mkdir demo && cd demo
echo '{"type":"module"}' > package.json # save the listing as insertion.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 insertion.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.
What do the capture, once, and passive options of addEventListener change, and where would each earn its keep in a real page?
Sample Answer
Direct answer
The third argument of addEventListener can be an options object, and these three options each change one thing. capture: true moves the listener from the bubble phase to the capture phase, so it runs on the way down, before listeners on elements beneath it. once: true makes the browser remove the listener automatically after its first call. passive: true is a promise that the listener will never call preventDefault(), which lets the browser start the default action (scrolling, for a touch or wheel event) without waiting for the listener to finish. (The default action is what the browser does with the event if nothing cancels it.) Capture earns its keep for intercepting or for events that do not bubble, once for one-shot work, and passive for scroll, wheel and touch listeners that only observe.
What each option changes
An event travels down from the window to the target (capture phase), runs listeners on the target, then travels back up (bubble phase). Listeners are registered for the bubble phase unless capture is true. The table lists what the option does and a place where it is the right tool.
| Option | What changes | Where it earns its keep |
|---|---|---|
capture: true | Runs before listeners on descendants; also sees events that do not bubble | A container that must see a click before any child can stop it (analytics, a "block all clicks while saving" guard), or a container-level focus listener |
once: true | Listener is removed automatically after its first invocation | First-interaction setup, a one-time transitionend cleanup (transitionend is the event a CSS transition fires when it finishes), waiting for the next outside click to close a menu |
passive: true | preventDefault() becomes a no-op; the browser can scroll immediately | touchstart, touchmove and wheel listeners that track position or swipe direction without cancelling the scroll |
signal | abort() on the signal's AbortController removes the listener. An AbortController is a built-in object whose .signal is a token you hand to addEventListener; calling abort() on the controller removes every listener registered with that signal | A component that adds several listeners and must remove them all when it is torn down (removed from the page) |
Measured behaviour
The harness runs each option in a real page, in Chromium, Firefox and WebKit (Playwright's WebKit is the engine, not Safari). passive is checked by dispatching a cancelable touchstart (an event whose default action may be vetoed by preventDefault()) and calling preventDefault() inside the listener, then reading event.defaultPrevented. Each numbered block in the script fills one or two keys of the printed JSON: block 1 gives order, block 2 onceCount, block 3 defaultPreventedWhenPassive and defaultPreventedWhenNotPassive, block 4 docWheelDefaultPrevented, block 5 removedOnlyWithMatchingCapture and signal, and block 6 focusReachedAncestor.
// listener_options.mjs
import { chromium, firefox, webkit } from 'playwright';
const html = `<div id="outer"><button id="inner">go</button></div>`;
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 out = await page.evaluate(() => {
const outer = document.getElementById('outer');
const inner = document.getElementById('inner');
const r = {};
// 1. capture: the order of a click on the inner button
const order = [];
outer.addEventListener('click', () => order.push('outer-bubble'));
outer.addEventListener('click', () => order.push('outer-capture'), { capture: true });
inner.addEventListener('click', () => order.push('inner-target'));
inner.click();
r.order = order.join(' > ');
// 2. once: fires one time, then the browser removes the listener
let onceCount = 0;
inner.addEventListener('click', () => { onceCount++; }, { once: true });
inner.click(); inner.click(); inner.click();
r.onceCount = onceCount;
// 3. passive: preventDefault() is ignored; the event stays uncancelled
const probe = (passive) => {
const el = document.createElement('div');
let prevented;
el.addEventListener('touchstart', (e) => { e.preventDefault(); prevented = e.defaultPrevented; }, { passive });
el.dispatchEvent(new Event('touchstart', { cancelable: true }));
return prevented;
};
r.defaultPreventedWhenPassive = probe(true);
r.defaultPreventedWhenNotPassive = probe(false);
// 4. the default for wheel on document-level targets
let docWheelPrevented;
document.addEventListener('wheel', (e) => { e.preventDefault(); docWheelPrevented = e.defaultPrevented; });
document.body.dispatchEvent(new WheelEvent('wheel', { cancelable: true, bubbles: true }));
r.docWheelDefaultPrevented = docWheelPrevented;
// 5. removal: capture must match; a signal removes everything at once
let hits = 0;
const fn = () => hits++;
inner.addEventListener('click', fn, { capture: true });
inner.removeEventListener('click', fn); // capture flag differs: nothing removed
inner.click(); const afterWrongRemove = hits;
inner.removeEventListener('click', fn, { capture: true });
inner.click(); r.removedOnlyWithMatchingCapture = [afterWrongRemove, hits - afterWrongRemove].join(',');
const ac = new AbortController();
let sig = 0;
inner.addEventListener('click', () => sig++, { signal: ac.signal });
outer.addEventListener('click', () => sig++, { signal: ac.signal, capture: true });
inner.click(); const before = sig;
ac.abort();
inner.click(); r.signal = [before, sig - before].join(',');
// 6. focus does not bubble, but a capture listener on an ancestor still sees it
const focusSeen = [];
outer.addEventListener('focus', () => focusSeen.push('bubble'));
outer.addEventListener('focus', () => focusSeen.push('capture'), true);
inner.focus();
r.focusReachedAncestor = focusSeen.join(',');
return r;
});
console.log(name, JSON.stringify(out));
await browser.close();
}
chromium {"order":"outer-capture > inner-target > outer-bubble","onceCount":1,"defaultPreventedWhenPassive":false,"defaultPreventedWhenNotPassive":true,"docWheelDefaultPrevented":false,"removedOnlyWithMatchingCapture":"1,0","signal":"2,0","focusReachedAncestor":"capture"}
firefox {"order":"outer-capture > inner-target > outer-bubble","onceCount":1,"defaultPreventedWhenPassive":false,"defaultPreventedWhenNotPassive":true,"docWheelDefaultPrevented":false,"removedOnlyWithMatchingCapture":"1,0","signal":"2,0","focusReachedAncestor":"capture"}
webkit {"order":"outer-capture > inner-target > outer-bubble","onceCount":1,"defaultPreventedWhenPassive":false,"defaultPreventedWhenNotPassive":true,"docWheelDefaultPrevented":false,"removedOnlyWithMatchingCapture":"1,0","signal":"2,0","focusReachedAncestor":"capture"}
Reading the keys: order shows the capture listener on the outer element running first, then the target, then the outer bubble listener. onceCount: 1 after three clicks. A passive listener leaves defaultPrevented false while a normal one sets it true. docWheelDefaultPrevented: false shows a wheel listener on document with no options behaving as passive, which matches the MDN rule that wheel, mousewheel, touchstart and touchmove listeners on Window, Document, document.documentElement and document.body default to passive. removedOnlyWithMatchingCapture: "1,0" means a removal with the wrong capture flag left the listener in place (it fired once more) and a removal with the matching flag stopped it. signal: "2,0" shows that one abort() removed a bubble listener and a capture listener together. focusReachedAncestor: "capture" shows that focus, which does not bubble, reached the ancestor only through the capture listener.
Using them on a real page
// capture: watch every click in a form before any field can stop it
form.addEventListener('click', trackClick, { capture: true });
// once: unlock audio on the first interaction, then forget the listener
window.addEventListener('pointerdown', unlockAudio, { once: true });
// passive: observe scrolling without ever blocking it
window.addEventListener('scroll', updateProgress, { passive: true });
carousel.addEventListener('touchmove', trackSwipe, { passive: true });
// signal: one call tears down everything the component added
const controller = new AbortController();
const { signal } = controller;
panel.addEventListener('keydown', onKey, { signal });
document.addEventListener('click', onOutsideClick, { signal, capture: true });
function destroy() { controller.abort(); }
Why passive matters for scrolling
When a listener might call preventDefault() on a touchmove or wheel event, the browser has to run it and see the result before it knows whether to scroll. MDN describes it directly: a passive listener declares that it will not cancel the default action, so the browser can start the default action immediately without waiting for the listener to finish. Its demo shows a long-running wheel listener delaying the scroll when passive is off and scrolling immediately when it is on. The practical effect is scroll smoothness: a slow handler on a non-passive listener makes scrolling stall, while the same slow handler on a passive one does not hold up the scroll (though it still occupies the main thread, the single thread on which the page's JavaScript, style and layout work all run, so a slow handler delays everything queued behind it).
Trade-offs and pitfalls
- Removal must match capture.
removeEventListeneronly checkscapture; mismatching it removes nothing, as the"1,0"result above shows.onceandsignalavoid the problem, because the browser does the removal. onceremoves after the first call, not after the first matching situation. If the element never fires the event the listener stays registered and keeps its closure alive (the variables its function captured stay in memory), so pair it with asignalwhen the owner can be destroyed first.- Passive means you cannot cancel. A swipe-to-dismiss that has to stop vertical scrolling needs
passive: false. A cleaner route is the CSS propertytouch-action(for exampletouch-action: pan-x), which tells the browser up front which touch gestures it may handle itself, so no listener has to cancel anything. - Defaults differ by target. Document-level touch and wheel listeners are passive by default, but the same listener on an inner element is not, so write
passive: trueexplicitly on those. - Support. MDN lists
addEventListeneras widely available across browsers since July 2015. Check the compatibility table for an individual option before targeting older browsers, and do not rely onsignalthere without testing.
Running the code
mkdir demo && cd demo
echo '{"type":"module"}' > package.json
npm i playwright@1.48.2
npx playwright install chromium firefox webkit
# save the listing(s) above as listener_options.mjs, then run:
node listener_options.mjs
The printed output comes from runs in the mcr.microsoft.com/playwright:v1.48.2-jammy container, where the browsers are already installed and the install step is not needed. Playwright's WebKit build is the WebKit engine, not Safari itself.
To know when an element enters the viewport you could poll getBoundingClientRect in a throttled scroll handler or use IntersectionObserver. Compare the two on cost, accuracy, and support, and show how you would lazy-load an image with the better one.
Sample Answer
Direct answer
Use IntersectionObserver. It is a browser API where you register an element and a callback, and the browser calls you when the element's overlap with the viewport (the part of the page currently visible in the window, or another ancestor element) changes. Scroll polling means running your own code on every scroll tick, asking each element for its position with getBoundingClientRect, and comparing it with the window height. The observer is cheaper because the page does no measuring of its own, more accurate because it also reports changes that are not caused by scrolling, and supported across browsers since March 2019 according to MDN. The one reason to keep a polling fallback is a browser old enough to lack the API, and for those a simple "load everything immediately" fallback is usually better than a second code path. The lazy-load recipe is: keep the real URL in data-src, observe each image, and when it intersects, copy data-src into src and stop observing it.
Comparison
Throttled scroll handler + getBoundingClientRect | IntersectionObserver | |
|---|---|---|
| Who measures | Your code, on the main thread (the single thread that runs the page's JavaScript, layout and painting), on each tick | The browser; your callback only receives results |
| Cost | Reads layout for every unloaded image on every tick. Reading layout can force the browser to recompute it first, so the reads can compete with rendering | No layout reads in page code (measured below: zero) |
| Accuracy | Only runs when a scroll event fires. Misses changes caused by layout, resizing, content loading above, or scrolling inside a nested container you did not attach a handler to | Reports whenever the overlap changes for any reason; rootMargin (an extra margin added around the viewport, written like CSS margin) grows or shrinks the root box so you can start loading before the image is visible |
| Timing | You pick the throttle: shorter is more work, longer is late loads | Callback is asynchronous, delivered after the browser has computed intersections |
| Support | Everywhere | MDN: widely available, across browsers since March 2019 |
Measured in three engines
The harness builds a page with 40 images and runs it in two modes. In polling mode a throttled scroll handler (100 ms) checks every image that has not loaded yet. In observer mode a single IntersectionObserver with rootMargin: '100px 0px' loads and then unobserves each image. Two scenarios are run against each mode. In the first, a 3000 px spacer above the images is removed with no scrolling, which moves the first image into view through a layout change only. In the second, the page scrolls down in 300 px steps with a pause after each, and the harness counts the images whose src has been set. It also wraps getBoundingClientRect to count the reads that page code performs, and wraps IntersectionObserver.prototype.unobserve to count how many images are released from the observer, and it asserts the exact counts the prose below derives.
How the page is built: each image is 200 px tall with a 400 px bottom margin, so the images start every 600 px, at y = 0, 600, 1200, 1800, 2400 and so on. page_html is a template string that returns the whole page for a given mode ('poll' or 'io') and spacer height: the nested ${...} expressions generate the 40 <img> tags (each with a different tiny SVG data URL as its real image) and pick which of the two script bodies to include. The line that replaces Element.prototype.getBoundingClientRect is the same counting wrapper used to prove zero measuring.
// lazy_images.mjs
import { chromium, firefox, webkit } from 'playwright';
import assert from 'node:assert/strict';
const page_html = (mode, spacer) => `<style>body{margin:0} #spacer{height:${spacer}px} img{display:block;width:300px;height:200px;margin-bottom:400px;background:#ddd}</style>
<div id="spacer"></div>
${Array.from({ length: 40 }, (_, i) => `<img width="300" height="200" alt="photo ${i}" data-src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='300' height='200'%3E%3Crect width='300' height='200' fill='%23${(i * 6 + 20).toString(16).padStart(2, '0')}9988'/%3E%3C/svg%3E">`).join('')}
<script>
const margin = 100;
const load = (img) => { img.src = img.dataset.src; img.removeAttribute('data-src'); };
window.rectReads = 0;
window.unobserved = 0;
const origUnobserve = IntersectionObserver.prototype.unobserve;
IntersectionObserver.prototype.unobserve = function (el) { window.unobserved++; return origUnobserve.call(this, el); };
const origRect = Element.prototype.getBoundingClientRect;
Element.prototype.getBoundingClientRect = function () { window.rectReads++; return origRect.call(this); };
${mode === 'poll' ? `
// Polling: a throttled scroll handler measures every image that has not loaded yet.
let waiting = false;
function check() {
for (const img of document.querySelectorAll('img[data-src]')) {
const r = img.getBoundingClientRect();
if (r.bottom > -margin && r.top < innerHeight + margin) load(img);
}
}
addEventListener('scroll', () => {
if (waiting) return;
waiting = true;
setTimeout(() => { waiting = false; check(); }, 100);
}, { passive: true });
check();` : `
// IntersectionObserver: the browser reports crossings; the page measures nothing.
const io = new IntersectionObserver((entries, observer) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
load(entry.target);
observer.unobserve(entry.target); // each image needs one crossing, then no more work
}
}, { rootMargin: margin + 'px 0px' });
document.querySelectorAll('img[data-src]').forEach((img) => io.observe(img));`}
</script>`;
const started = (page) => page.evaluate(() => [...document.images].filter((i) => i.hasAttribute('src')).length);
for (const [name, type] of Object.entries({ chromium, firefox, webkit })) {
const browser = await type.launch();
const result = {};
for (const mode of ['poll', 'io']) {
const open = async (spacer) => {
const page = await (await browser.newContext({ viewport: { width: 800, height: 600 } })).newPage();
await page.setContent(page_html(mode, spacer));
return page;
};
// Scenario 1: layout change with no scroll. Removing a 3000 px spacer moves image 0 from y=3000 to y=0.
const a = await open(3000);
await a.waitForTimeout(300);
await a.evaluate(() => document.getElementById('spacer').remove());
await a.waitForTimeout(500);
const layoutChangeLoaded = await started(a);
// Scenario 2: scroll down in 300 px steps, pausing after each. Images sit at y = 0, 600, 1200, ...
const b = await open(0);
for (let y = 300; y <= 1800; y += 300) {
await b.evaluate((top) => scrollTo(0, top), y);
await b.waitForTimeout(150);
}
await b.waitForFunction(() => document.querySelectorAll('img[src]').length >= 5);
result[mode] = { layoutChangeLoaded, loadedAfterScroll: await started(b), rectReads: await b.evaluate(() => window.rectReads), unobserved: await b.evaluate(() => window.unobserved) };
}
assert.equal(result.poll.layoutChangeLoaded, 0); // no scroll event, so the poller never re-checks
assert.ok(result.io.layoutChangeLoaded > 0);
assert.equal(result.io.rectReads, 0);
assert.ok(result.poll.rectReads > 0);
assert.equal(result.poll.loadedAfterScroll, result.io.loadedAfterScroll);
assert.equal(result.io.layoutChangeLoaded, 2); // image 0 at y=0 and image 1 at y=600, inside 0..600 + 100 px margin
assert.equal(result.io.loadedAfterScroll, 5); // images 0 to 4, derived below
assert.equal(result.io.unobserved, 5); // each loaded image is unobserved exactly once
assert.equal(result.poll.unobserved, 0);
console.log(name, JSON.stringify({ poll: { ...result.poll, rectReads: '>0' }, io: result.io }));
await browser.close();
}
chromium {"poll":{"layoutChangeLoaded":0,"loadedAfterScroll":5,"rectReads":">0","unobserved":0},"io":{"layoutChangeLoaded":2,"loadedAfterScroll":5,"rectReads":0,"unobserved":5}}
firefox {"poll":{"layoutChangeLoaded":0,"loadedAfterScroll":5,"rectReads":">0","unobserved":0},"io":{"layoutChangeLoaded":2,"loadedAfterScroll":5,"rectReads":0,"unobserved":5}}
webkit {"poll":{"layoutChangeLoaded":0,"loadedAfterScroll":5,"rectReads":">0","unobserved":0},"io":{"layoutChangeLoaded":2,"loadedAfterScroll":5,"rectReads":0,"unobserved":5}}
Reading the result:
layoutChangeLoaded: polling loaded0images after the spacer was removed because noscrollevent fired, so its check never ran; the observer loaded2(the image now at the top and the one 600 px below it, which is inside the 100 px margin of a 600 px tall viewport) because the intersection changed.loadedAfterScroll: both modes loaded the same5images after scrolling to 1800 px, so accuracy for plain scrolling is the same when the handler runs often enough. The 5 can be redone by hand. At a scroll position of 1800 px with a 600 px viewport and a 100 px margin, the watched band runs from 1800 - 100 = 1700 px to 1800 + 600 + 100 = 2500 px. Images start at 0, 600, 1200, 1800 and 2400 px, all of them before the band ends at 2500, and the next one starts at 3000, which is outside. Images loaded earlier during the 300 px steps stay loaded, so the count is images 0 to 4, which is 5.unobserved: the observer page released5images, one per image it loaded, and the polling page released none. This is the step that keeps a loaded image from costing anything afterwards, and from being run throughloada second time if the user scrolls back up over it (a second run would setsrcto the now-missingdata-src, which isundefined). With theunobserveline removed, the harness fails on this count.rectReads: the observer page made0calls togetBoundingClientRect. The polling page made some on each tick (the harness prints>0because the exact number depends on how many scroll ticks the browser delivered).
The lazy loader on its own
The observer branch of the harness is the loader:
- Keep the real URL in
data-srcand give the<img>widthandheightattributes. Reserved space stops the page from jumping when the image arrives; unexpected movement of this kind is what CLS (Cumulative Layout Shift, a Core Web Vital that scores visual stability) counts. - Create one observer with
rootMarginset to how early you want loading to start (100 px here; a larger margin starts loads earlier at the price of fetching images the user may never reach). - In the callback, skip entries whose
isIntersectingis false, copydata-srctosrc, and callobserver.unobserve(entry.target)so the image costs nothing afterwards. - For a browser without the API, check
'IntersectionObserver' in windowand set everysrcimmediately.
Pitfalls
- The callback receives entries, and an entry can be queued for an element that is not intersecting at all (the first delivery after
observe()reports the initial state), so always testisIntersecting. - The default root is the viewport. To watch images inside a scrolling container, pass that container as
root;rootMarginthen grows or shrinks that container's box. - One observer for many elements is cheaper than one observer per element.
- Disconnect the observer (
observer.disconnect()) when the component goes away, otherwise the observer keeps watching images that may no longer be on the page.
Running the code
mkdir demo && cd demo
echo '{"type":"module"}' > package.json
npm i playwright@1.48.2
npx playwright install chromium firefox webkit
# save the listing(s) above as lazy_images.mjs, then run:
node lazy_images.mjs
The printed output comes from runs in the mcr.microsoft.com/playwright:v1.48.2-jammy container, where the browsers are already installed and the install step is not needed. Playwright's WebKit build is the WebKit engine, not Safari itself.
Write a small browser helper that loads the user list from GET /api/users and returns the parsed data. Callers need to tell a server error response apart from the network being down, with the status where there is one. Walk me through what fetch does and does not treat as a failure.
Sample Answer
fetch only rejects when it gets no HTTP response at all. A 404 or 500 is a successful round trip as far as fetch is concerned, so the promise resolves and you must check response.ok (true for statuses 200 to 299) or response.status yourself. MDN: "A fetch() promise only rejects when the request fails, for example, because of a badly-formed request URL or a network error. A fetch() promise does not reject if the server responds with HTTP status codes that indicate errors (404, 504, etc.)." The helper below turns that into three error classes callers can tell apart: HttpError (the server answered, with a status), NetworkError (no answer), and ParseError (the server said success but the body is not JSON), plus the caller's own AbortError passed through untouched. A caller then branches on the class and status, for example if (err.name === 'HttpError' && err.status === 401) showLogin(), else if (err.name === 'NetworkError') showOfflineBanner(), and a ParseError is reported as a server bug.
What fetch does and does not treat as a failure
| Situation | What fetch does | Helper result |
|---|---|---|
| 200 with a JSON body | resolves, ok is true | returns parsed data |
| 404, 500 or any other non-2xx | resolves, ok is false | throws HttpError with status and the start of the body |
| Connection reset, DNS failure, offline, request blocked by CORS (the browser's cross-origin rule) | rejects with a TypeError | throws NetworkError, status is null |
| Server never answers, or sends headers and then stalls mid-body | waits until aborted | AbortSignal.timeout fires, helper throws NetworkError |
| 200 with an empty body or an HTML login page | resolves, ok is true | response.json() rejects, helper throws ParseError carrying status 200 |
Caller calls abort() on its own signal | rejects with AbortError | rethrown as is, because a cancel is not a failure to report |
Two details matter in interviews. First, a TypeError from fetch is deliberately vague: the browser does not tell a script whether the cause was offline, DNS, a reset or a CORS block, so the helper cannot either. Second, a successful status does not mean usable data: proxies and captive portals (the sign-in pages that hotel or airport Wi-Fi serves in place of the real site) often answer 200 with HTML, and response.json() is what exposes it, so the parse step needs its own error class.
Implementation
// users.js
export class HttpError extends Error {
constructor(status, statusText, body) {
super(`HTTP ${status} ${statusText}`.trim());
this.name = 'HttpError';
this.status = status; // the server answered; callers branch on this
this.body = body;
}
}
export class NetworkError extends Error {
constructor(message, cause) {
super(message, { cause });
this.name = 'NetworkError'; // no HTTP response at all: offline, DNS, reset, CORS block, timeout
this.status = null;
}
}
export class ParseError extends Error {
constructor(status, cause) {
super('Response body is not valid JSON', { cause });
this.name = 'ParseError';
this.status = status; // the server answered 2xx but the body was unusable
}
}
export async function loadUsers({ url = '/api/users', signal, timeoutMs = 5000 } = {}) {
const timeout = AbortSignal.timeout(timeoutMs);
const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
let res;
try {
res = await fetch(url, { headers: { Accept: 'application/json' }, signal: combined });
} catch (err) {
if (signal?.aborted) throw err; // the caller cancelled: not a failure to report
if (err.name === 'TimeoutError') throw new NetworkError(`No response within ${timeoutMs} ms`, err);
throw new NetworkError('Network request failed', err); // fetch rejects with a TypeError here
}
if (!res.ok) { // 4xx and 5xx still resolve; ok is status 200-299
const body = await res.text().catch(() => '');
throw new HttpError(res.status, res.statusText, body.slice(0, 500));
}
try {
return await res.json(); // rejects on an empty or non-JSON body
} catch (err) {
if (combined.aborted && !signal?.aborted) throw new NetworkError(`Body not finished within ${timeoutMs} ms`, err);
if (signal?.aborted) throw err;
throw new ParseError(res.status, err);
}
}
Choices worth defending:
- Custom error classes with a
nameand astatusfield let callers branch witherr.name === 'HttpError' && err.status === 401rather than parsing message text. - Timeout plus caller cancel. An abort signal is the object
fetchwatches in order to cancel a request.AbortSignal.timeout(ms)makes a signal that aborts by itself after the delay, with aDOMExceptionnamedTimeoutErroras its reason (MDN);AbortSignal.any([...])makes one signal that aborts when any of the listed signals does, here the caller's signal or the timeout. When a request is cancelledfetchrejects with the aborting signal's reason, so a timeout reaches the helper asTimeoutErrorand a caller's plainabort()asAbortError, which is why the two names differ. The helper checkssignal?.abortedfirst so a user cancel is never relabelled as a network problem. - Body read inside the same guard. A connection can drop after the headers arrive, in which case
res.json()rejects with a network-level error, not malformed JSON. The helper checks whether the timeout caused it before calling it aParseError. The three checks in the secondcatchmap like this:combined.abortedtrue and the caller'ssignalnot aborted means the timeout fired while the body was still arriving, soNetworkError; the caller'ssignalaborted means the caller cancelled, so the error is rethrown as is (AbortError); neither aborted means the bytes arrived in full and were not JSON, soParseError. The order matters, because a caller cancel also setscombined.aborted, and the first test excludes it. - Error body kept short.
body.slice(0, 500)gives support staff the server's message ({"error":"no such route"}) without holding a large page in memory. 204 No Content. This endpoint always returns a list, so an empty body is treated as a broken response. For an endpoint that may legitimately answer 204, testres.status === 204before callingres.json().
Test
The harness starts a small Node HTTP server with one route per case and runs the helper in real Chromium, Firefox and WebKit pages through Playwright. The "network down" cases are a route that destroys the socket and a route that never answers, which are reproducible; unplugging the network is not. In the page script, the aborted case starts its request first (the call runs synchronously as far as issuing the fetch), then ctl.abort() cancels it while it is still hanging, and the result is awaited last so the other cases run in between without affecting it; starting it after abort() would test an already-cancelled signal instead of an in-flight request. A further case cancels a request after the headers arrived, while the body is still streaming, and a final check reads HttpError.body for a short message and for a 5,000-character page that must be cut to 500 characters. Every normal response carries Connection: close, so no request reuses a socket left over from an earlier case that destroyed or abandoned its connection; without that header the last check failed occasionally in WebKit under load, and with it the failures stopped.
// users-test.mjs
import { chromium, firefox, webkit } from 'playwright';
import { readFileSync } from 'node:fs';
import http from 'node:http';
import assert from 'node:assert/strict';
const lib = readFileSync('users.js', 'utf8').replace(/export /g, '');
const server = http.createServer((req, res) => {
const send = (code, type, body) => { res.writeHead(code, { 'Content-Type': type, Connection: 'close' }); res.end(body); }; // no connection reuse between cases
switch (req.url) {
case '/': return send(200, 'text/html', '<!doctype html><title>t</title>');
case '/api/users': return send(200, 'application/json', '[{"id":1,"name":"Ada"}]');
case '/api/missing': return send(404, 'application/json', '{"error":"no such route"}');
case '/api/boom': return send(500, 'text/plain', 'upstream exploded');
case '/api/empty': return send(200, 'application/json', '');
case '/api/login-page': return send(200, 'text/html', '<html>sign in</html>');
case '/api/huge': return send(500, 'text/plain', 'x'.repeat(5000));
case '/api/drop': return req.socket.destroy(); // connection reset: fetch has no response to resolve with
case '/api/hang': return; // never answers
case '/api/slow-body': res.writeHead(200, { 'Content-Type': 'application/json' }); return res.write('[{"id":'); // headers and half a body, then silence
}
});
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const origin = `http://127.0.0.1:${server.address().port}`;
for (const [name, type] of Object.entries({ chromium, firefox, webkit })) {
const browser = await type.launch();
const page = await browser.newPage();
await page.goto(origin + '/');
await page.addScriptTag({ content: lib });
const outcome = await page.evaluate(async () => {
const run = async (label, opts) => {
try { const d = await loadUsers(opts); return `${label}: data ${JSON.stringify(d)}`; }
catch (e) { return `${label}: ${e.name} status=${e.status ?? 'none'}`; }
};
const ctl = new AbortController();
const aborted = run('aborted', { url: '/api/hang', signal: ctl.signal });
ctl.abort();
const ctl2 = new AbortController(); // cancelled after the headers arrived, while the body is still streaming
const midBody = run('cancel-mid-body', { url: '/api/slow-body', signal: ctl2.signal });
setTimeout(() => ctl2.abort(), 300);
return [
await run('ok', {}),
await run('404', { url: '/api/missing' }),
await run('500', { url: '/api/boom' }),
await run('empty', { url: '/api/empty' }),
await run('html', { url: '/api/login-page' }),
await run('reset', { url: '/api/drop' }),
await run('timeout', { url: '/api/hang', timeoutMs: 200 }),
await run('slow-body', { url: '/api/slow-body', timeoutMs: 200 }),
await midBody,
await aborted,
];
});
assert.deepEqual(outcome, [
'ok: data [{"id":1,"name":"Ada"}]',
'404: HttpError status=404',
'500: HttpError status=500',
'empty: ParseError status=200',
'html: ParseError status=200',
'reset: NetworkError status=none',
'timeout: NetworkError status=none',
'slow-body: NetworkError status=none',
'cancel-mid-body: AbortError status=none',
'aborted: AbortError status=none',
]);
// the error carries the start of the server's message, cut to 500 characters
const bodies = await page.evaluate(async () => {
const body = (url) => loadUsers({ url }).catch((e) => e.body);
return [await body('/api/missing'), (await body('/api/huge')).length];
});
assert.deepEqual(bodies, ['{"error":"no such route"}', 500]);
if (name === 'chromium') console.log(outcome.join('\n'));
console.log(name, 'ok');
await browser.close();
}
server.close();
Re-run many times, including with several containers at once, the test passed in all three engines. Deleting the signal?.aborted rethrow in the body catch, or the slice(0, 500), makes the run fail. The lines the Chromium page printed (the other two matched the same assertion list):
ok: data [{"id":1,"name":"Ada"}]
404: HttpError status=404
500: HttpError status=500
empty: ParseError status=200
html: ParseError status=200
reset: NetworkError status=none
timeout: NetworkError status=none
slow-body: NetworkError status=none
cancel-mid-body: AbortError status=none
aborted: AbortError status=none
chromium ok
Complexity and edge cases
One request and one body read: O(n) in the size of the response, constant extra memory apart from the parsed value. Edge cases the helper handles: non-JSON success bodies, empty bodies, a hung server, a caller cancelling, and a body that fails mid-stream. It does not retry; retries belong in the caller because only the caller knows whether the request is safe to repeat (a GET is, a payment POST is not).
Running the code
npm init -y && npm pkg set type=module
npm i playwright@1.48.2
npx playwright install chromium firefox webkit
node users-test.mjs
Save the listings as users.js and users-test.mjs in one folder. The script prints the lines shown above for Chromium, then firefox ok and webkit ok.
Unlock Full Question Bank
Get access to all DOM Manipulation and Browser APIs interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.