From 9e9b1ba22407532a5f5201433cb5b9b908398d0c Mon Sep 17 00:00:00 2001 From: Claudio Date: Mon, 11 May 2026 11:06:36 +0200 Subject: [PATCH] perf(preview): debounce full-res /proxy preload by 400ms Rapid arrow-nav was firing one /proxy fetch per photo with no way to abort (new Image() has no abort). Holding the right arrow through ten photos in two seconds left ten multi-MB transfers in flight competing for bandwidth and the RAW/HEIC transcoder. Now the preload only kicks in if the user lingers on a photo for 400ms; otherwise the timer is cleared and no /proxy request is made. --- .../src/components/preview/PreviewImage.tsx | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/preview/PreviewImage.tsx b/frontend/src/components/preview/PreviewImage.tsx index 09d070b..6c65232 100644 --- a/frontend/src/components/preview/PreviewImage.tsx +++ b/frontend/src/components/preview/PreviewImage.tsx @@ -59,21 +59,30 @@ function PreviewStillImage({ photo }: { photo: Photo }) { const fullResSrc = getPreviewFullResSrc(photo) const src = usingFullRes ? fullResSrc : thumbSrc - // Reset everything when the photo changes, then start the background - // full-res preload. Cancel the preloader's callback on unmount/change so a - // late-arriving onload from the previous photo can't flip state for the - // current one. + // Reset everything when the photo changes, then queue a *debounced* + // background full-res preload. Debouncing matters because `new Image()` + // requests can't be aborted: rapid arrow-nav would otherwise leave a + // dozen multi-MB /proxy fetches in flight, saturating the user's + // bandwidth (and the backend's transcoder for RAW/HEIC) for photos the + // user already navigated past. Only photos the user lingers on for + // FULL_RES_PRELOAD_DELAY_MS trigger the /proxy fetch. useEffect(() => { setLoaded(false) setUsingFullRes(false) setScale(1) setOffset({ x: 0, y: 0 }) - const preloader = new Image() - preloader.onload = () => setUsingFullRes(true) - preloader.src = fullResSrc + const FULL_RES_PRELOAD_DELAY_MS = 400 + let preloader: HTMLImageElement | null = null + const timer = window.setTimeout(() => { + preloader = new Image() + preloader.onload = () => setUsingFullRes(true) + preloader.src = fullResSrc + }, FULL_RES_PRELOAD_DELAY_MS) + return () => { - preloader.onload = null + window.clearTimeout(timer) + if (preloader) preloader.onload = null } }, [photo.id, fullResSrc])