skip to content

For deferring offscreen images and iframes, when is the browser's built-in lazy loading enough, and what does driving the loading yourself with IntersectionObserver buy you?

level: middleimportance: should knowfreq 50%

answer

  1. free and fails safe versus paid control
  2. the attribute covers only two elements
  3. background images and video need another lever
  4. your script has to arrive before anything defers
  5. stop observing once it has loaded

basics

~20 s

Built-in lazy loading is free, needs no JavaScript and works before scripts run, so it is the default for ordinary offscreen images and iframes. A custom IntersectionObserver is worth it when you need your own trigger distance, placeholders, or to defer something the browser cannot — background images, video, components or data.

solid answer

~50 s

I start with the browser's own deferral because it costs nothing, survives JavaScript failing to load, and the browser already tunes its trigger distance by connection speed. It covers the common case — offscreen `<img>` and `<iframe>` elements — and it does so without any code to maintain. I reach for an `IntersectionObserver` when I need control the attribute cannot give me: a specific trigger distance via `rootMargin`, a placeholder or shimmer swapped out when the real asset arrives, retry and error handling, analytics on what actually got seen, or deferral of things the attribute does not cover at all — a CSS `background-image`, a `<video>`, a component's data fetch, a whole section of the page. The tradeoff is exactly control versus simplicity: the observer path is more code, runs on the main thread, does nothing until your script has loaded, and can be got wrong in ways the browser cannot.

code

javascript · 13 lines
javascript
const lazyImages = document.querySelectorAll('img[data-src]');

const io = new IntersectionObserver((entries, observer) => {
  for (const entry of entries) {
    if (!entry.isIntersecting) continue;
    const img = entry.target;
    img.src = img.dataset.src;
    img.removeAttribute('data-src');
    observer.unobserve(img);
  }
}, { rootMargin: '600px 0px' });

lazyImages.forEach((img) => io.observe(img));

go deeper

for a junior

Know that the browser can defer offscreen images and iframes on its own with no JavaScript, and that hand-rolled deferral means writing and shipping code for the same job.

for a middle

Explain what the observer adds that the attribute cannot: your own trigger distance, placeholder handling, and deferral of things that are not images or iframes at all.

for a senior

Weigh the failure modes — a bundle that never arrives leaves data-src elements blank, and observer callbacks that are never torn down keep costing main-thread time for the whole session.

for a principal

Decide the house rule: native by default with a named, reviewed set of cases that justify custom deferral, so control is bought deliberately rather than by habit in every new component.

## Two ways to defer There are two mechanisms for holding back offscreen work, and the interview question is when each is the right tool. The first is the browser's own: mark an `<img>` or `<iframe>` as lazy and the browser takes over. It decides how close to the viewport the element must be, starts the fetch itself, and does all of it in the engine, before or without any of your JavaScript. The second is doing it yourself. You hold the real source somewhere the browser will not fetch — typically a `data-` attribute — observe the element with an `IntersectionObserver`, and when the callback says it is approaching the viewport, you assign the real source. ```javascript const io = new IntersectionObserver((entries, observer) => { for (const entry of entries) { if (!entry.isIntersecting) continue; entry.target.src = entry.target.dataset.src; observer.unobserve(entry.target); } }, { rootMargin: '600px 0px' }); ``` ## What the built-in path gives you **Zero cost and zero code.** No bytes shipped, no main-thread work, nothing to maintain or test. **It works when your JavaScript does not.** If the script fails, is blocked, or has simply not arrived yet, browser-native deferral still functions. Custom deferral in the same situation leaves you with elements whose real source lives in a `data-` attribute and never gets applied — a blank page section. That failure mode alone justifies preferring the native path for content. **Tuned trigger distances.** Browsers start these loads well before the element reaches the viewport, and adjust the distance based on the connection, so a slow connection gets a longer runway. You get that behaviour for free and it improves as browsers improve. **Correct interaction with the rest of loading.** The engine knows the element's position and the state of the page. Your script only knows what the observer told it, after layout, after your bundle arrived. ## What it cannot do The attribute applies to specific elements. That leaves real gaps: - A CSS `background-image` on an offscreen section — the attribute has nowhere to live. Deferring it means toggling a class when the section approaches. - `<video>` — the lever there is `preload="none"` plus a `poster` image, which is a different mechanism with different behaviour. - Anything that is not a resource at all: a component's data fetch, an analytics beacon, initialising a heavy widget, rendering a long section's contents. - The trigger distance itself. You accept the browser's choice; you cannot say "start loading 1200 pixels early because our scroll is unusually fast". - Presentation while loading. A shimmer placeholder, a blur-up preview, a fade-in on arrival, a retry after a failed fetch — all of this needs a callback you control. - Knowing what happened. If you want to record which items were actually seen, you need the observer regardless. ## What the observer costs It is JavaScript: bytes to download, parse and run, on the same main thread as everything else. It cannot act until your bundle has arrived, so the deferral itself is deferred — on a slow connection, images can begin loading noticeably later than the native path would have started them. It has to be cleaned up: stop observing an element once it has loaded, or the callback keeps firing for the life of the page. And the pattern of putting the real URL in `data-src` means anything that does not run your script — some crawlers, a JavaScript error, a stale cached bundle — sees no image at all. ## How to choose Use the built-in mechanism for content media. Reach for the observer when you need something it structurally cannot provide: a different trigger distance, a placeholder lifecycle, deferral of non-media work, or instrumentation. The two also combine — the observer is the right tool for deferring a section's *data* while the images inside that section still use the browser's own deferral once they exist. The answer an interviewer is listening for is the framing, not a list: native is the default because it is free and fails safe; custom is a deliberate purchase of control, paid for in code, main-thread time and a worse failure mode. If a candidate reaches for the observer first for ordinary images, they have paid that price without noticing there was one.

  • What is the worst failure mode of the data-src plus observer pattern?
    The script never runs — a bundle error, a blocked request, an unsupported path — and the real URL stays parked in `data-src`, so the user gets an empty element rather than a late one. Browser-native deferral degrades to a normal load in that situation, which is why it is the safer default for content the page depends on.
  • How would you defer a CSS background-image on an offscreen section?
    The attribute cannot help, so observe the section and add a class that carries the `background-image` once it approaches the viewport. Reserve the section's height first so applying the class does not resize it, and stop observing after the class lands so the callback is not doing work for the rest of the session.
  • Can you use both mechanisms on the same page?
    Yes, and it is often the best split. Let the browser defer the images and iframes it understands, and use an observer for the things it cannot reach — a section's data fetch, a background image, a widget's initialisation. You get the safe default for content and keep custom code confined to the cases that genuinely need it.
  • Does an IntersectionObserver callback run on every scroll frame?
    No — it reports asynchronously when an element crosses the thresholds you configured, not continuously as you scroll, which is why it is cheaper than a scroll handler measuring positions. It still runs on the main thread though, so a callback that does heavy work per element can still be the thing that makes scrolling stutter.

saying these in an interview costs you the question

  • Reaches for a scroll handler measuring offsets instead of an observer
  • Assumes the attribute covers CSS background images or video
  • Never unobserves, so callbacks keep firing all session
  • Forgets that custom deferral does nothing until the bundle loads
  • Thinks a custom observer is always the more professional choice

context