JavaScript140 min total · 15 parts
JavaScript Core Concepts: Scope, Closures, `this`, and the Event Loop
Part 15 of 15 · ~8 min
Common Mistakes Worth Remembering
Here is the search box we promised in the introduction, with everything the last fourteen chapters added to it:
function createSearchBox(inputEl, listEl, spinnerEl, countEl) {
let latestQuery = ""; // reassigned every keystroke — genuinely a `let`
let latestRequestId = 0; // likewise; both private to this call
let debounceTimeoutId;
let spinnerTimeoutId;
let inFlight = null;
const cache = new Map(); // memoised repeat queries
async function loadResults(query) { // ── fetch, guarded against stale responses
const requestId = ++latestRequestId;
inFlight?.abort();
const controller = new AbortController();
inFlight = controller;
startSpinner();
try {
const results = cache.get(query) ?? await fetchResults(query, controller.signal);
if (requestId !== latestRequestId) return; // a newer keystroke won — render nothing
cache.set(query, results);
renderRows(results);
openDropdown(); // two frames, so it can animate
} catch (err) {
if (err.name === "AbortError") return; // we cancelled it; not a failure
renderError("Search is unavailable right now.");
} finally {
stopSpinner();
}
}
function handleInput(event) { // ── debounced: one request per pause, not per key
latestQuery = normalize(event.target.value);
clearTimeout(debounceTimeoutId);
debounceTimeoutId = setTimeout(() => loadResults(latestQuery), 300);
}
inputEl.addEventListener("input", handleInput);
return {
getQuery: () => latestQuery,
destroy() { // ── every handle we opened, closed
inputEl.removeEventListener("input", handleInput);
clearTimeout(debounceTimeoutId);
clearTimeout(spinnerTimeoutId);
inFlight?.abort();
},
};
}
Every line of that is a bug we hit on the way. Here is the list, each one named by its symptom rather than its mechanism, because the symptom is what you will actually be looking at:
- Every row in the dropdown opens the last article. A
varloop variable shared by every handler. Closures capture the variable, not its value — andletgives each iteration its own. (Chapters two and four.) Cannot set properties of undefinedfrom a method that worked a second ago. A method handed toaddEventListeneris called plainly, so itsthisis not the object it was written in. Bind it once and keep the result, or wrap it in an arrow function. (Chapter six.)removeEventListenerremoves nothing.bindreturns a new function every time it is called, and removal matches by identity. Bind once, store it, use that reference for both calls. (Chapter six.)- The spinner never appears. Either the stack never emptied so the browser never painted (chapter seven), or the code hid it again before the awaited work had started (chapter ten). Two causes, one symptom, and the fix is different for each.
- The dropdown is empty and nothing is wrong in the console.
fetchdoes not reject on a 404 or a 500. Checkres.okyourself or a failed search looks exactly like a successful one with no matches. (Chapter nine.) - A value goes
undefinedhalfway down a Promise chain. A.thencallback with braces and noreturn. The chain keeps working and carriesundefinedfrom that point on. (Chapter nine.) - One dead endpoint blanks a panel that had nothing to do with it.
Promise.allrejects the moment any input rejects. When the pieces are independent,Promise.allSettledis the one you wanted. (Chapter nine.) - A keystroke takes twice as long as it should. Two independent
awaits in sequence. If the second does not need the first one's value, they should have been aPromise.all. (Chapter ten.) - An error appears in the console that no
catchin your code can see. Anasyncfunction called withoutawaitand without.catch. The rejection has nowhere to land, and a surroundingtry/catchexited long before it happened. (Chapter ten.) - The results do not match what is in the input box. Two requests in flight, the slower one landing last. Debounce reduces the window and does not close it — you need a sequence token or an
AbortController. (Chapter ten.) - A
setTimeout(fn, 0)runs after something you expected it to beat. Zero is a minimum wait, and the entire microtask queue is drained before any macrotask runs. (Chapters eight and eleven.) - The page is frozen and the profiler shows nothing slow. A chain of microtasks that never empties. The thread is busy without any single frame being long, and
await Promise.resolve()inside the loop does not help — it yields to the same queue you are monopolising. (Chapter eleven.) - The CSS transition does not run. The starting and ending states were set in the same task, so no frame was ever rendered in the starting state and there is nothing to animate from. (Chapter twelve.)
- The widget is gone and something is still writing to the page. An interval or a pending timer that outlived its teardown, holding a detached DOM node and its whole closed-over scope alive with it. (Chapters four and thirteen.)
The same bugs, somewhere else entirely
One running example is how you build a model. It is not how you check whether the model transferred — for that you need to see the same structure in clothes you do not recognise. So here is the first bug on that list, in a program with no browser, no DOM, and nothing to click: a command-line tool that renames a folder of photographs.
const files = await readdir(dir);
const jobs = [];
for (var i = 0; i < files.length; i++) {
jobs.push(() => rename(files[i], `${prefix}-${i}.jpg`)); // every job renames the same file
}
await Promise.all(jobs.map((job) => job()));
No rows, no dropdown, no click handlers. Same bug, exactly: one var i shared by every queued job, and by the time any of them runs the loop has finished and i is files.length. Every job reads the same variable, gets the same out-of-range index, and the whole batch fails on undefined.
If you can see the dropdown bug in that, the mechanism has come loose from the story — which was the point of telling one story in the first place.
Where this goes next
The five mechanisms in this reference — scope, hoisting, closures, this, and the event loop — were never really five topics. They are one system, and the search box is the evidence: you cannot fix the stale-response race without closures, you cannot explain the frozen spinner without the call stack, and you cannot reason about when the dropdown paints without the event loop and this and a debounce built out of a captured variable, all at once, in the same twelve lines.
The stale-closure bug in React Fundamentals is this exact capture mechanism showing up inside useEffect, with a dependency array instead of a for loop. TypeScript Fundamentals layers a type system over this same runtime without changing a single rule of it. And the array and object methods cheat sheet covers the data-shaping side of the work our search and rankByRecency functions were quietly doing all along.
Then go and break things. Every snippet here — the frozen spinner, the loop that opens the wrong article, the transition that refuses to animate, the microtask chain that freezes a page with nothing slow in it — runs in the code lab. Reading about a bug and watching one happen are not the same kind of knowing.
Practice this
UI Challenges
Continue learning
- JavaScriptJavaScript Array and Object Methods Cheat Sheet
- Interview & Career PrepThe Non-Technical Half of the Interview: Behavioral Questions, the STAR Method, and What Recruiters Are Actually Scoring
- AI & LLM EngineeringAI & LLM Engineering Fundamentals: Prompting, RAG, Embeddings, and Function Calling