Star Rating Widget
intermediateA dependency-free rating component: event delegation, hover preview vs committed state, keyboard support, and the ARIA pattern.
Build infinite scroll in JavaScript with IntersectionObserver — the sentinel pattern, in-flight and end-of-data guards, error retry, and the virtualization boundary.
Build the loading logic for an infinite feed: when the user nears the bottom, fetch the next page and append it — without duplicate fetches, without spinning forever at the end of the data, and without falling over when a page request fails.
This is the machine-coding question every feed-owning team (Meta, Airbnb, Pinterest…) reaches for, because the naive version is easy and every hardening step is a real production incident: the double-fetch, the retry-forever-at-the-end, the scroll-handler-jank. The modern implementation also has a right answer the question screens for: IntersectionObserver + a sentinel element, not scroll math.
// ❌ the 2015 answer
window.addEventListener("scroll", () => {
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 300) {
loadMore();
}
});Three strikes: scroll fires continuously on the main thread (needs throttling just to be survivable); the geometry reads (offsetHeight, scrollY) can force sync layout in a hot path; and it silently breaks when the feed lives in a scrollable container rather than the window. IntersectionObserver inverts control — the browser tells you when an element approaches the viewport, off the main thread, with zero per-scroll JS. Leading with "scroll listeners are the legacy answer, here's why" is the strongest possible opening.
Place an invisible element after the list. When it approaches the viewport, load more. The list grows, the sentinel moves down, re-arms automatically:
function createInfiniteScroll({
fetchPage, // (cursor) => Promise<{ items, nextCursor }>
onItems, // (items) => void — append to the DOM/UI
onStateChange = () => {}, // "idle" | "loading" | "error" | "done"
sentinel, // the element to watch
rootMargin = "600px", // start loading BEFORE the user hits the bottom
}) {
let cursor = null; // opaque server cursor; null = first page
let state = "idle"; // the state machine IS the exercise
const setState = (next) => {
state = next;
onStateChange(next);
};
async function loadMore() {
if (state === "loading" || state === "done") return; // THE guard
setState("loading");
try {
const { items, nextCursor } = await fetchPage(cursor);
cursor = nextCursor;
onItems(items);
if (nextCursor == null || items.length === 0) {
setState("done");
observer.disconnect(); // nothing left to watch for
} else {
setState("idle");
// If the new items didn't fill the viewport, the sentinel is
// STILL visible — check and keep loading (the short-page case)
maybeLoadAgain();
}
} catch (error) {
setState("error"); // do NOT retry automatically — see below
}
}
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) loadMore();
},
{ rootMargin } // e.g. 600px early — perceived-instant loading
);
function maybeLoadAgain() {
// Re-observing forces a fresh intersection check next frame
observer.unobserve(sentinel);
observer.observe(sentinel);
}
observer.observe(sentinel);
return {
retry() { // wired to a "Retry" button in the error row
if (state === "error") {
setState("idle");
loadMore();
}
},
destroy() {
observer.disconnect();
},
};
}const controller = createInfiniteScroll({
sentinel: document.querySelector("#feed-sentinel"),
fetchPage: (cursor) =>
fetch(`/api/feed${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ""}`)
.then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }),
onItems: appendRows,
onStateChange: renderFooter, // spinner / "Retry" button / "You're all caught up"
});state === "loading" short-circuiting, you fetch the same page twice and render duplicates. This is the #1 planted scenario, and it's the same "a promise is already running" discipline as the typeahead controller — state machines, not hope.nextCursor == null), transition to "done" and disconnect. The naive version leaves the observer armed and hammers the API with empty-page requests forever — a real incident class ("why is /feed getting 40 rps of page-97 requests?")."error", render a retry button; if you do auto-retry, it must be backoff-limited.maybeLoadAgain re-observe forces a re-check so the feed fills the screen. Subtle, real, and the guard fewest candidates know.?page=3 breaks under mutation: a new item posted while the user reads shifts every offset, producing duplicate or skipped rows at page boundaries — the classic feed bug. Opaque server cursors (?cursor=abc, encoding "after item X") are stable under inserts. The controller above treats the cursor as opaque by design — client-side page math is a smell. One paragraph of this converts the question from DOM trivia to a systems conversation.
"done", render an empty state — falls out of the state machine if items.length === 0 transitions correctly.rootMargin tuning — 600px starts the fetch before the user arrives (perceived-instant); too large prefetches pages nobody reads (bandwidth/API cost). It's a product trade-off with a knob, not a constant.destroy() disconnects; a leaked observer keeps callbacks (and everything they close over) alive — the listener-leak discipline again.Infinite loading appends forever — after 5,000 rows the DOM itself is the bottleneck (memory, style/layout cost), no matter how well you fetch. The fix is windowing/virtualization (react-window-style: render only visible rows, recycle the rest — the row-pool world where LRU thinking reappears). Production feeds do both: this controller for data, a virtualizer for DOM. Also name the accessibility cost honestly: infinite scroll breaks "reach the footer," keyboard traversal, and screen-reader landmarks — aria-live="polite" on the status row and a "Load more" button fallback are the mitigations. Volunteering the boundary and the a11y cost is senior-round material.
?page=N pagination presented without the mutation caveat.useInfiniteScroll({ fetchPage }): observer in an effect with cleanup, state in a reducer; the state machine transfers verbatim — which is the point of writing it framework-free first.scrollTop (or use CSS overflow-anchor). Naming overflow-anchor is a deep cut.IntersectionObserver again with a larger margin, or <link rel="prefetch"> — same primitive, second use.IntersectionObserver (jsdom lacks it), drive entries manually, fake timers for debounced variants — assert state transitions, not DOM: idle → loading → idle → … → done, and exactly one fetch per transition (the ordering-contract testing idea).A dependency-free rating component: event delegation, hover preview vs committed state, keyboard support, and the ARIA pattern.
Debounce, cancellation, and stale-response guards composed into a search box that never shows the wrong results.
A concurrency-limited scheduler with priorities and cancellation — the pool question upgraded to the API-design round.
O(1) get/put with least-recently-used eviction — the Map insertion-order trick, and the linked-list version interviewers ask about.