TheRare Visuals

Why our gallery crashed phones, and why pagination fixed it

A 15 GB portrait gallery took down mobile browsers. The memory maths behind it, why lazy loading alone wasn't enough, and the frontend and backend changes that fixed it.

TheRare Engineering1 September 20269 min readIntermediate

The bug report

A customer opened their portrait gallery on a phone. The booking held about 15 GB of photos and 4K video. The page started loading, grew slower and slower as they scrolled, and then the browser tab crashed.

On a laptop it worked. On a fast Wi-Fi connection the phone still crashed. So it wasn't the network. It was memory, and understanding why is one of the most useful things a frontend developer can learn.

The memory maths nobody tells you

A JPEG file is compressed. To draw it, the browser has to decode it into raw pixels, and raw pixels are big: 4 bytes per pixel (red, green, blue and transparency).

So the memory an image takes on screen has nothing to do with its file size. It's:

width × height × 4 bytes

For a photo straight from a 24-megapixel camera:

ImagePixelsMemory once decoded
Camera original6000 × 4000about 96 MB
Our thumbnail640 × 427about 1.1 MB

That 10 MB JPEG costs roughly 96 MB of memory to show, even as a 200-pixel tile. A browser tab on a phone often gets somewhere around a gigabyte or two before the system kills it, and sometimes much less. Ten camera originals on screen can be enough.

Now multiply by a full gallery: several hundred photos and dozens of videos, all rendered into the page at once.

Video is worse. Each <video> element can open its own network connection, fetch part of the file to find its first frame, and hold a decode buffer. Hundreds of them on one page is hundreds of media pipelines.

Why loading="lazy" wasn't enough

The first thing everyone reaches for is loading="lazy" on images. We had it. It helps: images far below the screen don't download until you scroll near them.

But it doesn't solve this problem, for three reasons:

  • The elements still exist. Hundreds of tiles are still in the page, each with its own layout and event handlers.
  • Videos ignore it. A <video> element starts work as soon as it's in the page.
  • Memory only goes up. Everything you've scrolled past has been downloaded and decoded. The further a customer scrolls through a big gallery, the more memory the page holds.

We needed a hard ceiling: a guarantee that the page can never hold more than a fixed amount, however big the gallery is.

Fix 1: never show an original in a grid

The server now makes a small thumbnail for every uploaded photo, 640 pixels on its longest edge, stored next to the original (photo.jpg.thumb.jpg). It's served through the same signed-link system, so it has exactly the same access rules as the original.

One detail matters on the server too: originals can be 50-megapixel PNGs, and decoding one fully would hurt our server's memory the same way it hurt the phone. So the thumbnail generator subsamples while decoding, reading every Nth pixel instead of loading the full image first.

Grids show thumbnails. Only the full-screen viewer loads the full image, one at a time.

Fix 2: videos that don't start working until asked

For video tiles, the thumbnail is used as the poster, and we tell the browser not to fetch any video data:

TSX
<video
  src={item.thumbnailUrl ? src : `${src}#t=0.1`}
  poster={item.thumbnailUrl || undefined}
  preload={item.thumbnailUrl ? "none" : "metadata"}
  muted
  playsInline
/>

With a poster, preload="none" means the tile costs one small image and nothing else. Only old videos without a thumbnail fall back to the #t=0.1 trick, which makes the browser seek slightly into the file so it shows a real frame instead of black. That trick is expensive, since the browser has to fetch real video data, so we use it only when there's no alternative.

Fix 3: pagination, the hard ceiling

Then the real fix. Instead of rendering the whole gallery, we render one page: 10 files at a time on the customer dashboard, 20 in the longer lists.

TSX
const [page, setPage] = useState(0);
const totalPages = Math.max(1, Math.ceil(media.length / PAGE_SIZE));

const pageItems = useMemo(
  () => media.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE),
  [media, page],
);

When you move to page 2, page 1's tiles are removed from the page and the browser can release their memory. However large the gallery, the page never holds more than one page of tiles. That's the ceiling we needed.

We did consider infinite scroll, which loads more as you reach the bottom. It feels smoother, but it accumulates: every page you load stays. On a booking with hundreds of 4K videos, a determined scroller eventually hits the same crash. Replacing a fixed page can't creep up.

A few details that make pagination pleasant rather than annoying:

  • Always show the first and last page. With 20 files a page, a big gallery runs to a dozen pages, and the shots at the end of the shoot are often the ones people want. Pressing Next eleven times to reach them is miserable. Our pager shows 1 … 5 6 7 … 12.
  • Don't hide one page behind "…". An ellipsis standing in for a single number takes the same space and hides a destination, so we show the number.
  • Fix the page when the list shrinks. If a search leaves only 2 pages while you're on page 5, the grid would be empty. We clamp the page to the last one that exists.
  • Render nothing for one page. Small bookings look exactly as they did before.

Fix 4: within a page, mount tiles as they approach

Admin pages list 20 video rows per page, and each row has a player. Even 20 players at once is a lot on a phone, so each row is wrapped in LazyMount, which renders the real content only when the row comes within 300 pixels of the screen:

TSX
{assets.map((asset) => (
  <LazyMount key={asset.id} minHeight={220}>
    <VideoRow asset={asset} />
  </LazyMount>
))}

Before that, it's an empty box of the right height, so the scroll bar doesn't jump. We deliberately don't unmount rows when they scroll away again. Unmounting would keep memory even flatter, but scrolling back up would flicker as images decode again. Because the page size already caps the total, "mount once, keep" is the better trade. (Part 2 walks through how LazyMount works.)

The backend half: when the server was the slow part

Fixing memory made the page stable. A second problem made it slow: shared galleries took about a minute to appear after a guest signed in.

Every photo lives in a private bucket, so every link the browser uses must be signed, a URL with a time-limited signature that proves we allowed it. We expected signing to be quick local cryptography. On Cloud Run it isn't. The server has no private key file (a deliberate security choice), so each signature is a network call to Google's IAM service, around 65 milliseconds each.

A 300-file gallery needed two or three signed links per file (thumbnail, preview, download):

300 files × 3 links × 65 ms ≈ 1 minute

The fix had two parts.

Sign in parallel. The signing calls are independent network waits, so we run up to 16 at once on a small shared thread pool:

Java
public <T, R> List<R> map(List<T> items, Function<T, R> mapper) {
    if (items.size() <= 1) {
        return items.stream().map(mapper).toList();
    }
    List<CompletableFuture<R>> futures = items.stream()
            .map(item -> CompletableFuture.supplyAsync(() -> mapper.apply(item), executor))
            .toList();
    return futures.stream().map(CompletableFuture::join).toList();  // keeps the original order
}

The pool is shared by every request, so it also caps how many signing calls the whole server makes at once. One trap: the mapping function runs on another thread, so it must not touch anything tied to the database session. Hibernate sessions aren't thread-safe. We read the values we need first, then sign.

Sign only what's on screen. The first response now signs just the first page of each section, 48 files, and says how many there are in total. The rest arrive through a paging endpoint:

Text
GET /api/v1/public/share/{code}/media/page?section=ITEMS&offset=48&limit=48

Three rules keep it correct:

  • Stable order. Items are sorted by upload time and then by id. Without the id tiebreak, two photos uploaded in the same second could swap places between requests, and one could appear twice while another vanished.
  • A maximum page size. limit is capped at 96, so nobody can request 10,000 files in one call.
  • Check permission on every page. Each page request re-checks the guest's access token and the allow-list, not just the first one. If access is revoked mid-scroll, the next page is refused and the guest goes back to sign-in.

Pages or infinite scroll? It depends on how people browse

We use both, on purpose:

ScreenPatternWhy
Customer dashboardNumbered pagesCustomers jump around and come back; hard memory ceiling on phones
Admin media listsNumbered pages + LazyMountStaff need a specific file; rows contain players
Shared gallery for guestsInfinite scroll + "Load more" buttonGuests browse once, top to bottom

On the shared gallery, the next page loads 600 pixels before you reach the bottom, so scrolling never waits. In the full-screen viewer, it starts fetching when you're three photos from the end, so the Next arrow keeps working past the first page. The "Load more" button and "Showing 96 of 312" count are there for browsers where the automatic trigger doesn't fire.

Infinite scroll still accumulates, so on very large shared galleries the ceiling problem can return. The next step there is virtualisation, which keeps only the rows near the screen in the page and recycles the rest.

Offset or cursor pagination?

We use offset paging ("skip 48, take 48"). It's simple and lets you jump to page 7. Its weakness is when items are added while someone is paging: everything shifts, and a row can be seen twice or skipped. Our galleries rarely change while a guest is browsing, and the stable sort limits the damage.

Cursor paging ("give me 48 after this photo's timestamp and id") is immune to that, and it's faster on very large tables because the database doesn't count through skipped rows. It's the better choice for feeds that change constantly, like chat messages or notifications.

A checklist for your own media-heavy pages

  • Work out the decoded size of your images: width × height × 4. Show thumbnails in grids.
  • Give videos a poster and preload="none".
  • Put a hard ceiling on what's in the page: pagination or virtualisation. loading="lazy" alone is not a ceiling.
  • Reserve space for content that hasn't loaded, so the page doesn't jump.
  • Paginate on the server too, so you only do expensive work (signing, resizing) for what's on screen.
  • Sort by something unique, not just a timestamp.
  • Re-check permissions on every page request.
  • Test on a real mid-range phone. Laptops hide memory problems.

Next, Part 4 steps back to look at how the whole backend is organised.

Found this useful?

Built something with what you learned here, or spotted a better way? Tell us. We work with freelance developers on Shootlo.