JavaScript NotesRohit’s interview study guide
Chapter 08

Browser, DOM & Events

Script loading, event propagation, delegation (and how React does it), debounce and throttle, passive listeners, storage and CSS specificity.

11 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Loading scripts: defer, async and type="module"

The browser builds a page by reading the HTML from top to bottom and turning each tag into a DOM node as it goes. When that parser meets a classic <script>, it has to stop and run the script before it parses anything else. The reason is historical but still binding: a script is allowed to change what comes next — it can call document.write() to inject more markup, or read and modify the elements parsed so far — so the parser can't safely carry on until the script has been downloaded and executed.

That costs you twice. First, the user stares at a blank or half-built page while the script travels over the network; a large script in the <head> delays everything below it. Second, the script can only see elements above it, so document.getElementById for anything further down returns null. The old workaround was to put scripts at the end of <body>. defer, async and modules fix it properly by letting the download happen in parallel with parsing and only differing in when the script runs.

A mental model: the parser is someone reading a book aloud. A plain script is a phone call they must make and finish before reading the next sentence. async is a parcel someone else fetches — the reader is interrupted the moment it arrives, whatever page they're on. defer is a stack of notes to read, in order, once the last chapter is done.

HTML
<script src="app.js"></script>                <!-- blocks parsing -->
<script src="app.js" defer></script>          <!-- runs after parsing, in order -->
<script src="analytics.js" async></script>    <!-- runs as soon as it arrives, any order -->
<script type="module" src="main.js"></script> <!-- deferred by default + import/export -->
What’s happening
  1. Plain <script src> — the parser reaches the tag, stops, requests app.js, waits for the network, runs the file, and only then moves to the next tag. During that wait nothing below the tag exists in the DOM, and nothing new is shown. (Browsers have a preload scanner that peeks ahead and starts downloading later files early, but it can't build DOM, so parsing is still stuck.)
  2. defer — the download starts the moment the parser sees the tag, and parsing carries on. When the whole document has been parsed, deferred scripts run in the order they appear in the HTML — even if a later one finished downloading first — and then DOMContentLoaded fires. defer only works on external scripts; on an inline <script> with no src it is ignored.
  3. async — also downloads in parallel, but runs the moment it arrives, pausing the parser just for the execution. Two async scripts run in whichever order their downloads finish, and DOMContentLoaded doesn't wait for them, so one might run before the DOM is complete and another after.
  4. type="module" — deferred by default, and unlike classic scripts this applies to inline modules too. Before running, the browser also fetches every file in its import graph. Adding async to a module makes it run as soon as it and all its imports are ready, ignoring order.
  5. Only the first line makes the user wait for the network. The other three differ in when they run and whether order is kept — which is exactly what the diagram and table below compare.
AdvancedDiagram: script loading timelines
<script> parsing stops while the script downloads and runs <script async> <script defer> <script type="module"> HTML parsing download execute DOMContentLoaded
Only a plain script stops parsing for its download. async pauses parsing just to run; defer and modules wait until parsing is done.
AdvancedComparison of script loading modes
Blocks parsing during downloadRunsOrder keptDOM ready when it runs
<script>YesImmediatelyYesOnly elements above it
asyncNoAs soon as downloadedNoNot guaranteed
deferNoAfter parsing, before DOMContentLoadedYesYes
type="module"NoLike defer (add async to change)YesYes

The second cost — a script only seeing what's above it — is the bug most people hit first:

HTML
<head>
  <script>
    const btn = document.getElementById("save");
    btn.addEventListener("click", save); // TypeError — btn is null
  </script>
</head>
<body>
  <button id="save">Save</button>
</body>
What’s happening
  1. The parser reaches the inline script in the <head> and runs it immediately. At this moment the DOM holds only <html>, <head> and the script itself — <body> hasn't been read yet.
  2. getElementById("save") doesn't throw; it simply finds nothing and returns null. So btn is null.
  3. null.addEventListener(…) throws a TypeError (Chrome words it Cannot read properties of null). The rest of the script is abandoned, and the button later appears on screen with no handler — the page looks fine but does nothing.
  4. Fixes: move the code to an external file loaded with defer, make it type="module" (which defers even inline code), or wrap it in a DOMContentLoaded listener. Adding defer to this inline script would do nothing, because defer needs a src.
AdvancedChoosing defer, async or module
AdvancedBuild tools and script loading

In practice, modern build tools already do this for you — Vite's index.html, for example, loads your entry point with <script type="module">. You'll still meet async on analytics and ad snippets, and you'll still hit the null bug above in hand-written pages and in interview questions about why a script "can't find" an element.

#Event propagation: capturing and bubbling

An event is never delivered to just one element. When you click a button, you have also clicked the <div> around it, the <body>, and the whole document. The DOM models this by sending one event object on a round trip: down from window to the element that was clicked, then back up again. The route — the event path — is worked out once when the event starts, from the target's chain of ancestors, so moving or removing elements mid-event doesn't change who it visits.

A mental model: a lift that goes down a building to one floor and then back up to the roof. Listeners are people waiting on each floor. A few of them want to catch the lift on the way down (capture); most wait for it on the way up (bubble).

When you click an element, the event travels in three phases:

  1. Capturing — from window down through each ancestor to the target.
  2. Target — at the element that was clicked.
  3. Bubbling — back up from the target through each ancestor to window.

You can read the current phase from e.eventPhase: 1 capturing, 2 at target, 3 bubbling.

addEventListener listens in the bubbling phase by default. Pass { capture: true } (or true) to listen on the way down instead.

HTML
<div id="outer">
  <button id="inner">Click</button>
</div>
AdvancedCapture, target and bubble in order
JavaScript
const outer = document.getElementById("outer");
const inner = document.getElementById("inner");

outer.addEventListener("click", () => console.log("outer capture"), { capture: true });
outer.addEventListener("click", () => console.log("outer bubble"));
inner.addEventListener("click", () => console.log("inner"));

// Clicking the button logs:
// outer capture → inner → outer bubble
What’s happening
  1. Setup: #outer has two listeners (one capture, one bubble) and #inner has one bubble listener. When you click the button, the browser builds the path window → document → <html> → <body> → div#outer → button#inner.
  2. Capture phase (eventPhase 1): the event walks down from window. window, document, <html> and <body> have no capture listeners, so nothing runs. At #outer the capture listener fires and logs outer capture. Here e.target is button#inner (where the click happened) and e.currentTarget is div#outer (whose listener is running). outer's bubble listener is skipped on the way down.
  3. Target phase (eventPhase 2): the event arrives at the button. Its listener runs and logs inner. Now e.target and e.currentTarget are both button#inner.
  4. Bubble phase (eventPhase 3): the event climbs back up. At #outer the non-capture listener fires and logs outer bubble; e.target is still the button, e.currentTarget is #outer again. It then passes <body>, <html>, document and window, finds no listeners, and the dispatch ends.
  5. If you click the padding of #outer outside the button, the target is #outer itself and the button isn't on the path at all, so inner never logs. Both of #outer's listeners now run in the target phase: outer capture, then outer bubble (current browsers run capture listeners on the target before its non-capture ones).
  6. The pattern to remember: e.target is fixed for the whole trip; e.currentTarget and e.eventPhase change at every stop.
AdvancedThe capture flag and removeEventListener

A listener is identified by three things: the event type, the function, and the capture flag. So outer.removeEventListener("click", fn) will not remove a listener added with { capture: true } — you must pass { capture: true } again. Other options like once or passive don't take part in the match.

Capture listeners are rare but useful when you must see an event before any child can stop it — for example a document-level "click outside to close" or analytics listener registered with { capture: true }, which still fires even if a component calls stopPropagation() during bubbling.

AdvancedEvents that don't bubble

#event.target vs event.currentTarget

Because one event object visits many elements, it needs two "where" properties. target answers where did this start? and is set once, before the trip begins. currentTarget answers which element am I being handled at right now? and the browser updates it before each listener runs.

Think of a parcel moving through post offices: target is the address it was sent from and never changes; currentTarget is the office it is sitting in right now.

  • event.target — the element where the event started (what was actually clicked).
  • event.currentTarget — the element whose listener is running right now (where you attached it). Inside a regular-function handler, this === event.currentTarget.
JavaScript
document.querySelector("ul").addEventListener("click", (e) => {
  console.log(e.target.tagName);        // "LI" (or a <span> inside it!)
  console.log(e.currentTarget.tagName); // "UL" — always the element with the listener
});
What’s happening
  1. Assume the list is <ul><li><span>Milk</span> (2 litres)</li></ul>. The listener is attached to the <ul> only.
  2. Click on the word "Milk": the deepest element under the pointer is the <span>, so the event starts there and bubbles span → li → ul. When the <ul> listener runs, e.target.tagName is "SPAN" and e.currentTarget.tagName is "UL".
  3. Click on "(2 litres)": that's a text node, and text nodes are never event targets — the target is the element that contains the text, the <li>. Logs "LI" and "UL".
  4. Click on the <ul>'s own padding, between items: the target is the <ul> itself, so both lines log "UL".
  5. tagName is upper-case for HTML elements. currentTarget is "UL" in every case because that's where the listener lives; only target varies with exactly where the pointer was. That's why delegation code (next topics) normalises the target with closest() instead of trusting it.
Advancedthis and currentTarget after the handler

Two more details catch people out — this, and what happens after the handler returns:

JavaScript
list.addEventListener("click", function (e) {
  console.log(this === e.currentTarget); // true — a regular function's `this` is the element
  setTimeout(() => {
    console.log(e.target);        // still the clicked element
    console.log(e.currentTarget); // null — the dispatch has finished
  }, 0);
});
What’s happening
  1. The browser calls a listener with this set to currentTarget, so with a regular function the first line logs true. With an arrow function this would be whatever it was outside (often undefined in a module), so in arrows always use e.currentTarget.
  2. The setTimeout callback is a macrotask: it runs after the click dispatch has completely finished.
  3. e.target keeps its value forever, so the callback still sees the clicked element.
  4. e.currentTarget only means something during dispatch; when the event finishes its trip the browser sets it to null. So the callback logs null. The same thing happens after an await inside an async handler, and when you console.log(e) and expand it later in DevTools.
  5. The fix is to copy it synchronously: const el = e.currentTarget; at the top of the handler, then use el later.
AdvancedShadow DOM retargeting

One more case: if the click happens inside a web component's shadow DOM, listeners outside the component see e.target retargeted to the host element, so the component's internals stay hidden. For an open shadow root, e.composedPath() gives you the full path if you really need it.

#stopPropagation and preventDefault

These are two independent switches on the event object. Propagation is about which other listeners get to see the event. The default action is what the browser itself does once the listeners are done: follow a link, submit a form, type the character for a keydown, open the context menu, scroll on a wheel or touch. The browser runs all the listeners first, then checks e.defaultPrevented to decide whether to do its default — which is why preventDefault() has to be called synchronously inside the handler, not after an await. (Checkboxes are a slight exception: the browser toggles the box before your listener runs and toggles it back if you cancel, so inside a click handler checked already shows the new value.)

A mental model: stopPropagation() says "nobody else needs to hear about this"; preventDefault() says "browser, don't do your usual thing". Neither one implies the other.

These do completely different things:

What it stops
e.stopPropagation()The event travelling to other elements (further up/down the tree)
e.stopImmediatePropagation()Other elements and the remaining listeners on this same element
e.preventDefault()The browser's default action — following a link, submitting a form, checking a box, scrolling on touch
JavaScript
// Handle a form with JavaScript instead of reloading the page
form.addEventListener("submit", (e) => {
  e.preventDefault();
  const data = new FormData(form);
  fetch("/api/signup", { method: "POST", body: data });
});

// A click inside a modal shouldn't reach the backdrop's "close" handler
modal.addEventListener("click", (e) => e.stopPropagation());
backdrop.addEventListener("click", closeModal);
What’s happening
  1. The user presses Enter in a field or clicks the submit button, and the browser fires submit on the form (not on the button). Its default action is to navigate to the form's action URL with the field values — a full page load that wipes out your JavaScript state.
  2. e.preventDefault() sets e.defaultPrevented to true. When the listener returns, the browser sees the flag and skips the navigation. It's the first line on purpose: if a later line threw an error, the page still wouldn't reload and lose the error.
  3. new FormData(form) collects every field that has a name — text inputs, checked boxes, selected options, files. Passed as a fetch body, it's sent as multipart/form-data, and the browser writes the Content-Type header with its boundary for you (don't set it by hand). Note that preventDefault didn't stop propagation: the submit event still bubbles on to document.
  4. The modal part assumes markup like <div class="backdrop"><div class="modal">…</div></div>. Click a button inside the modal: the target is that button, the event bubbles to .modal, and its listener calls stopPropagation(). The event never reaches .backdrop, so closeModal doesn't run. Any other listeners on .modal itself still run — that's the difference from stopImmediatePropagation().
  5. Click on the dark area around the modal: the target is .backdrop itself, .modal isn't on the path, and closeModal runs.
AdvancedPitfall: blocking handlers further up
AdvancedClosing a modal without stopPropagation

Here is that better fix for the modal — no stopPropagation at all:

JavaScript
backdrop.addEventListener("click", (e) => {
  if (e.target === backdrop) closeModal(); // only clicks that started on the backdrop itself
});
What’s happening
  1. Click inside the modal: the event bubbles up to .backdrop, so this listener runs, but e.target is the element inside the modal, not backdrop. The if fails and nothing happens.
  2. Because nobody stopped it, the event keeps bubbling to document, so analytics, dropdown "click outside" handlers and framework delegation all still see it.
  3. Click on the dark area: e.target is backdrop (and so is e.currentTarget), the check passes, and the modal closes.
AdvancedstopPropagation vs stopImmediatePropagation

And the difference between the two "stop" methods, on one element:

JavaScript
button.addEventListener("click", (e) => {
  console.log("first");
  e.stopImmediatePropagation();
});
button.addEventListener("click", () => console.log("second"));     // never runs
document.addEventListener("click", () => console.log("document")); // never runs
// Clicking the button logs only: first
What’s happening
  1. Listeners on the same element run in the order they were added, so the first one runs and logs first.
  2. stopImmediatePropagation() does two things: it skips the remaining listeners on this element (so second never logs) and stops the event moving to any other element (so document's listener never runs).
  3. If you changed it to stopPropagation(), the log would be first, second — every listener on the button still runs — but document would still be skipped.
  4. Separately, preventDefault() only works if the event is cancelable (e.cancelable) and the listener isn't passive — see Passive event listeners.

#Event delegation

Instead of attaching a listener to every child, attach one listener to a common parent and use event.target to work out which child was clicked. This works because of bubbling.

It works because every click on anything inside the list has to bubble through the list on its way up. The parent sees every one of those clicks and can ask "which of my children did this come from?". It's a receptionist at the front desk instead of a guard at every door: one person, and new rooms are covered the moment they're built.

HTML
<ul id="item-list">
  <li data-id="1">Item 1 <button class="delete">✕</button></li>
  <li data-id="2">Item 2 <button class="delete">✕</button></li>
  <li data-id="3">Item 3 <button class="delete">✕</button></li>
</ul>
AdvancedDelegation with closest()
JavaScript
document.getElementById("item-list").addEventListener("click", (event) => {
  // closest() handles clicks on nested elements inside the <li>
  const deleteBtn = event.target.closest(".delete");
  if (deleteBtn) {
    deleteBtn.closest("li").remove();
    return;
  }

  const li = event.target.closest("li");
  if (li && event.currentTarget.contains(li)) {
    console.log("List item clicked:", li.dataset.id);
  }
});
What’s happening
  1. There is exactly one listener, on ul#item-list. Every click inside the list bubbles up to it; event.currentTarget is always the <ul>, and event.target is whatever was really clicked.
  2. Click the ✕ in item 2: the target is that <button class="delete">. closest(".delete") checks the element itself first, then each ancestor, and returns the first match — here the button. deleteBtn.closest("li") walks up to <li data-id="2">, and .remove() takes it out of the DOM. return ends the handler, so a delete isn't also treated as a "select".
  3. Click the text "Item 2": text nodes are never targets, so the target is the <li>. closest(".delete") finds nothing on the <li> or above it and returns null. closest("li") returns the <li> itself, contains is true, and it logs List item clicked: 2. dataset.id is the string "2" — data-* values are always strings.
  4. Why the contains check: closest() doesn't stop at the <ul>; it keeps walking up to <html>. If this list sits inside another <li> (a nested menu, say), a click on the <ul>'s padding would find that outer <li>, which isn't one of our items. event.currentTarget.contains(li) confirms the match is inside the element that owns the listener.
  5. Add a fourth item later, e.g. with insertAdjacentHTML("beforeend", …), and it just works — its clicks bubble to the same <ul> and go through the same code. No new listener, no re-binding. That's the main reason delegation exists.
AdvancedBenefits of event delegation

Benefits:

  • One listener instead of hundreds — less memory, faster setup.
  • Works for elements added later — new <li>s are handled automatically, no re-binding.
  • Less cleanup — nothing to remove when children are removed.

When it's the wrong tool:

  • Events that don't bubble (focus, blur, mouseenter) never reach the parent — use focusin/focusout/mouseover, or listen with { capture: true }.
  • Very frequent events like mousemove delegated high up run your handler (and its closest() calls) for every movement anywhere inside.
  • A child that calls stopPropagation() makes its events invisible to the delegate.
AdvancedPitfall: clicks on nested children

#Event delegation in React

React uses event delegation for you. Even though you write onClick on individual elements, React attaches one native listener per event type (and phase) to the root container — the element you pass to createRoot; before React 17 it was document. It does this once, up front, for nearly every event it supports; a few that don't bubble natively (media events such as onPlay, and onScroll since React 17) are attached to the element itself.

When an event bubbles up to the root, React works out which component rendered the target from its internal fiber tree, then calls your handlers in the right order with a SyntheticEvent — a cross-browser wrapper around the native event with the same interface (e.target, e.preventDefault(), e.stopPropagation()). The original is still there as e.nativeEvent.

React
function List({ items, onSelect }) {
  // No real listener is attached to each <li> — React delegates to the root
  return (
    <ul>
      {items.map((item) => (
        <li key={item.id} onClick={() => onSelect(item.id)}>
          {item.name}
        </li>
      ))}
    </ul>
  );
}
What’s happening
  1. Say items is [{ id: 1, name: "Milk" }, { id: 2, name: "Bread" }]. React creates the <ul> and two <li> DOM nodes and stores each onClick in that element's props on its fiber — it never calls addEventListener on the <li>s. Each () => onSelect(item.id) is a closure over that iteration's item, so the second one remembers id 2.
  2. You click "Bread". The native click starts at that <li>, travels down through the root container (React's capture listener runs there, for any onClickCapture props), reaches the <li>, which has no native listener, and bubbles back up through the <ul> to the root, where React's bubble listener runs.
  3. React looks up the fiber for the native target (it keeps a reference to the fiber on every DOM node it creates) and walks up the fiber tree to the root, collecting every onClick it finds — here only the <li>'s.
  4. It creates one SyntheticEvent and calls the collected handlers innermost first. For each handler it sets e.currentTarget to that handler's own element (the <li>), even though natively the event is sitting at the root — e.nativeEvent.currentTarget is the root container at that moment.
  5. Your handler runs onSelect(2). If the <ul> also had an onClick, it would run next, unless the <li> handler called e.stopPropagation().
  6. You wrote per-element handlers; React ran a single delegated listener — the same pattern as the vanilla closest() code above, done for you.
AdvancedWhy React delegates to the root

Why: attaching real listeners to every element in a big component tree would cost a lot of memory and setup time. Delegating to the root uses the browser's own bubbling, and it's why React 17 moved to the root — so multiple React versions (or React inside a non-React page) don't fight over document.

React 17 also removed event pooling. In React 16 and earlier, SyntheticEvent objects were reused and their fields wiped after your handler returned, so reading e.target inside a setTimeout gave null unless you called e.persist(). From 17 on, the event object is yours to keep.

AdvancedstopPropagation in React handlers

#Debouncing

Some events arrive in bursts — one input event per keystroke, dozens of resize events while a window is dragged — but you only care about the state at the end of the burst. Running an API search on every keystroke wastes requests and can show results out of order.

Debouncing waits until events stop for a set time, then runs the function once. Ideal for search-as-you-type, resize handlers, autosave and form validation.

The name comes from electronics: a mechanical switch "bounces" and sends several signals for one press, and debouncing collapses them into one. A good mental model is a lift door: every person who steps in resets the close timer, and the door only closes once nobody has entered for a few seconds.

debounce.js
function debounce(func, delay) {
  let timeoutId;

  return function (...args) {
    clearTimeout(timeoutId); // cancel the previously scheduled call
    timeoutId = setTimeout(() => func.apply(this, args), delay); // keep `this` and args
  };
}
What’s happening
  1. debounce(func, delay) runs once. It creates timeoutId (starting as undefined) and returns a new function. That returned function closes over func, delay and timeoutId — and it's the same timeoutId for every call you make to it. That shared variable is what lets one call cancel the timer set by a previous call.
  2. Each call first runs clearTimeout(timeoutId), cancelling the call scheduled last time so it can never fire. On the very first call timeoutId is undefined, and clearTimeout(undefined) simply does nothing.
  3. Then setTimeout schedules a fresh call delay ms from now and stores the new id in timeoutId, overwriting the old one. The call only happens if no other event arrives within delay — otherwise step 2 cancels it.
  4. The wrapper is a regular function (...args) so it receives the caller's this (e.g. the input element, if you pass the debounced function straight to addEventListener). The inner arrow has no this of its own, so func.apply(this, args) forwards that same this. Each call gets its own args array, but only the last scheduled arrow survives, so func always receives the latest arguments.
  5. The debounced function itself returns undefined — func runs later, so its return value has nowhere to go. If you need a result, have func update state or resolve a promise.
AdvancedDebounced search in action

The timeoutId lives in a closure, so it survives between calls.

JavaScript
function searchQuery(query) {
  console.log("Searching for:", query);
}

const debouncedSearch = debounce(searchQuery, 300);

document.getElementById("search-input").addEventListener("input", (event) => {
  debouncedSearch(event.target.value); // fires once, 300ms after typing stops
});
What’s happening
  1. debouncedSearch is created once, at the top level, so there is one timeoutId for the lifetime of the page. Say the user types "react" with keystrokes at t = 0, 100, 200, 250 and 320ms.
  2. t=0, value "r": timeoutId is undefined, so clearTimeout does nothing. Timer A is scheduled for t=300 with args ["r"]; timeoutId now holds A.
  3. t=100, "re": A is cleared (it will never fire) and timer B is scheduled for t=400 with ["re"]. t=200, "rea": B cleared, C set for t=500. t=250, "reac": C cleared, D set for t=550.
  4. t=320, "react": D cleared, timer E set for t=620 with ["react"]. timeoutId holds E.
  5. No more keystrokes, so nothing clears E. At t=620 it fires and logs Searching for: react — one search instead of five, 300ms after the last keystroke.
  6. If the user paused for more than 300ms mid-word — say 400ms after "rea" — timer C would fire and search for "rea", then a second search would follow later. Debounce measures gaps, not words.
AdvancedCreating debounce in the wrong place

The most common bug with debounce is creating it in the wrong place. Inside a React component, const debounced = debounce(fn, 300) in the component body runs on every render, so every keystroke (which re-renders) gets a brand-new debounced function with a brand-new, empty timeoutId — nothing ever gets cancelled, and you get one call per keystroke, just delayed. Create it once (useMemo or useRef) and cancel the pending timer when the component unmounts. Lodash's debounce also offers leading: true, which runs on the first event and ignores the rest of the burst — handy for stopping a double-clicked "Pay" button from submitting twice.

#Throttling

Throttling runs the function at most once every N ms, no matter how often the event fires. Ideal for scroll position, mousemove, drag and window resize when you need continuous updates but not hundreds per second.

Debounce waits for silence, so during a long scroll it would never run at all. Throttle guarantees a steady rhythm while the burst is still going. A mental model: a shuttle bus that leaves at most once every 200ms. However many passengers turn up, departures are spaced out — and a passenger who arrives just after a bus has left still gets the next one (the trailing call), so the last event isn't dropped.

throttle.js
function throttle(func, interval) {
  let lastCall = 0;
  let timeoutId;
  let lastArgs;
  let lastThis;

  return function (...args) {
    const now = Date.now();
    const remaining = interval - (now - lastCall);
    lastArgs = args;                         // always remember the newest event
    lastThis = this;

    if (remaining <= 0) {
      clearTimeout(timeoutId);
      timeoutId = undefined;
      lastCall = now;
      func.apply(this, args);                // leading call
    } else if (!timeoutId) {
      timeoutId = setTimeout(() => {         // trailing call, so the last event isn't lost
        lastCall = Date.now();
        timeoutId = undefined;
        func.apply(lastThis, lastArgs);
      }, remaining);
    }
  };
}

window.addEventListener("scroll", throttle(() => {
  console.log("scroll position:", window.scrollY);
}, 200));
What’s happening
  1. The closure holds four variables: lastCall (when func last actually ran — 0, i.e. 1970, so the very first call always passes), timeoutId (a pending trailing call, or undefined), and lastArgs/lastThis (the newest call's arguments and this).
  2. Every call computes remaining = how long until another run is allowed, and records its arguments in lastArgs.
  3. remaining <= 0 — enough time has passed, so run now (the leading call) and set lastCall. Any pending trailing timer is cleared first; otherwise it would run func a second time a moment later.
  4. Otherwise, if no timer is pending, schedule a trailing call for exactly when the interval expires. When it fires it updates lastCall, clears timeoutId and runs func with lastArgs — the newest event, not the one that happened to schedule the timer.
  5. Otherwise a trailing call is already pending, so the event does nothing except update lastArgs.
  6. At the bottom, throttle(…) runs once and returns the actual listener. The browser can fire scroll many times a second; the log appears at most every 200ms. This callback reads window.scrollY live and ignores its arguments, which is common for scroll handlers.
AdvancedThrottle timeline with real numbers

Here is the timeline with real numbers — six events, 60ms apart, with a 200ms interval. This runs as-is in Node:

JavaScript
const log = throttle((label) => console.log("ran with", label), 200);

for (const t of [0, 60, 120, 180, 240, 300]) {
  setTimeout(() => log(`event@${t}`), t);
}
// ran with event@0     (at ~0ms)
// ran with event@180   (at ~200ms)
// ran with event@300   (at ~400ms)
What’s happening
  1. t=0: lastCall is 0, so remaining is hugely negative. func runs immediately with event@0, and lastCall becomes t=0.
  2. t=60: remaining = 200 − 60 = 140, and no timer is pending, so a trailing timer is set for t=200. lastArgs is ["event@60"].
  3. t=120 and t=180: remaining is 80, then 20, but a timer is pending, so the only effect is lastArgs becoming ["event@120"], then ["event@180"].
  4. t=200: the timer fires. lastCall becomes 200, timeoutId goes back to undefined, and func runs with event@180.
  5. t=240: remaining = 200 − 40 = 160 and no timer is pending, so one is set for t=400. t=300 just updates lastArgs. At t=400 the timer fires with event@300. No more events, so nothing else runs.
  6. Six events became three calls, exactly 200ms apart, and the final event's data made it through. That's why the closure keeps lastArgs: a simpler version that reuses the args of the call that scheduled the timer would log event@60 and event@240 instead — stale data, which matters as soon as the handler uses its argument.
AdvancedDebounce vs throttle compared
DebounceThrottle
FiresOnce, after the burst endsRegularly, during the burst
An event every 50ms for 3 seconds, 200ms setting1 call, 200ms after the last event16 calls, one every 200ms (t = 0, 200 … 3000)
Typical useSearch box, autosave, resize endScroll, mousemove, infinite scroll, game input
AdvancedrAF throttling and Lodash helpers
AdvancedA requestAnimationFrame scroll throttle

The requestAnimationFrame version looks like this:

JavaScript
let ticking = false;

window.addEventListener("scroll", () => {
  if (ticking) return;
  ticking = true;
  requestAnimationFrame(() => {
    updateHeader(window.scrollY);
    ticking = false;
  });
}, { passive: true });
What’s happening
  1. scroll can fire several times between two screen repaints. The first one finds ticking false, sets it to true, and asks for a callback before the next paint.
  2. Every other scroll event before that paint sees ticking is true and returns immediately — cheap.
  3. Just before the browser paints the next frame (about every 16.7ms on a 60Hz screen), the callback runs, reads the latest scrollY, updates the header and resets ticking.
  4. The effect is "at most once per frame, timed exactly with painting", so the work is never done twice for a frame nobody sees. { passive: true } is explained in the next topic.

#Passive event listeners

Touch and wheel listeners can call preventDefault() to stop scrolling, so the browser normally has to wait for your handler to finish before it scrolls — any slow handler makes scrolling janky.

Why that hurts: modern browsers scroll on a separate compositor thread, so scrolling can stay smooth even while the main thread is busy running JavaScript. But if a touchmove or wheel listener might cancel the scroll, the compositor can't move the page until the main thread has run that listener and reported back. A busy main thread — a long task, a slow handler — then freezes the scroll, even if your handler never cancels anything.

Marking a listener passive: true promises you won't call preventDefault(), so the browser can scroll immediately on the compositor thread.

JavaScript
window.addEventListener(
  "touchmove",
  (e) => {
    updateParallax(window.scrollY);
    // e.preventDefault() here would be ignored (and log a warning)
  },
  { passive: true },
);
What’s happening
  1. Registering with { passive: true } tells the browser up front that this listener will never cancel the scroll. If every touchmove listener on the path is passive, the compositor starts scrolling as soon as the finger moves.
  2. Your listener still runs for each touchmove, on the main thread, whenever that thread is free. If it's busy, the parallax effect may lag a little, but the scroll itself never stutters.
  3. Calling e.preventDefault() inside is ignored: e.defaultPrevented stays false, and Chrome logs a console message that it was unable to preventDefault inside a passive listener.
  4. One non-passive listener for the same event on the path is enough to make the browser wait again, so the promise only helps if every such listener makes it.
AdvancedOpting out of passive defaults

Modern browsers already treat touchstart, touchmove and wheel listeners on window, document and body as passive by default. If you genuinely need to block scrolling (a custom carousel), set { passive: false } explicitly.

Chrome introduced that default in version 56 for touch events and 73 for wheel events, and other engines followed. Listeners on ordinary elements are not passive by default, which is why the carousel case looks like this:

JavaScript
carousel.addEventListener(
  "touchmove",
  (e) => {
    if (isHorizontalSwipe(e)) e.preventDefault(); // stop the page scrolling while swiping slides
  },
  { passive: false },
);
What’s happening
  1. The listener is on the carousel element, not the whole page, so the browser only has to wait for JavaScript on touches that start inside the carousel — scrolling everywhere else stays fast. { passive: false } makes the intent explicit and stops any future default from silently turning it passive.
  2. For a horizontal swipe, preventDefault() cancels the native scroll for that move, and your code moves the slides instead. Vertical moves are left alone, so the page still scrolls.
  3. Often you can avoid JavaScript entirely: the CSS touch-action: pan-y on the carousel tells the browser in advance that only vertical panning is native there, so it never has to wait for a listener, and horizontal gestures are left for your pointer-event code.
AdvancedListener options once and signal

Other useful listener options: { once: true } removes the listener after the first call; { signal } removes it when an AbortController aborts.

JavaScript
button.addEventListener("click", loadEditor, { once: true }); // runs on the first click only

const controller = new AbortController();
window.addEventListener("resize", onResize, { signal: controller.signal });
window.addEventListener("keydown", onKey, { signal: controller.signal });
controller.abort(); // removes both listeners at once
What’s happening
  1. once: true — the browser removes the listener just before calling it the first time, so loadEditor runs on the first click and later clicks do nothing. Good for lazy set-up.
  2. signal ties each listener to the controller. controller.abort() removes every listener registered with that signal in one go.
  3. That matters because removeEventListener needs the exact same function reference that was added — an inline arrow can't be removed at all. With a signal you don't need to keep the references.
  4. It's ideal for teardown: one controller per component or widget, and abort() in the cleanup (a React effect cleanup, for instance). A listener added with an already-aborted signal is never added in the first place.

#localStorage, sessionStorage and cookies

Added

There are three common ways to keep small pieces of data in the browser. Web Storage — localStorage and sessionStorage — is a simple key/value store of strings, scoped to the page's origin (scheme + host + port, so http:// and https:// versions of a site have separate storage). Cookies are older and were designed for the server: the browser attaches them to HTTP requests automatically, which is both their purpose and their cost.

A mental model: localStorage is a drawer in your desk — it stays until someone empties it. sessionStorage is a sticky note on this one tab — reloading keeps it, closing the tab throws it away. A cookie is a badge you show at every door you walk through, whether the door needs it or not.

localStoragesessionStorageCookies
LifetimeUntil clearedUntil the tab closesUntil the expiry date (or session)
Shared across tabsYes (same origin)No — per tabYes
Size~5–10 MB~5 MB~4 KB each
Sent to the serverNoNoYes, with every request
Readable from JSYesYesYes, unless HttpOnly

The Web Storage API is synchronous — every read and write blocks the main thread — so keep it to small values like preferences. For large or structured data, use IndexedDB.

JavaScript
localStorage.setItem("theme", "dark");          // values are always strings
localStorage.getItem("theme");                  // "dark"
localStorage.setItem("user", JSON.stringify({ id: 1 }));
JSON.parse(localStorage.getItem("user"));

// React to changes made in OTHER tabs
addEventListener("storage", (e) => console.log(e.key, e.newValue));
What’s happening
  1. setItem("theme", "dark") stores the pair under this page's origin. Every value is converted to a string first: setItem("count", 5) stores "5", so getItem("count") + 1 is "51", not 6.
  2. getItem("theme") returns "dark". A key that doesn't exist returns null, not undefined.
  3. Objects must be serialised. Without JSON.stringify, setItem("user", { id: 1 }) stores the string "[object Object]" and the data is gone. With it, the stored string is '{"id":1}', and JSON.parse builds a new object from it — changing that object doesn't touch storage until you setItem again.
  4. JSON.parse(localStorage.getItem("missing")) is JSON.parse(null), which returns null (it reads null as the text "null"), so a missing key doesn't crash. A corrupted value does throw a SyntaxError, so wrap reads of stored JSON in try/catch.
  5. The storage event fires in every other tab or window of the same origin when localStorage changes — never in the tab that made the change. e.key is "theme", e.oldValue the previous string and e.newValue "dark"; after localStorage.clear(), e.key is null. Typical uses: logging out every tab at once, or syncing the theme.
AdvancedReading and writing document.cookie

Cookies have their own, older API:

JavaScript
document.cookie = "theme=dark; max-age=31536000; path=/; SameSite=Lax";
document.cookie = "lang=en; path=/";
console.log(document.cookie); // "theme=dark; lang=en"
What’s happening
  1. document.cookie is an accessor property, not a plain string. Assigning to it adds or updates one cookie; it doesn't wipe the others. Everything after the first ; — max-age, path, SameSite — configures that cookie.
  2. theme has max-age=31536000 seconds, so it lives for a year. lang has no expiry, so it's a session cookie, deleted when the browser session ends.
  3. Reading gives you every cookie this page can see as one "name=value; name=value" string. The attributes are never returned, and HttpOnly cookies are left out completely. You have to parse the string yourself.
  4. From now on both cookies are attached to every request to this site (subject to SameSite), which is why the size limit is small. To delete one, set it again with max-age=0 and the same path.
AdvancedPitfall: tokens in localStorage
AdvancedCookies and CSRF

The trade-off runs the other way for cookies: because the browser sends them automatically, another site can try to trigger requests that carry your user's cookie (CSRF). SameSite=Lax or Strict is the main defence — it stops the cookie being sent on most cross-site requests.

#CSS specificity

When several rules target the same element, the browser picks the one with the highest specificity.

Specificity is one step of the cascade, the algorithm that decides which declaration wins when several set the same property on the same element. In order, the cascade looks at: origin and !important, then cascade layers, then specificity, then source order. Specificity is the step interview questions are usually about. It's computed from the selector text alone — never from the HTML, or from how many elements the selector matches.

The best mental model is a version number rather than a decimal number: 1.0.0 beats 0.12.0 because the first column is compared first, and the next column only matters on a tie. There is no carrying between columns, so one ID beats any number of classes. Think of it as a score with columns compared left to right:

ColumnWhat countsExample
Inline stylesstyle="…" attribute<h1 style="color:red">
A — IDs#id#header
B — Classes, attributes, pseudo-classes.class, [type="text"], :hover, :nth-child().title:hover
C — Elements and pseudo-elementsdiv, p, ::beforeh1::after
No weight*, combinators > + ~ (space)
HTML
<h1 class="title" id="header">Hello</h1>
AdvancedScoring and comparing selectors
CSS
#header { color: red; }   /* (1, 0, 0) — wins */
.title  { color: blue; }  /* (0, 1, 0) */
h1      { color: green; } /* (0, 0, 1) */

ul li.active a { }        /* (0, 1, 3) */
.nav .item     { }        /* (0, 2, 0) — beats the one above: B column is compared first */
What’s happening
  1. Score each selector by walking through it piece by piece. #header is one ID → A = 1 → (1, 0, 0). .title is one class → B = 1 → (0, 1, 0). h1 is one element → C = 1 → (0, 0, 1).
  2. All three match the same <h1> and set color. Compare column A first: 1 against 0 and 0, so #header wins and the heading is red. B and C are never even looked at.
  3. If you deleted the #header rule, A would tie at 0, column B decides, and .title (0, 1, 0) beats h1 (0, 0, 1) — blue.
  4. ul li.active a: ul C+1, the space is a combinator (0), li C+1, .active B+1, a C+1 → (0, 1, 3).
  5. .nav .item: two classes → (0, 2, 0). Against (0, 1, 3): A ties at 0, then B is 2 against 1, so .nav .item wins. The three elements in C can't make up for a missing class — even (0, 1, 13) would lose, because nothing carries over into column B.
  6. All of this only matters when both selectors match the same element and set the same property; otherwise both rules simply apply.
AdvancedScoring harder selectors

#header wins because a single ID outranks any number of classes. If two selectors tie, the one that appears later wins.

A few harder ones, the kind that come up when you're asked to score a selector out loud:

CSS
#nav > ul li:hover a::before { }    /* (1, 1, 4) */
input[type="text"]:not(.disabled) { } /* (0, 2, 1) */
:is(#main, .sidebar) p { }          /* (1, 0, 1) */
:where(#main, .sidebar) p { }       /* (0, 0, 1) */
What’s happening
  1. #nav > ul li:hover a::before: #nav A+1; > and the space count nothing; ul, li, a C+3; :hover is a pseudo-class, B+1; ::before is a pseudo-element, C+1 → (1, 1, 4).
  2. input[type="text"]:not(.disabled): input C+1, the attribute selector B+1, and :not() adds its argument .disabled, B+1 → (0, 2, 1).
  3. :is(#main, .sidebar) p: :is() takes the specificity of its most specific argument, #main → (1, 0, 0), plus p → (1, 0, 1). That is true even for a <p> that matched through .sidebar — a surprising way to end up with a very high score.
  4. :where(#main, .sidebar) p: :where() always counts as zero, so only p counts → (0, 0, 1). Same matching as line 3, almost no weight — perfect for base styles that anything should be able to override.
AdvancedSpecificity of :is, :not and :where
AdvancedHow !important overrides specificity

!important

!important overrides normal specificity — it beats every normal declaration, including inline styles.

CSS
p { color: blue !important; }
#special { color: red; }  /* loses, even though the ID is more specific */
What’s happening
  1. Assume <p id="special">. Without !important, #special (1, 0, 0) would beat p (0, 0, 1) and the text would be red.
  2. The cascade sorts declarations by importance before it looks at specificity. color: blue !important is in a higher tier than any normal declaration, so specificity is never compared — the text is blue.
  3. An inline style="color: green" loses too, because it's a normal declaration. To beat the !important rule you need another !important declaration: #special { color: red !important; } would win, because between two important declarations specificity applies again.

Two !important rules fight with normal specificity again. Treat it as a last resort (e.g. utility classes or overriding third-party CSS); overusing it makes styles impossible to reason about. Cascade layers (@layer) are the modern way to control priority.

CSS
@layer base, components;

@layer components {
  .btn { color: white; }       /* (0, 1, 0) — wins: later layer */
}

@layer base {
  #app .btn { color: black; }  /* (1, 1, 0) — loses despite the ID */
}
What’s happening
  1. @layer base, components; declares the layer order up front: base first, components second. For normal declarations, a later layer wins — no matter where each block appears in the file (here the components block even comes first).
  2. Both rules match the button. The cascade compares layers before specificity, so components beats base and the text is white, even though #app .btn (1, 1, 0) far outranks .btn (0, 1, 0).
  3. Specificity still decides between rules inside the same layer.
  4. Styles outside any layer beat all layered ones, so you can put a reset or third-party CSS in a low layer and override it with ordinary selectors, no !important needed. For !important declarations the layer order flips: an important rule in base beats one in components. Layers are supported in all major browsers since 2022.
AdvancedInherited values have no specificity

Inheritance is different from specificity

Some properties are inherited by children automatically — mostly text-related ones like color, font-*, line-height, visibility. Box-model properties like margin, padding, border and background are not.

Inherited values have no specificity at all: any rule that targets the child directly, even *, beats a value inherited from the parent.

CSS
#article { color: red; }   /* very specific… */
p { color: black; }        /* …but this targets the <p> directly, so the <p> is black */
What’s happening
  1. Assume <div id="article">Intro text <p>Paragraph text</p></div>. #article matches the <div>, so the div's color is red, and "Intro text", which belongs directly to the div, is red.
  2. For the <p>, the cascade only considers rules that match the <p> itself. #article doesn't match it, so its high specificity never enters the comparison. p { color: black; } does match, so the paragraph is black.
  3. Inheritance is only the fallback: when no rule sets color on an element, it takes its parent's computed value. Delete the p rule and the paragraph turns red.
  4. That's why even * { color: black; }, with specificity (0, 0, 0), would beat the inherited red. It's also why links don't take their parent's colour: the browser's own stylesheet sets a color on <a>, which beats inheritance until you write a { color: inherit; }.
  5. If you changed the rule to p { color: inherit; }, the paragraph would be red again — inherit explicitly asks for the parent's value.

Force inheritance with color: inherit, or reset with initial, unset and revert. They differ in where they reset to: initial uses the property's default from the CSS spec (display: initial is inline, even on a <div>); unset acts like inherit for inherited properties and initial for the rest; revert goes back to the browser's own stylesheet (display: revert on a <div> gives block).

Built from Rohit’s “Javascript/HTML interview” study doc. Examples target modern browsers and Node 20+.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.