The DOM, events and storage
One document, one main thread, and a pile of machinery between your click handler and the pixels. What gets asked here is ordering: which handler runs first, whether the answer survives an element added five minutes later, whether the token you stored can be read by a script you did not write, and what the browser was doing for the 2.5 seconds before anything appeared. All of it is learnable as sequences, and all of it has a wrong version that sounds right.
The three phases, in order
A click on a deeply nested button does not start at the button. The browser computes the path from the window down to the target and dispatches in three phases: CAPTURE, from window down through every ancestor; then AT_TARGET on the element itself; then BUBBLE, back up the same path to the window. Every ancestor gets two chances to hear about the same click.
By default addEventListener registers for the bubble phase. Pass true as the third argument (or { capture: true }) and it registers for capture instead. On the target element itself, the phase flag is ignored for ordering - listeners on the target fire in the order they were added, which is why 'my capture listener on the button ran second' is not a bug.
Everything else follows from that path. Delegation works because the event passes through the ancestor. stopPropagation works because it cuts the path short. And an event that does not bubble - focus, blur, mouseenter - cannot be delegated at all, which is why the focusin/focusout pair exists.
- target versus currentTarget
- target is where the event happened and never changes as the event travels. currentTarget is whichever element's listener is running right now, so it is different in every handler on the path and it is what this refers to in a non-arrow handler. Once the handler returns, currentTarget is null - which is why capturing it in a setTimeout gives you null.
- The pipeline, once
- Bytes to DOM, stylesheets to CSSOM, the two combined into a render tree of what is actually visible, then layout (where and how big), paint (fill in pixels, possibly onto several layers), composite (put the layers together on screen). Reflow re-runs from layout; repaint re-runs from paint. Transform and opacity can often re-run from composite alone, which is the whole reason they are the cheap things to animate.
Questions
Answer each one yourself before you open it.
01What is the DOM?
The browser's in-memory tree representation of the parsed document, plus an API for reading and changing it. It is what your HTML becomes, not what your HTML is - once the page is parsed, editing the DOM changes the page and the original HTML text is irrelevant.
It is worth saying explicitly that the DOM is not part of JavaScript. It is a browser API, specified separately, exposed to whatever language the host provides - which is why Node has no document, and why 'JavaScript can't do X' is often really 'this host does not implement that API'.
02document versus window?
window is the global object and the browsing context - the tab. It owns timers, location, history, fetch, localStorage and the global scope. document is one property of it: the root of the DOM tree for the page currently loaded in that window.
The practical consequence is lifetime. Navigate, and document is replaced while window persists; that is why listeners you want to survive go on window and why document.write after load wipes the page. In a classic script, a top-level var becomes a property of window; a let or const does not, and neither does anything in a module.
03querySelectorAll or getElementsByClassName?
querySelectorAll almost always - it takes any CSS selector and returns a static NodeList, a snapshot. getElementsByClassName and getElementsByTagName return LIVE HTMLCollections that update themselves as the DOM changes.
The live collection is the trap: a for loop that removes elements from a live collection skips every other one, because the collection shrinks underneath the index. Live collections are also faster for repeated queries, which is essentially never the bottleneck. A NodeList has forEach; an HTMLCollection does not, so you spread it first.
04innerHTML, textContent or innerText?
textContent gives you every text node in the subtree, including in hidden elements, and is the fastest. innerText gives you the RENDERED text, so it respects display: none and CSS text-transform - and it forces a reflow to work that out. innerHTML parses and serialises HTML, and it is the XSS sink.
Setting innerHTML with anything that came from a user or an API is how a script tag or an onerror handler ends up executing in your page. Setting textContent cannot execute anything, because nothing is parsed as markup. If you genuinely need HTML from an untrusted source, it has to be sanitised first - there is no safe shortcut.
05Walk me through event bubbling and capturing.
Three phases. Capture runs from the window down through every ancestor to the target; then the target's own listeners run; then bubbling runs back up to the window. addEventListener registers for the bubble phase by default, and for capture if you pass true or { capture: true }.
Follow-up Why does capturing exist if nobody uses it?
Because it lets an ancestor see an event before any descendant can cancel it - that is how you build a global 'close on any click outside' or an analytics listener that cannot be stopped by a component calling stopPropagation. It is also the only phase available for events that do not bubble.
06What is the third argument to addEventListener?
Historically a boolean for capture. Now an options object: { capture, once, passive, signal }. once removes the listener after it fires, passive promises you will not call preventDefault, and signal takes an AbortSignal so you can remove the listener by aborting a controller.
signal is the modern answer to teardown: one AbortController, one abort() call, and every listener registered with that signal is gone - no keeping references to functions. passive is a performance contract: the browser can start scrolling immediately instead of waiting to see whether your touchstart or wheel handler will cancel it, and browsers default those two to passive on the window and document for exactly that reason.
07stopPropagation, stopImmediatePropagation, preventDefault - what is the difference?
preventDefault cancels the browser's default action - following the link, submitting the form, ticking the checkbox - but the event keeps travelling. stopPropagation stops the event travelling further along the path, but other listeners on the SAME element still run. stopImmediatePropagation stops both: no more listeners anywhere, including siblings on this element.
They are orthogonal, and mixing them up produces the two classic bugs. Calling stopPropagation to 'stop the form submitting' does not stop it, because the default action is not propagation. And using stopPropagation liberally inside a component breaks anything built on delegation higher up - your dropdown stops closing, and the reason is three files away.
Follow-up Does returning false do anything?
In an inline onclick attribute and in old jQuery, yes - jQuery treated it as preventDefault plus stopPropagation. In a listener added with addEventListener, returning false does nothing at all.
08What is event delegation and why would you use it?
Attach one listener to a common ancestor and work out what was clicked from event.target, rather than attaching one listener per child. Because events bubble, the parent hears everything.
Two payoffs. One listener instead of a thousand, which is memory and setup time on a long list. And it works for elements that do not exist yet - add a row after the page loads and it is handled, with no rebinding. That second one is the real reason, and it is the answer to 'why not just loop and attach'.
Follow-up How do you find the right element inside the handler?
event.target.closest('.row') - it walks up from the exact node that was clicked until it matches, so a click on an icon inside a button inside the row still resolves to the row. Then guard on the result being non-null and on the container actually containing it, because closest can walk past your list.
09event.target or event.currentTarget?
target is the element the event originated on and stays the same all the way along the path. currentTarget is the element whose listener is currently executing. In delegation, target is the button you clicked and currentTarget is the list you attached to.
currentTarget is also what this is bound to in a normal function handler, and it is nulled once the handler returns - so reading it inside a setTimeout or an await gives null. If you need it later, copy it to a local variable first.
10Which events do not bubble?
focus, blur, mouseenter, mouseleave, load, error, and scroll when it fires on an element rather than the document. Each of the first three has a bubbling counterpart: focusin and focusout, and mouseover and mouseout.
This matters exactly when you try to delegate. A single 'validate on blur' listener on the form does nothing, because blur does not reach the form - swap it for focusout and it works. mouseenter versus mouseover has a second difference beyond bubbling: mouseover fires again as the pointer moves between child elements, mouseenter fires once for the whole subtree.
11How do you remove an event listener, and why can't you remove an anonymous one?
removeEventListener with the same type, the same function REFERENCE, and the same capture flag. An arrow function written inline creates a new function object every time it is evaluated, so the one you pass to remove is not the one that was added and nothing is removed.
Same trap for .bind(this) - it returns a new function each call, so adding with fn.bind(this) and removing with fn.bind(this) fails silently. Keep a reference, or use { signal } from an AbortController and abort it. The consequence of getting this wrong is the classic leak: a component unmounts, its listener stays on window holding a closure over the whole component, and it fires forever.
12What is this inside a DOM handler, and what happens if you use an arrow?
In a normal function handler, this is currentTarget - the element the listener is attached to. An arrow function has no this of its own, so it takes the enclosing scope's, which in a class or a module is usually the instance or undefined, not the element.
That is why passing an object method directly (el.addEventListener('click', obj.handle)) loses the object: this becomes the element. Either bind it once and keep the reference, or wrap it in an arrow that calls obj.handle() - the arrow keeps the outer this and the element is still available on event.currentTarget either way.
13localStorage, sessionStorage and cookies - compare them.
localStorage: about 5MB, lives until explicitly cleared, per origin, never sent to the server, readable by JS. sessionStorage: same size and API, but scoped to one tab and gone when the tab closes. Cookies: about 4KB each, an explicit expiry, and sent on every matching request automatically - which is the whole point of them and their whole cost.
The differences that decide an answer are 'sent to the server' and 'readable by JS'. Cookies are the only one automatically attached to requests, which is what makes session auth work and what makes CSRF possible. An HttpOnly cookie is the only one of the four combinations that JavaScript cannot read at all. Both storages are also synchronous and on the main thread, so writing a lot on every keystroke is a real jank source.
Follow-up Does sessionStorage survive a refresh?
Yes - a reload and a back-navigation keep it, because it is the tab's session, not the page's. It does not survive closing the tab, and a new tab (even to the same URL) starts empty. Duplicating a tab copies it, which surprises people.
14Why is storing an auth token in localStorage a problem?
Because any JavaScript running on your origin can read it, so a single XSS - including one from a compromised dependency or an ad script - exfiltrates the token in one line. An HttpOnly cookie cannot be read by script at all, so the same XSS cannot steal it.
The honest version, which is worth saying because interviewers push on it: XSS is catastrophic either way. With an HttpOnly cookie the attacker cannot take the token home, but they can still make authenticated requests from the victim's browser while the page is open. So the cookie shrinks the blast radius from 'token stolen and replayable indefinitely' to 'abuse while the tab is open'. That is a real improvement, not immunity.
Follow-up So what do you actually do?
Refresh token in an HttpOnly, Secure, SameSite cookie; short-lived access token held in memory only - a JS variable, not storage - and refreshed on demand. And a Content-Security-Policy, because the whole argument is downstream of whether an attacker can run script on your origin.
15Which cookie attributes matter?
HttpOnly - JavaScript cannot read it. Secure - HTTPS only. SameSite - controls whether it is sent on cross-site requests, and Lax is the modern browser default. Domain and Path scope it. Then expiry: Expires takes an absolute date, Max-Age takes seconds from now, and a cookie with neither is a session cookie that dies with the browser.
Prefer Max-Age: it needs no clock agreement between server and client, and Expires depends on the user's system clock. If both are present, Max-Age wins. And note that setting Domain widens a cookie to subdomains rather than narrowing it - omitting Domain is the more restrictive choice, not the looser one.
Follow-up What do the SameSite values mean?
Strict: never sent on any cross-site request, so following a link in from another site arrives logged out. Lax: sent on top-level GET navigations only, which is the sensible default and blocks the classic form-post CSRF. None: sent on everything, and it is rejected unless the cookie is also Secure - that is the setting third-party embeds need.
16When would you use IndexedDB?
When you need more than a few megabytes, structured records rather than strings, indexes and queries, or transactions - offline data, cached API responses, a large asset store. It is asynchronous and off the main thread, unlike the storages.
The reason people avoid it is the API: raw IndexedDB is event-based and verbose, so in practice you wrap it (idb, Dexie) or you do not use it. It also has the same origin scoping and the same eviction risk as everything else in browser storage - the browser can clear it under storage pressure unless you have requested persistence.
17Can a write to localStorage fail?
Yes. It throws a QuotaExceededError when you are over the limit, and historically it threw on every write in Safari's private browsing. Reading can also throw if the browser is configured to block site data, so a wrapper with try/catch around both is not defensive padding, it is required.
This is the reason to never call setItem inline. The failure is total - one uncaught throw in a state-persisting helper takes out the interaction that called it - and the correct behaviour is nearly always to carry on without persistence rather than to break.
18How do two tabs of your app stay in sync?
The storage event: it fires on OTHER tabs of the same origin whenever localStorage changes, giving you the key, the old value and the new value. It does not fire in the tab that made the change, which is the part people trip on. BroadcastChannel is the cleaner modern tool for deliberate messaging.
19Walk me through how a browser renders a page.
Parse the HTML into the DOM. Parse CSS into the CSSOM. Combine them into a render tree of everything that is actually visible. Run layout to compute every box's size and position. Paint the pixels, possibly into several layers. Composite the layers to the screen.
Two blocking facts hang off this. CSS is render-blocking: the browser will not paint until the CSSOM is built, because painting with unknown styles would flash. And a plain script tag is parser-blocking: it stops DOM construction while it fetches and executes, and it also waits for any pending CSS above it, because the script might ask for a computed style.
20Reflow or repaint - what is the difference?
A reflow (layout) is the browser recomputing geometry - sizes and positions - and it is triggered by changing width, height, padding, margin, position, font-size, or adding and removing elements. A repaint is redrawing pixels with no geometry change: colour, background, box-shadow, visibility. Every reflow implies a repaint; the reverse is not true.
Follow-up What is layout thrashing?
Alternating reads and writes in a loop. Writing invalidates layout; reading offsetHeight, getBoundingClientRect, scrollTop or getComputedStyle forces the browser to redo it synchronously to answer you. Do that fifty times and you have fifty forced layouts. The fix is to batch: read everything into variables first, then write everything.
21defer or async on a script tag?
Both download in parallel with parsing, so neither blocks the parser. async executes as soon as it lands, in whatever order they finish, possibly before the DOM is complete. defer executes after parsing finishes, in document order, right before DOMContentLoaded.
So defer for anything that touches the DOM or depends on another script, async only for genuinely independent third-party things like analytics. Both attributes are ignored on an inline script - they need a src. And type='module' is deferred by default, which is why module scripts do not need the attribute.
22DOMContentLoaded or load?
DOMContentLoaded fires when the HTML is parsed and the DOM is built - deferred scripts have run, but images, stylesheets in some cases, and iframes may still be downloading. load fires when everything, including every image and subframe, has finished.
Almost all initialisation belongs on DOMContentLoaded, because load can be seconds later and is hostage to one slow image. Use load only when you genuinely need final dimensions of media. And if your script has defer, the DOM is already there when it runs, so you often need neither.
23What is IntersectionObserver for?
Being told when an element enters or leaves the viewport (or another scroll container), asynchronously and off the main thread. Lazy-loading images, infinite scroll, firing an animation when a section appears, measuring whether an ad was actually seen.
The alternative was a scroll listener calling getBoundingClientRect on every element on every scroll event - which fires at high frequency and forces a synchronous layout each time, the exact layout-thrashing pattern. The observer batches, runs off the main thread, and hands you an entry with isIntersecting and intersectionRatio. Configure it with root, rootMargin (to fire early) and threshold.
24What are the Core Web Vitals?
Three: LCP, largest contentful paint, which is loading - good is 2.5 seconds or under. INP, interaction to next paint, which is responsiveness - good is 200 milliseconds or under. And CLS, cumulative layout shift, which is visual stability - good is 0.1 or under. All measured at the 75th percentile of real users.
Each has an obvious first fix. LCP: the largest element is usually a hero image or a heading blocked by a font - preload it, serve it in a modern format, do not lazy-load it, and cut render-blocking CSS. INP: break up long tasks so the main thread is free to respond, and stop doing heavy work synchronously in the handler. CLS: give every image and embed explicit width and height (or aspect-ratio), reserve space for ads and banners, and use font-display with a metric-matched fallback.
Follow-up What happened to FID?
INP replaced it in March 2024. FID only measured the delay before the FIRST interaction's handler started running - so a page could score well while every click took a second to actually update anything. INP measures the full latency of interactions across the whole visit, from input to the next paint, and reports near the worst one. It is a much harder metric to game and a much better proxy for feeling slow.
25What is CORS?
A browser security mechanism. The same-origin policy stops a page reading a response from a different origin; CORS is how a server opts in, by sending Access-Control-Allow-Origin and friends. It is enforced by the browser, on the response - the request usually still reached the server and still ran.
That last point is the one that changes how you debug it. A CORS error does not mean the request was blocked; for a simple request it means the response arrived and the browser refused to hand it to your JavaScript. So a CORS error is never fixed in the front end - it is a response header on the server, or a proxy on your own origin. And it protects the READ, not the write, which is precisely why CSRF exists as a separate problem.
26What is a CORS preflight?
An automatic OPTIONS request the browser sends before the real one, asking whether the real request is allowed. It is triggered by anything not 'simple': a method other than GET, POST or HEAD; a custom header like Authorization or X-Request-Id; or a Content-Type other than form-urlencoded, multipart/form-data or text/plain.
So application/json alone triggers a preflight, which is why almost every API call in a modern app is two round trips. The server answers with Access-Control-Allow-Methods, -Allow-Headers and Access-Control-Max-Age, and Max-Age is the lever that matters: it caches the preflight so you pay for it once rather than on every call.
Follow-up What changes when you send credentials?
With credentials: 'include', the server must send Access-Control-Allow-Credentials: true, and Access-Control-Allow-Origin must name the exact origin - the wildcard is rejected outright. Same for Allow-Headers and Allow-Methods; wildcards do not count in credentialed mode.
27What is XSS and how do you prevent it?
Cross-site scripting: getting your page to run an attacker's JavaScript on your origin. Stored (saved on the server and served to everyone), reflected (bounced off a URL parameter), and DOM-based (your own client code writes untrusted input into the page). The defences are: escape on output, never build HTML from untrusted strings, sanitise if you must accept HTML, and a Content-Security-Policy as the backstop.
Concretely: use textContent instead of innerHTML, avoid dangerouslySetInnerHTML and its equivalents, and treat every URL from user input as hostile - javascript: in an href is XSS. Note that frameworks escape by default, which is why the remaining XSS in a React app is almost always in the three or four places where you deliberately opted out.
28What is CSRF, and how is it different from XSS?
Cross-site request forgery: another site causes the user's browser to make an authenticated request to yours, using cookies the browser attaches automatically. The attacker never sees the response - they just cause the side effect. XSS is running code on your origin; CSRF is abusing credentials from somebody else's.
The defences are different too. SameSite=Lax on session cookies blocks the classic cross-site form POST and is now the browser default. Beyond that, a per-session CSRF token in a header or form field that the attacker cannot read, and never using GET for anything that changes state. Note the shape of the problem: CSRF exists because cookies are sent automatically, so an app authenticating with an Authorization header is not vulnerable to it in the same way - that is the one genuine security argument for bearer tokens.
29What happens when you type a URL and press enter?
DNS resolves the hostname to an IP, possibly from a cache at four different levels. A TCP connection opens and, for HTTPS, a TLS handshake negotiates keys and verifies the certificate. The browser sends an HTTP request; the server responds. The browser parses the HTML, discovers subresources and fetches them, builds the DOM and CSSOM, runs layout, paints and composites. Then the JavaScript runs and the page becomes interactive.
The parts worth mentioning to show depth: the browser checks its own cache before any network at all and may serve the whole thing from there; HSTS can upgrade the scheme before a request is made; the connection is often already open thanks to HTTP/2 multiplexing; and the preload scanner is off fetching subresources ahead of the main parser while the parser is blocked. Ending at 'and then it paints' is the answer that stops one step short.
30How do you make a click handler feel instant on a slow phone?
Do the smallest possible synchronous work in the handler - update the thing the user is looking at - and push everything else off. Break long tasks up, move computation to a Web Worker, and avoid forcing layout inside the handler.
This is INP restated: the browser cannot paint until the main thread is free, so a 300ms handler is a 300ms delay before anything changes on screen. A task over 50ms is a 'long task' by definition and is what blocks the paint. Yielding - even with a plain await on a task-queued promise - lets the browser paint the optimistic update before the expensive part continues.
31requestAnimationFrame or setTimeout for animation?
requestAnimationFrame. It runs your callback right before the next paint, so you get exactly one update per frame at the display's actual refresh rate, and it pauses entirely in a background tab. setTimeout runs on its own schedule and will tear or do redundant work.
It is also the right place to batch DOM reads and writes deliberately: measure in one frame, mutate in the next, and you have removed forced synchronous layout without changing what the code does.
32How do you create and dispatch your own event?
new CustomEvent('name', { detail: payload, bubbles: true }) and element.dispatchEvent(e). Listeners read e.detail. Note that bubbles defaults to FALSE on a manually constructed event, so an event that seems not to reach the parent is usually missing that flag.
33Debounce or throttle?
Debounce waits until the activity stops and then runs once - a search-as-you-type box, a resize handler that recalculates a layout. Throttle runs at most once per interval while the activity continues - a scroll position indicator, a drag handler.
The one-line test: do you care about the final state (debounce) or about keeping up during the action (throttle)? Get it wrong and a debounced scroll handler updates nothing until the user stops scrolling, or a throttled search fires ten requests for one word.
34Why would you use a Web Worker?
To run expensive JavaScript off the main thread so the UI stays responsive - parsing a large file, heavy computation, image processing. Workers have no DOM access and communicate by structured-cloned messages, so they are only worth it when the work is genuinely CPU-bound and self-contained.
35What is the difference between preload, prefetch and preconnect?
preload: fetch this now at high priority, I need it for THIS page - a hero image, a critical font. prefetch: fetch this at low priority for a LIKELY next navigation. preconnect: do the DNS, TCP and TLS work for this origin now so the eventual request is faster.
preload is the LCP lever - a font or hero image discovered late by the parser is the usual reason LCP is bad. Overusing it is counterproductive, because everything preloaded competes with everything else preloaded, and a preload that goes unused is a warning in the console and wasted bandwidth.
36What is the same-origin policy, and what counts as an origin?
Scheme, host and port - all three. https://a.example.com and https://b.example.com are different origins; so are http and https on the same host, and port 3000 and port 3001. The policy stops a document reading another origin's DOM, cookies, or responses.
Note what it does NOT stop: embedding. You can load an image, a script, a stylesheet or an iframe from anywhere - you just cannot read the pixels, the source or the document. That asymmetry is the reason CSRF works and the reason a compromised CDN script is game over.
37How would you find why a page is slow?
Open the Performance panel, record a load or an interaction, and look at what the main thread was doing: long tasks, forced synchronous layouts, script evaluation. Then Lighthouse for the field-metric summary and Network for what blocked the critical path.
The useful discipline is to distinguish the three causes before touching anything: is it network (too much, too late, unoptimised), is it main thread (too much JavaScript, long tasks), or is it rendering (layout thrashing, huge trees, expensive paint)? Each has a different fix, and guessing wrong costs a day.
38What is a memory leak in a browser app, and where do they come from?
Objects kept reachable after they should be garbage. In practice: listeners never removed, timers never cleared, observers never disconnected, detached DOM nodes still referenced by a closure or an array, and a global cache that only ever grows.
Detached DOM is the one to name: you remove an element from the page but a variable still points at it, so the whole subtree stays in memory. Devtools' heap snapshot has a 'Detached' filter that finds exactly this. Every one of those causes has the same fix shape - clean up in the teardown path, which is what an AbortController makes into one line.
Predict the output
The output is hidden. Predict it before you open it.
The three phases, in order
// <div id='outer'><button id='inner'>go</button></div>
const outer = document.getElementById('outer');
const inner = document.getElementById('inner');
outer.addEventListener('click', () => console.log('outer capture'), true);
inner.addEventListener('click', () => console.log('inner capture'), true);
outer.addEventListener('click', () => console.log('outer bubble'));
inner.addEventListener('click', () => console.log('inner bubble'));
inner.click();Show the output
outer capture
inner capture
inner bubble
outer bubbleCapture runs top-down to the target, then the target's own listeners, then bubbling runs back up. On the TARGET itself the capture flag does not reorder anything - the two inner listeners fire in the order they were added, which is why 'inner capture' precedes 'inner bubble' here but would swap if you registered them the other way round.
stopPropagation is not retroactive
const outer = document.getElementById('outer');
const inner = document.getElementById('inner');
outer.addEventListener('click', () => console.log('A'), true);
outer.addEventListener('click', () => console.log('B'));
inner.addEventListener('click', (e) => { console.log('C'); e.stopPropagation(); });
inner.click();Show the output
A
CThe capture listener on the ancestor has already run by the time the target's handler calls stopPropagation - you can only stop what has not happened yet. B never runs because bubbling is cut. This is exactly why a global 'close everything' listener is registered in the capture phase: a component calling stopPropagation cannot silence it.
stopPropagation versus stopImmediatePropagation
const b = document.getElementById('inner');
b.addEventListener('click', (e) => { console.log('one'); e.stopPropagation(); });
b.addEventListener('click', () => console.log('two'));
b.click();
console.log('---');
const c = b.cloneNode(true);
c.addEventListener('click', (e) => { console.log('three'); e.stopImmediatePropagation(); });
c.addEventListener('click', () => console.log('four'));
c.click();Show the output
one
two
---
threestopPropagation only stops the event moving to the NEXT element on the path; other listeners on the same element still run, so 'two' fires. stopImmediatePropagation stops the remaining listeners here as well, so 'four' never fires. Note cloneNode does not copy listeners, which is why c starts clean.
target versus currentTarget in delegation
// <ul id='list'><li id='row'><button id='del'>x</button></li></ul>
document.getElementById('list').addEventListener('click', (e) => {
console.log(e.target.id, e.currentTarget.id);
console.log(e.target.closest('li').id);
});
document.getElementById('del').click();Show the output
del list
rowtarget is the exact node clicked - the button, not the row. currentTarget is where the listener lives. closest walks up from target until it matches, which is how you recover the row no matter how deeply the click landed. Reading e.target.id directly is the bug that breaks the moment someone puts an icon inside the button.
Removing an anonymous listener
const b = document.getElementById('inner');
b.addEventListener('click', () => console.log('hi'));
b.removeEventListener('click', () => console.log('hi'));
b.click();
const named = () => console.log('bye');
b.addEventListener('click', named);
b.removeEventListener('click', named);
b.click();Show the output
hiTwo identical-looking arrow functions are two different objects, so removeEventListener finds nothing to remove and fails silently - no error, no return value. The named reference removes cleanly, so the second click logs only the leftover first listener. .bind(this) has the same problem for the same reason.
this in a handler
const obj = {
id: 'obj',
normal() { console.log(this.id); },
arrow: null,
};
obj.arrow = () => console.log(obj.id);
const b = document.getElementById('inner');
b.addEventListener('click', obj.normal);
b.addEventListener('click', obj.arrow);
b.click();Show the output
inner
objPassing a method as a listener detaches it: this becomes currentTarget, which is the button, so this.id is the element's id. The arrow captured obj lexically at creation and is unaffected by how it is called. This is the single most common source of 'this.setState is not a function' in pre-hooks class components.
Storage only holds strings
localStorage.setItem('user', { name: 'nova' });
console.log(localStorage.getItem('user'));
localStorage.setItem('n', 42);
console.log(typeof localStorage.getItem('n'));
console.log(localStorage.getItem('nothing'));
console.log(JSON.parse(localStorage.getItem('nothing')));Show the output
[object Object]
string
null
nullsetItem coerces its value with String(), so an object becomes the literal text [object Object] and is unrecoverable. Everything comes back as a string, including numbers and booleans - 'false' is truthy. A missing key returns null, and JSON.parse(null) happens to work because null stringifies to 'null', which is why the missing-key path often looks fine right up until the value is a plain string.
focus does not bubble, focusin does
// <form id='f'><input id='i'></form>
const f = document.getElementById('f');
f.addEventListener('focus', () => console.log('focus on form'));
f.addEventListener('focusin', () => console.log('focusin on form'));
document.getElementById('i').focus();Show the output
focusin on formfocus and blur do not bubble, so a delegated listener on the form never hears them. focusin and focusout are the bubbling equivalents and are what you delegate with. The same pairing exists for mouseenter/mouseleave (no bubble) and mouseover/mouseout (bubble).
preventDefault on a checkbox
// <input id='cb' type='checkbox'>
const cb = document.getElementById('cb');
cb.addEventListener('click', (e) => {
console.log('handler sees:', cb.checked);
e.preventDefault();
});
cb.click();
console.log('after:', cb.checked);Show the output
handler sees: true
after: falseThe checkbox toggles BEFORE the click event is dispatched, so your handler observes the new state. Cancelling the event runs the browser's undo step and reverts it. That is why a controlled checkbox that calls preventDefault appears to ignore clicks, and why reading e.target.checked inside the handler is not the same as reading it afterwards.
Delegation survives elements added later
// <ul id='list'></ul>
const list = document.getElementById('list');
list.addEventListener('click', (e) => {
const li = e.target.closest('li');
console.log(li ? li.dataset.id : 'no row');
});
list.innerHTML = '<li data-id="7">seven</li>';
list.querySelector('li').click();
list.click();Show the output
7
no rowThe listener was attached before the row existed and still handles it, because the click passes through the ul on its way up - that is the real reason to delegate. The second call clicks the ul itself, where closest('li') finds nothing and returns null, which is why the guard is not optional.
once, and AbortController teardown
const b = document.getElementById('inner');
b.addEventListener('click', () => console.log('once'), { once: true });
const ac = new AbortController();
b.addEventListener('click', () => console.log('signal'), { signal: ac.signal });
b.click();
b.click();
ac.abort();
b.click();Show the output
once
signal
signalonce removes the listener after its first invocation, so it appears exactly one time. The signal listener survives until abort(), then stops. One controller can carry every listener a component registers, so teardown is a single abort() call and there are no function references to keep.
window, document and the global scope
// A classic (non-module) script.
var a = 1;
let b = 2;
console.log(window.document === document);
console.log(window === globalThis);
console.log(window.a, window.b);Show the output
true
true
1 undefineddocument is a property of window, which is why the bare name works. A top-level var in a classic script becomes a property of the global object; let and const create bindings in the script scope and do not. Inside an ES module, neither does, and top-level this is undefined rather than window.
Cookies from JavaScript
document.cookie = 'a=1';
document.cookie = 'b=2';
console.log(document.cookie);
document.cookie = 'a=3; max-age=0';
console.log(document.cookie);Show the output
a=1; b=2
b=2document.cookie is a strange accessor: assigning to it adds or replaces ONE cookie rather than overwriting the string, and reading gives you name=value pairs only - no attributes, and nothing marked HttpOnly, which is exactly the point of that flag. Deleting means re-setting with max-age=0 (or a past expiry) and the same path and domain.
DOMContentLoaded versus load
// The document contains one <img> with a slow src.
document.addEventListener('DOMContentLoaded', () => console.log('dom'));
window.addEventListener('load', () => console.log('load'));
console.log('sync');Show the output
sync
dom
loadSynchronous script runs first. DOMContentLoaded fires once the HTML is parsed and deferred scripts have run - it does not wait for the image. load waits for every subresource, which on a real page can be seconds later and hostage to one slow request. Initialise on the first; measure image dimensions on the second.
Reading forces layout
const box = document.getElementById('outer');
box.style.width = '100px';
console.log(box.offsetWidth);
box.style.width = '200px';
console.log(box.offsetWidth);Show the output
100
200Style writes are normally batched until the browser needs to paint - but offsetWidth cannot be answered without a current layout, so each read forces one synchronously. The values are correct, and that is the trap: the code is right and slow. In a loop over fifty elements this is fifty forced layouts, and batching all reads before all writes removes forty-nine of them.
A custom event that does not bubble
const outer = document.getElementById('outer');
const inner = document.getElementById('inner');
outer.addEventListener('saved', (e) => console.log('heard', e.detail.id));
inner.dispatchEvent(new CustomEvent('saved', { detail: { id: 1 } }));
inner.dispatchEvent(new CustomEvent('saved', { detail: { id: 2 }, bubbles: true }));Show the output
heard 2bubbles defaults to false on a constructed event, so the first dispatch never reaches the ancestor and nothing logs. Native UI events mostly bubble, which is why the default catches people out. detail is the only place a payload belongs; adding your own properties to the event object works but is not what listeners will look for.
Write it from memory
Complete, runnable code. Practice typing it from memory before your interview.
A delegated list handler
One listener for a whole list, handling rows that do not exist yet, and telling apart two different buttons inside the same row. This is the answer to 'wire up this list' in a coding round.
function mountList(listEl, { onDelete, onEdit }) {
const controller = new AbortController();
listEl.addEventListener(
'click',
(event) => {
const action = event.target.closest('[data-action]');
if (!action || !listEl.contains(action)) return;
const row = action.closest('[data-id]');
if (!row) return;
const id = row.dataset.id;
if (action.dataset.action === 'delete') onDelete(id, row);
if (action.dataset.action === 'edit') onEdit(id, row);
},
{ signal: controller.signal }
);
// Keyboard parity: rows are focusable, Enter activates the primary action.
listEl.addEventListener(
'keydown',
(event) => {
if (event.key !== 'Enter') return;
const row = event.target.closest('[data-id]');
if (row) onEdit(row.dataset.id, row);
},
{ signal: controller.signal }
);
return () => controller.abort();
}
// Usage:
// <ul id="rows">
// <li data-id="1">One <button data-action="edit">Edit</button>
// <button data-action="delete">Delete</button></li>
// </ul>
const unmount = mountList(document.getElementById('rows'), {
onDelete: (id, row) => row.remove(),
onEdit: (id) => console.log('edit', id),
});- closest('[data-action]') rather than reading event.target directly - a click on an icon or a span inside the button still resolves.
- The listEl.contains guard matters because closest can walk past the list entirely if the listener is ever moved to document.
- Returning the abort function is the whole teardown story: no stored references, no matching removeEventListener calls.
- Buttons rather than divs, so Enter and Space already work; the keydown block is only for making the ROW itself activatable.
A safe localStorage wrapper
The plain-JS core of a useLocalStorage hook: read, write and remove without ever throwing, with JSON handled in one place and cross-tab updates delivered.
const storage = {
get(key, fallback = null) {
try {
const raw = window.localStorage.getItem(key);
if (raw === null) return fallback;
return JSON.parse(raw);
} catch {
// Blocked storage, or a value written by an older version that
// is not valid JSON. Neither is worth breaking a render for.
return fallback;
}
},
set(key, value) {
try {
window.localStorage.setItem(key, JSON.stringify(value));
return true;
} catch {
// QuotaExceededError, or private-mode / blocked site data.
return false;
}
},
remove(key) {
try {
window.localStorage.removeItem(key);
} catch {}
},
// Fires in OTHER tabs of this origin. Returns an unsubscribe.
subscribe(key, onChange) {
const handler = (event) => {
if (event.storageArea !== window.localStorage) return;
if (event.key !== key && event.key !== null) return;
onChange(storage.get(key));
};
window.addEventListener('storage', handler);
return () => window.removeEventListener('storage', handler);
},
};
// Usage
storage.set('prefs', { theme: 'dark' });
console.log(storage.get('prefs', {}).theme); // 'dark'
const stop = storage.subscribe('prefs', (v) => console.log('changed in another tab', v));- try/catch on the READ as well as the write: a browser set to block site data throws on access, not only on quota.
- A null event.key means the whole storage was cleared, so that case has to pass the filter too.
- The storage event does not fire in the tab that made the change - if you need same-tab listeners, call onChange yourself after set().
- JSON.parse lives in exactly one place, so a corrupted value degrades to the fallback instead of throwing at the call site.