ADR 0011: /images grid scroll performance
Date: 2026-07-13 Status: Accepted
Context
Section titled “Context”The /images gallery scrolled at 2-3 fps for a real user. It took several
rounds to fix, and most of those rounds changed the wrong thing. This ADR
records the single change that fixed it and, deliberately, the changes that did
not, so future work can tell the load-bearing fix from the speculative ones.
The debugging trap: a headless browser probe (Playwright/CDP) that scrolls via
element.scrollTop measured ~47 fps and never reproduced the jank. Programmatic
scrollTop moves content but moves no real pointer and drives no real
compositor frame the way a wheel or scrollbar-drag does, so every hypothesis
tested that way looked “smooth.” The bug was only cracked from a user-exported
DevTools Performance trace: parse the .json.gz, sum dur over traceEvents
by name, and read the dominant entries (RasterTask, Decode Image,
Decode LazyPixelRef, Commit). PaintImage events carry args.data.url;
Decode Image carries args.imageType.
The one fix that worked
Section titled “The one fix that worked”Thumbnails were resized by width only. The blob store holds full-page screenshots at extreme heights (one is 1280×179095). Width-only resize:
- turned tall captures into 360×1790-ish thumbnails that decoded to multi-MB
bitmaps (
Decode LazyPixelRef≈ 1 s in the trace); and - turned the 179095-tall one into 360×50333, which exceeds WebP’s 16383px max
dimension, so
sharpthrew, the route fell back to serving the full 14.7 MB PNG into a 180px cell, and that decoded at ~470 ms per scroll frame (Decode Image,imageType: png).
The fix (web/lib/images.ts resolveThumb): resize with fit: "cover" to the
grid cell’s 4:3 box (w × ¾w, top-anchored) with limitInputPixels: false.
Every thumbnail becomes tiny and uniform — 71 images went to a largest of
14.8 KB, 528 KB total, all 360×270, zero PNG fallbacks — and no thumbnail can
exceed WebP’s limit. Cache prefix changed .w<n> → .t<n> to invalidate the
stale width-only files. This was the only change that moved the needle.
General lesson: when resizing arbitrary user images to WebP, bound both dimensions; a large single axis silently exceeds the 16383px limit and throws.
Decision: what was kept vs reverted
Section titled “Decision: what was kept vs reverted”Several other changes were made while chasing this. Each was re-judged against “would I make this on its own merits, knowing the crop is the fix?”
Kept — independent value or load-bearing:
- The thumbnail pipeline itself (
?w=<px>→ cached WebP inresolveThumb+/api/image/[hash]). The crop is a property of this; it is the fix. content-visibility: autoon cards. A pure win once thumbnails are tiny: the ~55 off-screen cards on a 71-card grid skip decode/layout/paint, and every node stays in the DOM for browser Ctrl+F.ThumbCardmemoization + a stableonOpen. Stops all cards re-rendering on every live SSE fold during active image capture.- Suppressing the global
LiveRefresherrouter.refresh()on self-live routes (web/components/LiveRefresher.tsx)./api/streamfires on any.harnerychange, so during active production a self-live page was doing a full RSC re-render (tail-scanning a 67 MBevents.ndjson) ~4×/sec for nothing — the gallery already folds new events client-side. Genuinely wasteful independent of the scroll bug, so kept.
Reverted — speculative attempts that did not help and cost something:
- A
pointer-events: none-during-scroll guard (a scroll listener toggling the grid via refs). Premised on a hover-churn theory that scrollbar-drag later disproved; it added per-scroll timer work for zero benefit. - Removing the card/image hover transitions +
group-hover:scale. A cosmetic regression with no measured payoff; the original hover polish was restored. - Removing
backdrop-filterfrom the role badges + hover buttons. A deliberate frosted-glass design element; its removal was a guess that did not help, so the frosting was restored.
Result
Section titled “Result”Decode/raster, which was ~2 s of a ~2.5 s trace window, collapses to near zero:
no grid image can decode to more than a 360×270 bitmap. If /images scroll ever
regresses again, get a real DevTools trace and check the image-decode totals
first — do not trust a headless scrollTop probe, and do not re-add the
reverted hover/pointer-events changes expecting them to matter.