TL;DR
- The browser already ships strong primitives for visibility, size changes, cancellation, performance timing, and cross-context communication. I try those before adding abstraction layers.
- I prefer observers over polling,
AbortControllerover ad hoc boolean flags, and browser-native eventing over overbuilt custom infrastructure. - Senior frontend interviews often test whether you can solve runtime problems with platform APIs instead of rebuilding the platform in userland.
- The most valuable browser API knowledge is practical: cleanup, backpressure, main-thread cost, and choosing the right primitive for the failure mode.
Context
When frontend systems get complex, teams often reach for framework-specific fixes before asking what the browser already gives them.
The recurring problems are familiar:
- typeahead requests racing each other
- infinite scroll causing jank
- dashboard cards resizing unpredictably
- main-thread work blocking input
- multiple tabs getting out of sync
Most of those are better solved with platform primitives than with custom event buses and timer-heavy utilities.
The Approach
1. Match the API to the signal you actually need
This is the quick decision table I keep in my head:
| Problem | API I reach for | Why |
|---|---|---|
| visibility / lazy load | IntersectionObserver | async and efficient |
| element size changes | ResizeObserver | container-aware without global resize |
| DOM mutations | MutationObserver | targeted structural observation |
| canceling requests or listeners | AbortController | one cancellation primitive for many async edges |
| perf instrumentation | PerformanceObserver | browser-native signals for web vitals and long tasks |
| cross-tab sync | BroadcastChannel or storage event | same-origin coordination |
| CPU-heavy work | Web Worker | keeps the main thread responsive |
2. Use IntersectionObserver for feed and infinite-scroll workloads
This is still one of the highest-value APIs for product UIs.
const sentinel = document.querySelector("#sentinel")
const observer = new IntersectionObserver(
async (entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue
await loadNextPage()
}
},
{
rootMargin: "200px 0px",
}
)
observer.observe(sentinel)
Why I like it:
- no scroll listener math
- async visibility signal
- root margin lets me preload before the user hits the edge
What I still watch:
- duplicate fetches when the sentinel remains visible
- lack of virtualization on long feeds
- forgetting to disconnect the observer on teardown
3. Use AbortController for search, navigation, and cleanup
This is the most practical async primitive frontend teams underuse.
let activeController = null
async function search(query) {
activeController?.abort()
activeController = new AbortController()
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {
signal: activeController.signal,
})
const data = await response.json()
renderResults(data)
} catch (error) {
if (error.name !== "AbortError") {
reportError(error)
}
}
}
It also works nicely for event cleanup:
const controller = new AbortController()
window.addEventListener("resize", handleResize, {
signal: controller.signal,
})
document.addEventListener("visibilitychange", handleVisibility, {
signal: controller.signal,
})
return () => controller.abort()
That gives me one teardown point instead of manually removing every listener.
4. Use ResizeObserver when layout depends on container size, not viewport size
Global window.resize listeners are often the wrong abstraction for component systems.
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const width = entry.contentRect.width
updateLayoutMode(width > 720 ? "wide" : "narrow")
}
})
observer.observe(document.querySelector("[data-chart-panel]"))
This is especially useful for dashboards, card layouts, and embedded widgets whose size is controlled by the container rather than the viewport.
5. Use PerformanceObserver when diagnosing real UI pain
For senior frontend work, performance knowledge should not stop at console.time.
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.entryType === "longtask") {
console.log("Long task detected:", entry.duration)
}
}
})
observer.observe({ entryTypes: ["longtask"] })
This is useful when debugging:
- hydration cost
- large JSON parsing
- chart rendering spikes
- main-thread stalls during input
6. Use cross-tab APIs when product state should stay coherent
For auth/session changes, draft indicators, or lightweight workspace sync, BroadcastChannel is often enough.
const channel = new BroadcastChannel("workspace")
channel.postMessage({
type: "filters-updated",
payload: { status: "open" },
})
channel.onmessage = (event) => {
syncFilters(event.data.payload)
}
This is much cleaner than abusing polling or server round-trips for same-browser coordination.
7. Think in terms of cleanup and backpressure
The best browser API usage is not just “I know the API exists.” It is:
- I disconnect observers
- I abort superseded work
- I do not flood the main thread
- I choose streaming or workers when data volume grows
That is the difference between demo knowledge and production knowledge.
Trade-offs
- Observer APIs are efficient, but they still need cleanup, sensible thresholds, and protection against repeated triggers.
MutationObserveris powerful, but it gets expensive fast when teams watch broad subtrees without a narrow purpose.AbortControlleris the cleanest cancellation model I know on the web platform, but older codebases often need migration work to standardize around it.- Browser-native APIs reduce dependency load, but they demand stronger runtime literacy from the team.