DEV Community

Cover image for Building a Fast Responsive Image Grid Without Layout Shift
GalleryDock
GalleryDock

Posted on

Building a Fast Responsive Image Grid Without Layout Shift

A gallery page contains 150 photos.

On a desktop connection it looks fine. Then you open it on a phone.

The page jumps while images load. Scrolling stutters. The browser downloads files much larger than the screen needs. Lazy loading helps a little, but the first several seconds still feel unstable.

Large image grids expose frontend performance problems very quickly.

The solution is not one optimization. You need layout stability, responsive image delivery, controlled lazy loading, and a rendering strategy that avoids making the browser process the entire gallery at once.

Reserve image space before the file arrives

One of the easiest ways to create layout shift is rendering an image without telling the browser its dimensions.

Consider:


html
<img src="/photos/portrait.jpg" alt="Portrait">
Until the image metadata arrives, the browser may not know how much vertical space it needs.
When the image loads, everything below it moves.
Instead, provide dimensions:
<img
  src="/photos/portrait.jpg"
  width="1600"
  height="1067"
  alt="Portrait"
>
The browser can calculate the aspect ratio immediately.
Modern CSS also makes this explicit:
.gallery-item {
  aspect-ratio: var(--ratio);
  overflow: hidden;
}

.gallery-item img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}
If image dimensions are already stored in your media metadata, generate the ratio server-side.
const ratio = `${image.width} / ${image.height}`;

return (
  <figure
    className="gallery-item"
    style={{ "--ratio": ratio }}
  >
    <img
      src={image.url}
      alt={image.alt}
      width={image.width}
      height={image.height}
    />
  </figure>
);
That metadata is more valuable than it looks. Width and height should ideally be extracted during upload processing rather than calculated in the browser.
Do not send desktop-sized images to phones
A responsive grid might display an image at 320 pixels wide on mobile and 700 pixels wide on desktop.
Sending a 3000-pixel image in both cases wastes bandwidth.
Use srcset:
<img
  src="/media/photo-800.webp"
  srcset="
    /media/photo-400.webp 400w,
    /media/photo-800.webp 800w,
    /media/photo-1200.webp 1200w,
    /media/photo-1600.webp 1600w
  "
  sizes="
    (max-width: 600px) 50vw,
    (max-width: 1200px) 33vw,
    25vw
  "
  width="1600"
  height="1067"
  alt="Wedding portrait"
>
The browser now chooses an appropriate resource based on viewport size and device pixel ratio.
This is usually better than selecting image sizes with JavaScript because the browser knows more about the current rendering environment.
Your image pipeline should therefore generate several variants when media is processed.
For example:
original.jpg
400.webp
800.webp
1200.webp
1600.webp
You may also generate AVIF versions when your infrastructure supports it efficiently.
Use modern formats without breaking fallback behavior
The <picture> element works well when you want multiple formats:
<picture>
  <source
    type="image/avif"
    srcset="
      /photo-400.avif 400w,
      /photo-800.avif 800w
    "
  >

  <source
    type="image/webp"
    srcset="
      /photo-400.webp 400w,
      /photo-800.webp 800w
    "
  >

  <img
    src="/photo-800.jpg"
    width="1600"
    height="1067"
    alt="Portrait"
  >
</picture>
The browser uses the first supported format.
This lets you improve transfer size without forcing format detection into application code.
One mistake is generating every possible format and dimension combination without considering storage and processing cost.
A media pipeline can easily explode into dozens of derived files per upload.
Choose a small number of useful breakpoints based on actual gallery layouts.
Lazy loading is useful, but not for everything
Native lazy loading is usually enough for images far below the fold:
<img
  src="/photo.webp"
  loading="lazy"
  width="1600"
  height="1067"
  alt="Portrait"
>
But do not blindly add loading="lazy" to every image.
The first visible images should load immediately.
For an important image near the top of the page, you can use:
<img
  src="/hero.webp"
  loading="eager"
  fetchpriority="high"
  width="2000"
  height="1200"
  alt="Gallery cover"
>
Overusing fetchpriority="high" is another common mistake.
If 20 images are high priority, none of them are meaningfully prioritized.
Reserve it for genuinely important above-the-fold content.
Avoid rendering hundreds of complex cards at once
Images are only part of the problem.
A gallery item may also contain:
favorite controls
comments
selection state
download buttons
price information
menus
hover effects
Rendering 300 full interactive cards at once can become expensive even if images are lazy-loaded.
One simple CSS optimization is content-visibility:
.gallery-item {
  content-visibility: auto;
  contain-intrinsic-size: 400px 300px;
}
This allows the browser to skip much of the rendering work for off-screen content.
For extremely large collections, virtualization may be appropriate, but photo grids make virtualization harder because item heights can vary.

Before adding a virtualization library, measure whether simpler containment and incremental rendering solve the problem.
Build the grid without JavaScript layout calculations
A basic responsive grid does not need JavaScript.
.gallery {
  display: grid;
  grid-template-columns:
    repeat(auto-fill, minmax(240px, 1fr));
  gap: 12px;
}
This adapts naturally to available width.
For more visually varied galleries, CSS columns can create a masonry-like layout:
.gallery {
  columns: 4 280px;
  column-gap: 12px;
}

.gallery-item {
  break-inside: avoid;
  margin-bottom: 12px;
}
Each approach has trade-offs.
CSS Grid gives predictable row structure. Columns produce a masonry-like appearance but alter visual ordering behavior.
Choose based on product requirements, not just appearance.
Cache derived images aggressively
Generated image variants are ideal CDN assets because they usually do not change.

Give immutable derived files versioned names:
photo_abc123_800.webp
Then use long-lived caching:
Cache-Control: public, max-age=31536000, immutable
If an image changes, create a new asset identifier instead of overwriting the old URL.

This makes browser and CDN caching much more effective.
Private media requires a different authorization strategy, but the underlying principle remains useful: avoid regenerating and retransferring identical derivatives unnecessarily.
Measure the actual gallery
Optimizing individual images is not enough.
Test realistic pages.
A gallery with six sample images may hide problems that become obvious with 200.
Measure:
Largest Contentful Paint
Cumulative Layout Shift
total transferred image bytes
number of image requests
main-thread rendering time
mobile scrolling performance
Also test slow mobile networks.

A layout that feels perfect on gigabit Wi-Fi can behave very differently on a constrained connection.
The best image grid does less work
Fast media interfaces usually come from reducing unnecessary work.
Reserve dimensions before images load.

Send files close to their rendered size.
Let the browser choose responsive resources.
Lazy-load content that is genuinely off-screen.
Avoid rendering complex UI before it is needed.
Cache immutable derivatives aggressively.

None of these techniques is particularly exotic.
But together they change a large photo gallery from a page that merely works into one that remains stable and responsive even when the media collection becomes large.
Enter fullscreen mode Exit fullscreen mode

Top comments (0)