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:
| Image | Pixels | Memory once decoded |
|---|---|---|
| Camera original | 6000 × 4000 | about 96 MB |
| Our thumbnail | 640 × 427 | about 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:
<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.
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:
{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:
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:
GET /api/v1/public/share/{code}/media/page?section=ITEMS&offset=48&limit=48Three 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.
limitis 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:
| Screen | Pattern | Why |
|---|---|---|
| Customer dashboard | Numbered pages | Customers jump around and come back; hard memory ceiling on phones |
| Admin media lists | Numbered pages + LazyMount | Staff need a specific file; rows contain players |
| Shared gallery for guests | Infinite scroll + "Load more" button | Guests 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.