Result Lookup Page: Closure Bug, Request Storm and Out-of-Order Responses
Shiksha Coaching Classes runs an online mock-test result lookup. One page has a roll-number input, a Look Up button, and an empty table that is filled after a fetch to /api/result.php. The API returns JSON and can take up to 4 seconds under load. Three bugs are open. (1) A for loop using var i attaches click handlers to five tab buttons; every tab now shows the last tab's data. (2) When a student types a 10-digit roll number quickly, a request fires on every keystroke and the server returns 502 errors during the 6-8 pm peak. (3) If a student corrects a wrong roll number, an earlier slow response sometimes arrives after the newer one and overwrites the correct marks. The developer proposes: "Just add jQuery and set async: false so the order is guaranteed." Most students use low-end Android phones on 3G.
1. Explain why every tab shows the last tab's data, give two fixes, and explain why the `async: false` proposal is rejected.
Read the worked answer
var is function-scoped, not block-scoped, so the five handlers do not each capture their own i. There is one binding named i in the enclosing function; by the time any click happens the loop has finished and that single binding holds 5. All five handlers read the same variable and fetch tab 5's data. This is a binding problem, not an event problem - adding jQuery does not change it, because jQuery still calls the same closure.
Fix 1 - block scope:
for (let i = 0; i < tabs.length; i++) {
tabs[i].addEventListener('click', () => loadTab(i));
}
let creates a fresh binding per iteration, so each arrow function closes over its own i.
Fix 2 - avoid the closure entirely with event delegation:
tabBar.addEventListener('click', (e) => {
const btn = e.target.closest('[data-tab]');
if (!btn) return;
loadTab(Number(btn.dataset.tab));
});
This also works for tabs added later by JS, which the per-element loop does not.
Why async: false is rejected: synchronous XHR blocks the main thread for the full wait. On a low-end Android phone with a 4-second API, the page freezes for 4 seconds - no scrolling, no typing, no tap feedback - and the browser may show a "page unresponsive" dialog. Synchronous XHR on the main thread is deprecated and logs a warning in Chrome; it is also being removed from some contexts. It guarantees order by making the UI unusable, which is the wrong trade. Ordering should be handled by the client, not by freezing it.
2. Design the input handling so that typing a 10-digit roll number does not fire ten requests, and so that a stale response can never overwrite a newer one. Show code and state your timing choice.
Read the worked answer
Two independent problems: too many requests, and unordered responses. Solve them separately.
1. Debounce (rate control)
function debounce(fn, ms) {
let t;
return function (...args) {
clearTimeout(t);
t = setTimeout(() => fn.apply(this, args), ms);
};
}
const onType = debounce((v) => lookup(v), 400);
input.addEventListener('input', (e) => onType(e.target.value.trim()));
Each keystroke cancels the pending timer. A student typing 10 digits at roughly 150-250 ms per key generates one request, not ten, so the peak-hour 502s drop. 400 ms is chosen because it is longer than typical inter-key delay but short enough to feel responsive; below ~250 ms you still fire mid-typing, above ~800 ms it feels laggy. Only call lookup when the value is exactly 10 digits.
2. Stale-response guard (ordering)
let latest = 0;
async function lookup(roll) {
const mine = ++latest;
const res = await fetch('/api/result.php?roll=' + encodeURIComponent(roll));
const data = await res.json();
if (mine !== latest) return; // a newer request has started; discard
render(data);
}
The counter is incremented before the fetch, so whichever request started last owns the screen. The earlier slow reply still arrives but fails the mine !== latest test and is dropped, which fixes complaint (3). AbortController is the stronger version - it cancels the in-flight request so the 3G bandwidth is not wasted - but the token check is what guarantees correctness even if abort fires late. Note encodeURIComponent: a roll number pasted with a space or & would otherwise corrupt the query string.
3. The results table rows are created by JavaScript after the fetch. A new requirement adds a "Show details" button to each row. Explain why a handler bound with `document.querySelectorAll('.details')` at page load fails, and how to bind correctly.
Read the worked answer
querySelectorAll returns a static NodeList of the elements that exist at the moment it runs. At page load the results table is empty - the rows only exist after the fetch resolves - so the list is empty and zero handlers are attached. Even if it ran after the first fetch, a second lookup replaces the table body and the new buttons are fresh DOM nodes with no listeners. The common workaround, re-running the binding code after every render, creates duplicate handlers on nodes that were kept and is easy to forget on one code path.
The correct approach is event delegation: bind one listener on a container that exists at load time and let the event bubble.
tbody.addEventListener('click', (e) => {
const btn = e.target.closest('button.js-details');
if (!btn) return; // click was on the row, not the button
showDetails(btn.dataset.roll);
});
Why it works: click bubbles from the button up to <tbody>, so one listener serves every present and future row, and nothing is re-bound on re-render. The closest guard handles clicks on an icon inside the button and ignores clicks elsewhere in the row. Put the roll number in data-roll, not in an onclick attribute - inline handlers are hard to audit, break under a Content-Security-Policy that disallows unsafe-inline, and mix data into code. Also ensure the fetch path clears tbody before rendering, otherwise a corrected roll number appends a second set of rows under the old ones.