Browser & Performance Interview Questions
These questions test whether you understand what happens below React â in the browser itself. React is built on top of browser APIs, and interviewers want to know you understand the foundation, not just the framework.
Q21: What Is Event Delegation?
Interview Question: "What is event delegation and how does React use it?"
Think of it like a restaurant with 50 tables. You could hire one waiter per table (50 waiters), or you could hire one head waiter who stands at the door and routes every order to the right kitchen station. Event delegation is the head waiter approach â instead of attaching an event listener to every element, you attach one listener to a parent and let events bubble up.
How It Works in Plain JavaScript
When you click a button inside a div inside a section, the click event doesn't just fire on the button. It bubbles up through every parent element all the way to document.
// Without delegation: one listener per button (wasteful)
document.querySelectorAll('.btn').forEach(btn => {
btn.addEventListener('click', handleClick);
});
// With delegation: one listener on the parent (efficient)
document.querySelector('.button-container').addEventListener('click', (e) => {
const target = e.target as HTMLElement;
if (target.matches('.btn')) {
handleClick(e);
}
});How React Uses Event Delegation
Here's the part that impresses interviewers: React uses event delegation by default. When you write onClick on a button, React doesn't attach a listener to that button. It attaches a single listener to the root DOM node of your app.
function App() {
return (
<div>
<button onClick={() => console.log('Button 1')}>One</button>
<button onClick={() => console.log('Button 2')}>Two</button>
<button onClick={() => console.log('Button 3')}>Three</button>
</div>
);
}Behind the scenes, React has ONE listener on the root. When a click bubbles up, React looks at the event target, figures out which component it belongs to, and calls the right handler. This is why you can have a list with 10,000 items and React doesn't attach 10,000 click listeners.
Why React Changed in Version 17
Before React 17, the single listener was on document. This caused problems when multiple React apps lived on the same page â their events would interfere. React 17 moved the listener to the root container (document.getElementById('root')) so each React tree manages its own events.
React 16: document.addEventListener('click', ...)
React 17+: rootContainer.addEventListener('click', ...)React's SyntheticEvent
React wraps the native browser event in a SyntheticEvent â a cross-browser wrapper that normalizes event behavior:
function Form() {
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
e.stopPropagation();
// e.nativeEvent â access the real browser event if needed
}
return <form onSubmit={handleSubmit}>...</form>;
}The SyntheticEvent has the same interface across all browsers. You almost never need e.nativeEvent, but it's there if you do.
The One-Liner That Impresses: "React implements event delegation at the root â every onClick in your app is actually one listener on the root container that uses event bubbling to dispatch to the right handler, which is why adding 1,000 buttons doesn't add 1,000 listeners."
Common Follow-Up Questions
"When would you use native event listeners instead of React's?"
"When you need to listen outside the React tree â like
window.addEventListener('resize', ...)ordocument.addEventListener('keydown', ...). Also for third-party libraries that manipulate the DOM directly. Always clean up in useEffect's return function."
"What's the difference between stopPropagation and preventDefault?"
"
stopPropagationstops the event from bubbling up to parent elements.preventDefaultstops the browser's default action (like form submission or link navigation). They solve different problems â you can use both, one, or neither."
"What about events that don't bubble?"
"Some events like
focus,blur, andscrolldon't bubble. React normalizes this â it uses the capture phase versions (focusin/focusout) internally so thatonFocusandonBlurwork with delegation. That's one of the values SyntheticEvent provides."
Red Flags in Your Answer
- Saying React attaches a listener to each element (it doesn't)
- Not knowing about event bubbling
- Confusing
stopPropagationwithpreventDefault - Not mentioning the React 17 change from
documentto root container - Forgetting to clean up native event listeners in useEffect
Q22: What Is Throttling vs Debouncing?
Interview Question: "Explain the difference between throttling and debouncing. When would you use each?"
Think of it like this:
Debouncing is like an elevator door. Every time someone walks up, the door resets its closing timer. It only closes once everyone has stopped arriving for a moment. "Wait until things calm down, then act."
Throttling is like a turnstile at a subway. No matter how many people are pushing, only one person gets through every 3 seconds. "Act at a steady pace, no matter how frantic things get."
Debouncing: Wait for Silence
The function only fires after the user stops doing the action for a specified time.
function debounce<T extends (...args: any[]) => void>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timer: ReturnType<typeof setTimeout>;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
}Best use case â autocomplete search:
function SearchInput() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const searchAPI = useMemo(
() =>
debounce(async (term: string) => {
if (!term) return setResults([]);
const data = await fetch(`/api/search?q=${term}`).then(r => r.json());
setResults(data);
}, 300),
[]
);
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value;
setQuery(value);
searchAPI(value);
}
return (
<div>
<input value={query} onChange={handleChange} placeholder="Search..." />
{results.map(item => (
<div key={item.id}>{item.name}</div>
))}
</div>
);
}The user types "react hooks" â that's 11 keystrokes. Without debouncing, you'd make 11 API calls. With 300ms debounce, you make 1 or 2 calls, after the user pauses typing.
Throttling: Act at a Steady Rate
The function fires at most once every N milliseconds, no matter how often the event fires.
function throttle<T extends (...args: any[]) => void>(
fn: T,
interval: number
): (...args: Parameters<T>) => void {
let lastCall = 0;
return (...args) => {
const now = Date.now();
if (now - lastCall >= interval) {
lastCall = now;
fn(...args);
}
};
}Best use case â scroll position tracking:
function useScrollPosition() {
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
const handleScroll = throttle(() => {
setScrollY(window.scrollY);
}, 100);
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
return scrollY;
}The scroll event fires up to 60 times per second. Throttling to 100ms means you update at most 10 times per second â still smooth for UI updates, but not overwhelming React with re-renders.
The Comparison
| Debounce | Throttle | |
|---|---|---|
| When it fires | After silence | At a steady rate |
| Analogy | Elevator door | Subway turnstile |
| Use for | Search input, form validation, resize end | Scroll tracking, mouse move, game loops |
| Waits for user to stop? | Yes | No |
| Guarantees execution during action? | No â resets every time | Yes â fires every N ms |
The React Way: useDebounce Hook
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
function Search() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
if (debouncedQuery) fetchResults(debouncedQuery);
}, [debouncedQuery]);
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}The One-Liner That Impresses: "Debounce waits for calm, throttle enforces rhythm â debounce is for when you care about the final value (search), throttle is for when you care about ongoing updates (scroll position)."
Common Follow-Up Questions
"Can you use useDeferredValue instead of debounce?"
"For rendering purposes, yes.
useDeferredValueis priority-based â it defers low-priority re-renders. But it doesn't reduce API calls. If you need to limit network requests, you still need debounce. They solve different problems: debounce limits execution,useDeferredValuelimits rendering."
"What about requestAnimationFrame as a throttle?"
"rAF naturally throttles to the screen refresh rate (~60fps). It's perfect for visual updates like animations or scroll-driven effects because it syncs with the browser's paint cycle. It's essentially a 16ms throttle that's aligned with the display."
"How would you implement a debounce with a leading edge?"
"A leading-edge debounce fires immediately on the first call, then ignores subsequent calls until silence. Useful for 'save' buttons â you want the first click to register immediately but ignore accidental double-clicks."
Red Flags in Your Answer
- Mixing up which one waits for silence (that's debounce)
- Saying "throttle delays the function" (it doesn't delay â it executes immediately, then enforces a cooldown)
- Not cleaning up timers/listeners in useEffect
- Using debounce when throttle is appropriate (scroll tracking needs steady updates, not silence-waiting)
- Implementing debounce or throttle inline without memoization (creates new function on every render)
Q23: What Is IntersectionObserver?
Interview Question: "What is IntersectionObserver and how would you use it in a React app?"
Think of it like a security camera with a laser tripwire. You set up the camera to watch a specific element, and it tells you when that element enters or leaves the visible area. You don't have to constantly check â it notifies you automatically.
Before IntersectionObserver, developers used scroll events with getBoundingClientRect() â expensive calculations running 60 times per second. IntersectionObserver does the same thing but the browser handles the math internally and only notifies you when something actually changes.
The Basic API
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
console.log('Element is visible!');
}
});
},
{
root: null,
rootMargin: '0px',
threshold: 0,
}
);
observer.observe(someElement);The three options explained:
root: What counts as the "viewport."nullmeans the browser viewport. You can set it to a scrollable container.rootMargin: Expand or shrink the trigger zone.'200px'means "trigger 200px before the element actually enters the viewport."threshold: How much of the element must be visible.0means any pixel.1means 100% visible.0.5means half visible.
Use Case 1: Infinite Scroll
The most common React use of IntersectionObserver â loading more content when the user scrolls near the bottom:
function useInView(options?: IntersectionObserverInit) {
const ref = useRef<HTMLDivElement>(null);
const [isInView, setIsInView] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(([entry]) => {
setIsInView(entry.isIntersecting);
}, options);
observer.observe(el);
return () => observer.disconnect();
}, [options]);
return { ref, isInView };
}
function InfiniteList() {
const { ref: bottomRef, isInView } = useInView({ rootMargin: '200px' });
const { data, fetchNextPage } = useInfiniteProducts();
useEffect(() => {
if (isInView) fetchNextPage();
}, [isInView, fetchNextPage]);
return (
<div>
{data.pages.flat().map(item => (
<ProductCard key={item.id} product={item} />
))}
<div ref={bottomRef} />
</div>
);
}Use Case 2: Lazy Loading Images
Don't load images until they're about to appear. This can save megabytes of bandwidth:
function LazyImage({ src, alt, ...props }: React.ImgHTMLAttributes<HTMLImageElement>) {
const { ref, isInView } = useInView({ rootMargin: '100px' });
const [loaded, setLoaded] = useState(false);
return (
<div ref={ref}>
{isInView ? (
<img
src={src}
alt={alt}
onLoad={() => setLoaded(true)}
style={{ opacity: loaded ? 1 : 0, transition: 'opacity 0.3s' }}
{...props}
/>
) : (
<div className="bg-gray-200 animate-pulse" style={{ aspectRatio: '16/9' }} />
)}
</div>
);
}Use Case 3: Scroll-Triggered Animations
function FadeInSection({ children }: { children: React.ReactNode }) {
const { ref, isInView } = useInView({ threshold: 0.2 });
return (
<div
ref={ref}
style={{
opacity: isInView ? 1 : 0,
transform: isInView ? 'translateY(0)' : 'translateY(20px)',
transition: 'opacity 0.6s ease, transform 0.6s ease',
}}
>
{children}
</div>
);
}Why IntersectionObserver Beats Scroll Events
| Scroll Events | IntersectionObserver | |
|---|---|---|
| Fires | Every pixel scrolled (~60/sec) | Only when visibility changes |
| Runs on | Main thread | Browser-optimized (off main thread) |
| Needs | Manual getBoundingClientRect calculations | Built-in visibility detection |
| Performance | Can cause jank | Zero jank |
| Multiple elements | One calculation per element per frame | Browser batches efficiently |
The One-Liner That Impresses: "IntersectionObserver is the browser-native way to react to element visibility changes â it replaces expensive scroll listeners with a callback-based API that the browser optimizes internally, making it the foundation for infinite scroll, lazy loading, and scroll-triggered animations."
Common Follow-Up Questions
"How does IntersectionObserver work with virtualized lists?"
"They complement each other. IntersectionObserver detects when to load more data (infinite scroll trigger), while virtualization (react-window, TanStack Virtual) controls which DOM nodes exist. The observer watches a sentinel element; the virtualizer manages the visible window of items."
"Can you observe multiple elements with one observer?"
"Yes, and you should. One observer can watch many elements â call
observer.observe(el)for each. The callback receives an array of entries. This is more efficient than creating separate observers."
"What happens if the observed element is removed from the DOM?"
"The observer automatically stops tracking it. But you should still call
observer.disconnect()in your useEffect cleanup to prevent memory leaks from the observer itself."
Red Flags in Your Answer
- Suggesting scroll events with
getBoundingClientRectinstead of IntersectionObserver - Not cleaning up the observer in useEffect (
observer.disconnect()) - Creating a new observer per element instead of reusing one for multiple elements
- Not mentioning
rootMarginfor prefetching (loading before the user sees the element) - Forgetting that IntersectionObserver is asynchronous â it doesn't fire synchronously when an element enters the viewport