Search IOCombats

Search challenges, guides, questions and articles

System DesignTopic 14 of 15BeginnerAug 3, 2026

Image Gallery with Lazy Loading

Design an image grid that defers offscreen loading, serves the right resolution per device, reserves layout space to prevent shift, and preloads lightbox neighbours.

frontend-system-designpractice-problemimages

The Problem

Design an image gallery: a responsive grid of photos, clicking one opens a full-size lightbox with previous and next navigation. It must load quickly on a phone, and it must not shift the layout as images arrive.

Images are usually the largest thing a page downloads, and a gallery is a page that is almost entirely images. Four decisions determine whether it is fast: what to load and when, which resolution to serve, what occupies the space beforehand, and what to prefetch. Each has a native platform answer that is right most of the time, and knowing when the native answer runs out is the substance of the problem.

Requirements

Functional

  • Responsive grid adapting from one column to many.
  • Offscreen images not downloaded until needed.
  • Lightbox with keyboard and swipe navigation.
  • Adjacent lightbox images preloaded so navigation is instant.
  • Graceful handling of a failed image.

Non-functional

  • Zero cumulative layout shift from images.
  • No device downloads pixels it cannot display.
  • Above-fold images not delayed by below-fold ones.
  • Lightbox usable with a keyboard and announced correctly.

Lazy Loading

Two mechanisms, and they are complementary rather than competing.

Native, and why it is the default

<img
  src="photo.jpg"
  alt="Sunset over the harbour"
  loading="lazy"
  decoding="async" />

One attribute. No JavaScript, so it works before your bundle has downloaded or parsed - which matters more than it sounds, because a JavaScript lazy loader cannot start loading anything until it has executed, making it strictly slower to first image than the native attribute.

The browser's heuristics are also better than the ones you would write. Chromium adjusts how early it begins fetching based on connection type, using a larger margin on slow connections where the fetch takes longer. Reproducing that requires reading navigator.connection and maintaining your own table of thresholds.

decoding="async" is the useful companion: it lets the browser decode off the main thread, so a large image does not block a frame while being decoded. On a grid of dozens of images, that is the difference between smooth and stuttering scroll.

Intersection Observer, and when it earns its place

The native attribute cannot express: a custom threshold, a placeholder that cross-fades rather than swaps, priority ordering among approaching images, lazy loading of something that is not an <img> (a CSS background, a video poster), or measurement of exactly when an image entered view.

const LOAD_MARGIN_PX = 400;

function useLazyImage(ref: React.RefObject<HTMLImageElement>, src: string) {
  const [state, setState] = useState<'idle' | 'loading' | 'loaded' | 'failed'>(
    'idle',
  );

  useEffect(() => {
    const element = ref.current;
    if (!element) return;

    // Respect the platform where it is available: if the browser supports
    // native lazy loading, there is no reason to observe at all.
    if ('loading' in HTMLImageElement.prototype && !needsCustomBehaviour) {
      element.loading = 'lazy';
      element.src = src;
      return;
    }

    const observer = new IntersectionObserver(
      (entries) => {
        for (const entry of entries) {
          if (!entry.isIntersecting) continue;

          setState('loading');
          element.src = src;
          // One-shot: an image never needs to be un-loaded.
          observer.unobserve(element);
        }
      },
      { rootMargin: `${LOAD_MARGIN_PX}px 0px`, threshold: 0 },
    );

    observer.observe(element);
    return () => observer.disconnect();
  }, [ref, src]);

  return state;
}

Two details. Unobserve after loading - keeping the observer alive for a loaded image is pure overhead across a long grid. And one shared observer for the whole grid is meaningfully cheaper than one per image, since each observer carries its own bookkeeping; pass the element as the map key and look up the callback.

Diagram
100%
flowchart TB S["Image enters the prefetch zone<br/>(rootMargin 400px below the fold,<br/>or the browser's own margin)"] S --> P["Placeholder is already painted:<br/>reserved box at the correct<br/>aspect-ratio + blurhash gradient"] P --> REQ["src / srcset assigned<br/>-> browser picks a candidate<br/>from srcset + sizes"] REQ --> PRI{"priority"} PRI -->|"above fold, LCP candidate"| HI["fetchpriority='high'<br/>+ preload hint in head"] PRI -->|"below fold"| LO["low priority, queued behind<br/>critical resources"] HI --> NET["network fetch"] LO --> NET NET --> DEC["decode<br/>(off main thread via decoding='async')"] DEC --> PAINT["paint into the reserved box"] PAINT --> FADE["opacity transition<br/>placeholder -> image, 200ms"] FADE --> DONE["loaded - unobserve,<br/>drop the placeholder data"] NET -->|"error"| ERR["onError:<br/>keep the reserved box,<br/>show a retry affordance,<br/>never collapse the layout"] style P fill:#1e3a5f,stroke:#3b82f6 style HI fill:#1e3f2d,stroke:#22c55e style LO fill:#1e293b,stroke:#475569 style ERR fill:#3f1e1e,stroke:#ef4444 style DONE fill:#1e3f2d,stroke:#22c55e
visualized byIOCombats

Note that the placeholder is painted before the request begins, not after. A placeholder that appears at the same time as the request is pointless - the whole purpose is to occupy the space during the fetch.

Responsive Images

Serving one image size to every device is the largest single waste in most galleries. A phone at 400px CSS width does not need a 1600px file, and downloading one costs bandwidth, decode time and memory for pixels it cannot show.

<img
  src="photo-800.jpg"
  srcset="
    photo-400.jpg   400w,
    photo-800.jpg   800w,
    photo-1200.jpg 1200w,
    photo-1600.jpg 1600w
  "
  sizes="(max-width: 640px) 100vw,
         (max-width: 1024px) 50vw,
         33vw"
  width="1600"
  height="1067"
  alt="Sunset over the harbour"
  loading="lazy"
  decoding="async" />

The division of labour:

  • srcset declares which files exist and their intrinsic widths in w units.
  • sizes declares how wide the image will be laid out at each viewport width.
  • width and height give the intrinsic aspect ratio so the box can be reserved.

The browser combines sizes with the viewport width and device pixel ratio to pick a candidate. On a 375px phone at DPR 2, 100vw means 750 device pixels, so it picks the 800w file. On a 1440px desktop, 33vw is 475 CSS pixels, so at DPR 1 it picks 800w and at DPR 2 it picks 1200w or 1600w.

sizes is the part that goes wrong, and it goes wrong invisibly. It is written before layout exists, the browser trusts it absolutely, and choosing a candidate happens before stylesheets have necessarily been applied. Declaring sizes="100vw" on an image that is actually a third of the viewport wide means downloading roughly nine times the pixels needed - and nothing on the page looks wrong, so the waste is undetectable without measuring transferred bytes against rendered size.

Two related points worth raising:

Modern formats. AVIF and WebP are substantially smaller than JPEG at equivalent quality. <picture> with type attributes lets the browser choose, falling back cleanly:

<picture>
  <source
    type="image/avif"
    srcset="photo-400.avif 400w, photo-800.avif 800w"
    sizes="33vw" />
  <source
    type="image/webp"
    srcset="photo-400.webp 400w, photo-800.webp 800w"
    sizes="33vw" />
  <img
    src="photo-800.jpg"
    alt="Sunset over the harbour"
    loading="lazy"
    width="1600"
    height="1067" />
</picture>

Generation is a build or service concern, not a manual one. Producing four widths in three formats for every image is twelve derivatives, which is a job for an image CDN or a framework's image component. Next.js next/image handles srcset, sizes, format negotiation and dimension attributes, and hand-writing the markup above is usually a sign that something is bypassing it.

Layout Shift

An image with no reserved space is zero pixels tall until it loads, then suddenly hundreds - shoving everything below it down. That is Cumulative Layout Shift, and a gallery of unreserved images is close to the worst possible case, since every arrival moves the whole grid.

.gallery-item {
  /* Holds regardless of the file's dimensions, and regardless of whether the
     image ever loads. This is the reliable fix. */
  aspect-ratio: 3 / 2;
  overflow: hidden;
}

.gallery-item img {
  width: 100%;
  height: 100%;
  /* Fill the reserved box without distorting the image. */
  object-fit: cover;
  display: block;
}

Three layers, and using more than one is not redundant:

  1. width and height attributes on the <img>. Modern browsers derive an aspect ratio from them even when CSS overrides the rendered size, so this alone fixes the common case.
  2. CSS aspect-ratio on the container, which is authoritative for a responsive grid where the rendered box comes from CSS rather than from the file.
  3. object-fit: cover, so an image whose real ratio differs from the reserved box fills it rather than distorting or letterboxing.

Two further points. Where images have genuinely varying ratios - a masonry layout - each item needs its own ratio from stored metadata, which means the API must return dimensions alongside URLs. Serving dimensions with the image list is not an optimisation, it is what makes zero-CLS possible at all. And the placeholder must match the reserved shape, or it merely relocates the shift instead of removing it.

The measurement side - what CLS is, how it is scored, and why field data differs from lab data - is in Performance Engineering.

Placeholders

Reserved space prevents shift. A placeholder makes the wait feel intentional.

StrategyPayloadFidelityCost
Nothing (empty box)0NoneReads as broken
Dominant colour~7 bytesVery lowTrivial; store a hex value
CSS gradient from 2-3 colours~20 bytesLowTrivial
Blurhash / thumbhash20-30 charsMediumSmall decode library plus main-thread work
LQIP (inlined tiny JPEG)1-2 KB eachMedium-highInflates HTML for every image on the page
type GalleryImage = {
  id: string;
  url: string;
  width: number;
  height: number;
  alt: string;
  /** ~28 characters encoding a blurred approximation. */
  blurhash: string;
  /** Fallback if blurhash decoding is unavailable. */
  dominantColour: string;
};

function GalleryItem({
  image,
  isEager,
}: {
  image: GalleryImage;
  isEager: boolean;
}) {
  const [isLoaded, setIsLoaded] = useState(false);

  return (
    <div
      className='relative overflow-hidden rounded-lg'
      // Per-image ratio from stored metadata - this is why the API returns
      // dimensions rather than only URLs.
      style={{ aspectRatio: `${image.width} / ${image.height}` }}>
      <BlurhashCanvas
        hash={image.blurhash}
        fallbackColour={image.dominantColour}
        // Fades out rather than being removed, so there is no flash of gap.
        className={cn(
          'absolute inset-0 transition-opacity duration-200',
          isLoaded ? 'opacity-0' : 'opacity-100',
        )}
      />

      <img
        src={image.url}
        srcSet={buildSrcSet(image)}
        sizes='(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw'
        width={image.width}
        height={image.height}
        alt={image.alt}
        loading={isEager ? 'eager' : 'lazy'}
        // Only the LCP candidate gets the high-priority signal.
        fetchPriority={isEager ? 'high' : undefined}
        decoding='async'
        onLoad={() => setIsLoaded(true)}
        onError={() => setIsLoaded(false)}
        className={cn(
          'h-full w-full object-cover transition-opacity duration-200',
          isLoaded ? 'opacity-100' : 'opacity-0',
        )}
      />
    </div>
  );
}

The choice is mostly a function of how many images are on the page. Inlining 1.5KB LQIPs for sixty thumbnails adds 90KB to the initial HTML - the exact payload you were trying to keep small, and it is on the critical path in a way the images themselves are not. Blurhash's 28 characters for sixty images is under 2KB total, which is why it dominates for grids. For a single hero image, an LQIP is fine and looks better.

One subtlety: a decoded image already in cache still fires onLoad, but it may fire before React attaches the handler, leaving the placeholder visible over a loaded image. Checking img.complete in an effect on mount is the standard guard.

Above-Fold Priority

Above fold priority

Lazy loading everything is a common and damaging mistake. The largest above-fold image is almost certainly the Largest Contentful Paint element, and deferring it directly delays the metric users experience as page speed.

Diagram
100%
flowchart TB subgraph VIEW["Viewport - first paint"] subgraph ABOVE["ABOVE THE FOLD - eager"] H["Hero / first grid row<br/>loading='eager'<br/>fetchpriority='high'<br/>+ &lt;link rel=preload&gt; in head<br/>= the LCP element"] R1["Row 1 remainder<br/>loading='eager'<br/>normal priority"] end FOLD["═══════ fold ═══════"] end subgraph NEAR["NEAR - within the prefetch margin (~400px)"] R2["Row 2<br/>loading='lazy'<br/>browser starts fetching<br/>as it approaches"] end subgraph FAR["FAR BELOW - not requested at all"] R3["Rows 3..N<br/>placeholder only:<br/>reserved box + blurhash.<br/>Zero image bytes."] end ABOVE --> FOLD --> NEAR --> FAR NOTE["Lazy-loading an above-fold image<br/>is actively harmful: browsers do not<br/>defer in-viewport images anyway, so it<br/>only discards the priority signal."] style ABOVE fill:#1e3f2d,stroke:#22c55e style NEAR fill:#3f2d1e,stroke:#f59e0b style FAR fill:#1e293b,stroke:#475569 style NOTE fill:#3f1e1e,stroke:#ef4444
visualized byIOCombats

Three mechanisms for the above-fold set:

<!-- In <head>: starts the fetch before the parser reaches the <img> at all. -->
<link
  rel="preload"
  as="image"
  href="hero-800.avif"
  imagesrcset="hero-400.avif 400w, hero-800.avif 800w, hero-1200.avif 1200w"
  imagesizes="100vw"
  fetchpriority="high" />
  • loading="eager" (or simply omitting loading) so nothing is deferred.
  • fetchpriority="high" on the single LCP candidate, which raises it above other resources competing for bandwidth.
  • A preload hint for the hero, so its fetch starts during HTML parsing rather than after layout.

Use fetchpriority="high" on one image. Marking six as high priority means none of them is prioritised, and it steals bandwidth from the stylesheet and fonts that first paint also needs.

Deciding which images are above the fold is the awkward part, because it depends on viewport size and cannot be a property of the data. The practical approach is positional - mark the first row or two eager based on the grid's column count at the rendering breakpoint - and accept imprecision at unusual sizes. Server-rendered pages have an advantage here, since the markup can be generated with the right attributes rather than corrected after hydration; the reasoning is in Rendering Architecture.

The Lightbox

Opening a full-size view has two engineering concerns: preloading neighbours so navigation is instant, and focus management so the overlay is usable by keyboard.

const PRELOAD_NEIGHBOURS = 1; // one each side; 2 on fast connections

function usePreloadNeighbours(images: GalleryImage[], index: number) {
  useEffect(() => {
    const isSlow =
      navigator.connection?.saveData ||
      ['slow-2g', '2g'].includes(navigator.connection?.effectiveType ?? '');

    // Never spend a metered user's data speculatively.
    if (isSlow) return;

    const targets = [];
    for (let offset = 1; offset <= PRELOAD_NEIGHBOURS; offset += 1) {
      if (images[index + offset]) targets.push(images[index + offset]);
      if (images[index - offset]) targets.push(images[index - offset]);
    }

    const loaders = targets.map((image) => {
      // A detached Image() warms the HTTP cache without touching the DOM.
      const loader = new Image();
      loader.sizes = '100vw';
      loader.srcset = buildSrcSet(image);
      loader.src = image.url;
      return loader;
    });

    return () => {
      // Cancels in-flight speculative fetches if the user moves on quickly.
      for (const loader of loaders) loader.src = '';
    };
  }, [images, index]);
}

Two points. new Image() populates the HTTP cache without rendering anything, which is the cheapest possible preload. And respecting saveData and effectiveType matters: speculative loading is a bandwidth bet, and it is the wrong bet on a metered connection.

Focus management is where lightboxes most often fail:

function Lightbox({ images, index, onClose, onNavigate }: LightboxProps) {
  const dialogRef = useRef<HTMLDivElement>(null);
  const openerRef = useRef<HTMLElement | null>(null);

  useEffect(() => {
    // Remember where focus was, so it can be returned on close.
    openerRef.current = document.activeElement as HTMLElement;
    dialogRef.current?.focus();

    // Stop the page behind the overlay from scrolling.
    const previousOverflow = document.body.style.overflow;
    document.body.style.overflow = 'hidden';

    return () => {
      document.body.style.overflow = previousOverflow;
      // Returning focus is what stops a keyboard user being dumped at the top
      // of the document after closing.
      openerRef.current?.focus();
    };
  }, []);

  return (
    <div
      ref={dialogRef}
      role='dialog'
      aria-modal='true'
      aria-label={`Image ${index + 1} of ${images.length}: ${
        images[index].alt
      }`}
      tabIndex={-1}
      onKeyDown={(event) => {
        if (event.key === 'Escape') onClose();
        if (event.key === 'ArrowRight') onNavigate(index + 1);
        if (event.key === 'ArrowLeft') onNavigate(index - 1);
      }}>
      <img
        src={images[index].url}
        srcSet={buildSrcSet(images[index])}
        sizes='100vw'
        alt={images[index].alt}
        // The user asked for this image - never defer it.
        loading='eager'
        fetchPriority='high'
      />
      <div role='status' aria-live='polite' className='sr-only'>
        {`Image ${index + 1} of ${images.length}`}
      </div>
    </div>
  );
}

Four requirements: focus moves into the dialog on open, focus returns to the opener on close, Escape closes, and aria-modal plus a focus trap keep Tab inside the overlay. A lightbox that leaves focus behind it is one of the most common accessibility defects on image-heavy sites, because it is invisible to anyone testing with a mouse. The patterns are described in Accessibility Architecture.

Alt text deserves a note, since a gallery is where it is most often wrong. Alt text describes the image's purpose in context, not its file name and not its full caption. A decorative divider takes alt="" so screen readers skip it. A photo in a gallery takes a short description of what it shows. And a thumbnail that is the only content of a link takes text describing the destination, not the picture.

Common Interview Follow-Up Questions

"The gallery has 5,000 images. Does lazy loading solve it?" It solves bandwidth but not the DOM. Five thousand <img> elements plus their containers and placeholders is tens of thousands of nodes, and the layout cost of that tree is paid on every invalidation whether or not the images have loaded. So the gallery needs pagination or windowing, which is the subject of Virtualized List - and a grid is a slightly harder case than a list because windowing operates on rows whose column count changes with the viewport. There is a second, less obvious problem: browsers do not always release decoded image data promptly, so a long scroll session accumulates memory even for images that have scrolled far away, and unmounting rows is the only reliable way to let that go.

"Cumulative Layout Shift is 0.3 despite width and height on every image." Then the shift is probably not the images. Common culprits in a gallery are a font swap changing caption heights, an ad or embed slot with no reserved space, a "load more" button that moves as content arrives, and a placeholder whose aspect ratio does not match the image's - which relocates the shift rather than removing it. The way to find it is field data with attribution rather than a lab run, because the element that shifts often differs by viewport and by connection speed. If it genuinely is the images, the likely cause is a responsive grid where CSS determines the box and the attributes describe the file, which is exactly the case that needs explicit aspect-ratio on the container.

"How does this interact with a CDN and caching?" Heavily, and it is where most of the real performance comes from. An image CDN generates derivatives on demand from a single source, so you are not producing twelve files per image at build time, and it can negotiate format by Accept header so AVIF is served where supported without <picture> markup. Cache headers should be long and immutable with the transformation parameters in the URL, so a new size is a new URL rather than an invalidation. The one thing to watch is the cold-cache first request, where an on-demand transformation adds real latency - which is why the hero image's derivatives are worth warming at deploy time rather than discovering the cost on a user's LCP.

"How do you test this?" The pure parts are srcset and sizes generation, and they deserve a table of viewport widths and device pixel ratios asserting which candidate a browser should choose - it is arithmetic, and getting it wrong is invisible in a screenshot. Layout shift needs a real browser with the Layout Instability API, asserting a CLS of zero for the grid while images resolve in a staggered order. The lightbox's focus behaviour is fully testable in jsdom: open, assert focus moved in, Escape, assert focus returned to the trigger. What cannot be unit tested is whether the right image was chosen at a real device pixel ratio, so a transferred-bytes-versus-rendered-size audit belongs in CI as a budget check. The layering rationale is in Testing Strategy.

"A user is on a 2G connection with data saver enabled. What changes?" Several things, and the mechanism is navigator.connection. Skip speculative preloading of lightbox neighbours entirely, since a bandwidth bet is the wrong bet on a metered plan. Bias srcset selection downward by serving a narrower candidate set, or cap quality at the CDN. Reduce the lazy-loading margin so fewer images are fetched ahead of need. And consider a click-to-load mode where placeholders remain until tapped, which is aggressive but honest on a connection where a full grid is minutes of downloading. The important framing is that these are user-respecting defaults rather than degradation - the user has told you their constraint, and ignoring it is the failure.

Tradeoffs Table

OptionProsConsWhen to Use
Native loading="lazy"One attribute, no JavaScript, works before your bundle, connection-aware heuristicsNo custom threshold, no cross-fade, no priority ordering, <img> onlyDefault for every below-fold image
Intersection ObserverFull control of threshold, placeholder transitions, priority and analyticsJavaScript on the critical path, per-observer overhead, more code to maintainOnly for behaviours the native attribute cannot express
Single image sizeTrivial markup, one file per imagePhones download desktop pixels; wasted bandwidth, decode and memoryIcons, or images whose rendered size never varies
srcset plus sizesEach device downloads roughly what it needssizes is easy to state wrongly and the waste is invisibleAny content image in a responsive layout
Dominant colour placeholderEffectively free, prevents an empty gapConveys nothing about the imageLarge grids where payload matters most
Blurhash placeholder~28 chars, recognisable impression, scales to many imagesSmall decode library and a little main-thread workDefault for image grids
Inlined LQIPHighest fidelity preview, no extra request1-2 KB per image inflates the initial HTMLA single hero image, not a grid
Preload lightbox neighboursNavigation feels instantSpeculative bandwidth spent on images that may never be viewedOn unmetered connections, gated on saveData

Where This Applies

An image gallery is the most direct application of Performance Engineering in this track: LCP is decided by one above-fold image and whether it was prioritised, CLS is decided by whether space was reserved, and total transfer is decided almost entirely by whether sizes was declared accurately. The eager-versus-lazy split is easier to get right when markup is generated on the server with the correct attributes, which is one of the practical benefits of the strategies in Rendering Architecture. And the lightbox is a modal overlay with the focus obligations set out in Accessibility Architecture, alongside alt text, which is the accessibility decision a gallery makes most often and most often gets wrong.

Within this track, a long gallery hits the DOM ceiling described in Infinite Scroll and needs the windowing in Virtualized List. The images themselves usually arrive through the pipeline built in File Upload System, whose derivatives become the srcset candidates here. And the priority reasoning applies unchanged to the poster frame in Video Player, which is the LCP element on most video pages.

Advertisement

Frequently Asked Questions

When is native loading="lazy" enough, and when do you need Intersection Observer?

Native lazy loading is enough for the common case and should be the default, because it is one attribute, costs no JavaScript, works before your bundle has parsed, and the browser's own heuristics for how early to start loading are tuned per connection type in ways your code cannot easily match. It is not enough when you need control the attribute does not expose - a custom threshold because your rows are unusually tall, a placeholder that must cross-fade rather than pop, prioritising some images over others as they approach the viewport, loading something that is not an img element such as a background image or a video poster, or measuring exactly when an image entered the viewport for analytics. The pragmatic answer is to use the native attribute for the grid and reach for Intersection Observer only for the specific behaviours it cannot express, rather than replacing the whole mechanism.

What do srcset and sizes actually do, and why is sizes the part people get wrong?

srcset lists the available versions of an image with their intrinsic widths, and sizes tells the browser how wide the image will be laid out at any given viewport width. The browser needs both, because knowing that a 1600px file exists is useless without knowing whether the slot is 200px or 1200px wide. sizes is the part people get wrong because it is written before layout exists and is therefore easy to state incorrectly - and the browser trusts it absolutely, choosing a candidate from that declaration before stylesheets have necessarily been applied. A sizes value of 100vw on an image that is actually a third of the viewport wide causes the browser to download roughly nine times more pixels than needed, and nothing in the page will look wrong, so the waste is invisible without measurement.

What are the placeholder strategies and how do they compare?

A solid dominant colour is the cheapest, needing only a few bytes stored as a hex value, and it prevents an empty gap without pretending to be the image. A low-quality image placeholder is a tiny heavily compressed version of the real file inlined as a data URI, typically one to two kilobytes, which gives a recognisable blurred impression at the cost of inflating the HTML for every image on the page. Blurhash and thumbhash encode a blurred approximation into about twenty to thirty characters that are decoded to a gradient client-side, which is far smaller than an LQIP and looks better than a flat colour, at the cost of a small decoding library and a little main-thread work. The decision is mostly about how many images are on the page - inlining LQIPs for sixty thumbnails adds real weight to the initial HTML, which is exactly the payload you were trying to keep small.

How do you prevent layout shift from images, and why is width and height not the whole answer?

The browser needs to know an image's aspect ratio before the bytes arrive, so it can reserve the right box and avoid reflowing everything below when the image lands. Setting the width and height attributes is the classic fix and it still works, because modern browsers derive an aspect ratio from them even when CSS overrides the actual rendered size. It is not the whole answer because those attributes describe the intrinsic file, and in a responsive grid the rendered box is determined by CSS - so the reliable approach is an explicit aspect-ratio in CSS on the container, which holds regardless of the file's dimensions and regardless of whether the image loads at all. Reserving space is also what makes a placeholder useful, since a placeholder that is not the same shape as the image simply moves the shift rather than removing it.

What should be eager and what should be lazy?

Anything above the fold must be eager, and the largest above-fold image should additionally be marked as high priority, because it is almost certainly the Largest Contentful Paint element and delaying it directly delays the metric users feel as page speed. Lazily loading an above-fold image is actively harmful - it defers the one request that matters most, and browsers deliberately do not lazy-load images already in the viewport, so the attribute mostly serves to lose the priority signal. Everything below the fold should be lazy. The difficulty is that which images are above the fold depends on viewport size, so the split cannot be decided per image in a data file - it has to be decided by position in the rendered grid, typically by marking the first row or two eager based on the layout and accepting some imprecision at unusual viewport sizes.