Fifty thousand photos.

A real 42,000-photo library froze our photo grid. This is the whole investigation — why it froze, why jumping to the middle took three seconds, and the four fixes that made it scroll. Measured on the actual library, not a benchmark.

Veya has a Photos app. It indexes a folder of images on your machine — local-first, no upload, the files never leave your disk — and shows them in a grid you can scroll, search, and open. On a few hundred photos it's lovely. Then I pointed it at my real library: 42,079 photos. It froze.

Not "slow." Froze — beachball, fans, the window unresponsive for long enough that you'd assume it had crashed. This is the kind of bug that's actually a gift, because a real library on a real machine is a far better test than any synthetic benchmark you'd think to write. So I kept the library and went looking. What follows is the order I found things in, including two mistakes I made along the way.

Problem one: the grid was decoding the originals

The first freeze was the dumbest and the most expensive. The grid was rendering each tile from the original file. A 12-megapixel photo is maybe 3 MB on disk — but decoded into pixels for the screen it's about 49 MB of RAM. Forty-nine megabytes, times a few hundred tiles trying to paint at once. The machine wasn't frozen so much as drowning.

The fix is the oldest trick in image UI: a thumbnail tier. A background worker decodes every photo once, down to a 256-pixel WebP — about 256 KB decoded instead of 49 MB — and the grid points at those. The 15,087 thumbnails for my library (de-duplicated by perceptual hash, so identical shots share one file) come to 817 MB on disk and decode almost for free. The grid stopped drowning. Memory went from gigabytes to a couple hundred megabytes.

Problem two: the grid mounted everything

With the decode pressure gone, the grid still got heavy as you scrolled, and the DOM kept growing. The reason: it leaned on a CSS trick (content-visibility) to skip painting off-screen tiles, but React was still mounting a tile element for every photo it had been handed. Scroll deep into 42,000 photos and you have tens of thousands of live DOM nodes, each holding a decoded image. The browser does that math and gives up.

The answer is a virtualizer: render only the cells in the visible row range, plus a little overscan, absolutely positioned inside a tall spacer the height of the whole grid — and unmount everything else. We wrote a small fixed-cell one (square tiles make the math trivial). After it landed I drove the live library and watched the mounted-cell count: it stayed a constant ~36 at the top, at 50%, at the very bottom, and back. The DOM is now a screenful no matter how big the library is, and memory stays flat as you scroll. That's the property you want — cost that scales with the screen, not the collection.

Problem three: the deep jump that took three seconds

This is the one I'm proudest of finding, because the symptom and the cause were a room apart. Scrolling was smooth — but if you grabbed the scrollbar and jumped from the top to somewhere deep, the new screen took seconds to fill in. "Is it sequential?" was the question that cracked it. It was. Two compounding things:

  • It rebuilt from the start, one page at a time. The data layer loaded photos in pages of 500, and on every scroll it rebuilt the window from offset zero with an await in a loop. To reach photo #20,000 it fetched forty pages back-to-back, each waiting for the last.
  • Every one of those pages did a full table scan. The grid sorts by date — COALESCE(exif_date, mtime), falling back to file time when a photo has no EXIF. There were indexes on each column separately, but none on that expression, so SQLite couldn't use them. Every page query scanned all 42,079 rows and built a temporary sort tree from scratch. I measured it: about 60 ms per page, flat at any offset — because the cost was the scan-and-sort, not the position.

Forty pages × 60 ms, in sequence, is your three seconds. The fixes were small and the payoff was not:

  • One index, on the actual sort expression. CREATE INDEX … ON photos(COALESCE(exif_date, mtime) DESC). The query plan went from "scan + temp sort" to "scan using index," and a page query dropped from ~60 ms to under 10 ms. One line, roughly a 10× cut, and it serves both newest-first and oldest-first (the index just gets read backwards).
  • Stop paging on scroll. Load it all, once. Photo metadata is tiny — a couple hundred bytes a row, about 8 MB for the whole library. There was never a reason to page it. So now we run one indexed query, hold the entire sorted list in memory, and let the virtualizer render a screenful out of it. Scrolling — even a jump to the far end — never touches the database again. It just indexes an array that's already there.

Deep jumps went from roughly three seconds to about 50 milliseconds. The whole "page in as you scroll" machinery — the sliding window, the page cache, the eviction — got deleted. The best fix is often the code you get to remove.

The mistake I shipped to myself first

Honesty clause, because every real bug hunt has one. When I switched to "load it all," I left a guard rail in the database layer from the old paging code: it clamped any query to at most 1,000 rows. The paged reader never hit it (pages were 500). The load-everything reader asked for five million and got… 1,000. So the grid loaded the first thousand photos and then stopped dead — a wall of blank tiles after a specific image. The person testing it (me) stared at it for a while before the number "1000" clicked. Two lines to fix, and a reminder that a defensive limit you forget about is just a bug with good intentions.

Then I profiled the real thing

"It feels fast" is not a number. So I instrumented the running app — a tiny debug-only bridge that lets a script drive the real build and read what's actually on screen — and ran it against the 42k library: jump to a random scroll position, wait until the visible thumbnails have painted (their image elements actually fired load, not just "the data is there"), record the time, repeat a couple hundred times. Then percentiles.

That render-versus-data distinction matters, and I got it wrong once: an early metric counted "rows present," reported great numbers, and the screen was still blank. The data being in memory tells you nothing about whether pixels reached the glass. The honest metric is the paint.

What the real library said:

  • Paint time was uniform across depth. Top of the library, middle, far end — the same. That's the whole point: the index and the in-memory list erased the "deep is slow" penalty. p50 was about 200 ms for a small viewport; a big full-screen grid is more, because it scales with how many thumbnails are on screen (~4.5 ms a thumbnail to fetch and decode), not with where you are.
  • Memory stayed flat across hundreds of jumps — no leak, the virtualizer holding the line.
  • And the profiler caught a self-inflicted one: a "skip decoding while the user is scrubbing fast" optimization was also firing on a single discrete jump, blanking tiles for an extra ~130 ms for no reason. A jump isn't a scrub. Now it only engages when you're actually dragging through.

What I'm taking from it

Four fixes, and not one of them was clever: render the small thing instead of the big thing; mount what's on screen and nothing else; index the column you actually sort by; and don't page what fits in memory. The work wasn't inventing anything — it was finding, on a real library, which boring thing was wrong, and resisting the urge to fix the symptom instead of the cause. A slow deep-scroll looked like a rendering problem and was a missing index. A frozen grid looked like "too many photos" and was one wrong image source.

A couple of honest edges remain. There's a tier of HEIC photos — about 8,800 of mine — that we still can't decode, because the codec isn't a one-line add; for now those tiles show a clean placeholder instead of a broken image, and the real fix is queued. And the per-thumbnail fetch cost is the next thing worth shaving. But the library scrolls now, top to bottom, on a five-year-old laptop, with the photos never leaving it. That was the whole idea.

This is landing across a handful of pull requests as I write — measured on the build, not yet in one you can download. The time to chase three seconds is before anyone's waiting on them.