perf(ui): smaller initial page, slower idle polling

Photos grid was fetching per_page=500 on the very first request, which
serialized hundreds of thumbnail requests behind a single sort+payload.
Split into PER_PAGE_INITIAL=100 (one viewport, fast paint) and
PER_PAGE_BACKGROUND=500 (subsequent prefetch pages, fewer round-trips).

Idle polling for scan-status and worker-status was set to 10s / 15s
respectively. With nothing queued the typical session was firing 4–6
status requests every minute through the single uvicorn event loop on
top of everything else. Bumped both to 30s. While actively scanning /
processing the 2s / 3s cadence is unchanged — that's where the user
actually wants live updates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claudio
2026-05-10 21:28:16 +02:00
parent 4bb2c959a8
commit b1c3ee68dd
3 changed files with 16 additions and 14 deletions

View File

@@ -28,20 +28,20 @@ export function ScanProgress() {
queryKey: ['scan-status'], queryKey: ['scan-status'],
queryFn: () => library.scanStatus(), queryFn: () => library.scanStatus(),
refetchInterval: (query) => refetchInterval: (query) =>
query.state.data?.is_scanning ? 2000 : 10000, query.state.data?.is_scanning ? 2000 : 30000,
enabled: true, enabled: true,
}) })
const isScanning = scanStatus?.is_scanning ?? false const isScanning = scanStatus?.is_scanning ?? false
// Poll worker status to track vision queue activity. // Poll worker status to track vision queue activity.
// Fast polling (3s) while processing, slow (15s) otherwise. // Fast polling (3s) while processing, slow (30s) otherwise.
const { data: workerStatus } = useQuery<WorkerStatus>({ const { data: workerStatus } = useQuery<WorkerStatus>({
queryKey: ['worker-status-progress'], queryKey: ['worker-status-progress'],
queryFn: () => library.maintenance.workerStatus(), queryFn: () => library.maintenance.workerStatus(),
refetchInterval: (query) => { refetchInterval: (query) => {
const q = totalQueued(query.state.data) const q = totalQueued(query.state.data)
return q > 0 ? 3000 : 15000 return q > 0 ? 3000 : 30000
}, },
enabled: true, enabled: true,
}) })

View File

@@ -81,17 +81,19 @@ export function usePhotosQuery() {
// seeks directly to the next slice via an indexed range scan, // seeks directly to the next slice via an indexed range scan,
// O(1) regardless of depth (no OFFSET skipping). // O(1) regardless of depth (no OFFSET skipping).
// //
// MAX_PAGES is intentionally modest: 20 × 500 = 10 000 photos // First page is small (~one viewport) so the grid paints fast;
// covers almost every browsing session up-front without burning // background pages are larger so we still cover ~10k photos in
// through a 100k library on cold load. If a user scrolls past // a few round-trips without burning through a 100k library on
// that horizon we'll add an infinite-query trigger; for now the // cold load. If a user scrolls past that horizon we'll add an
// cap keeps cold-load memory / network pressure sane. // infinite-query trigger; for now the cap keeps cold-load
const PER_PAGE = 500 // memory / network pressure sane.
const PER_PAGE_INITIAL = 100
const PER_PAGE_BACKGROUND = 500
const MAX_PAGES = 20 const MAX_PAGES = 20
const INTER_PAGE_DELAY_MS = 50 const INTER_PAGE_DELAY_MS = 50
const first = await fetchCursorPage( const first = await fetchCursorPage(
{ per_page: PER_PAGE, ...filterParams }, { per_page: PER_PAGE_INITIAL, ...filterParams },
signal, signal,
) )
const firstBatch = first.photos || [] const firstBatch = first.photos || []
@@ -104,7 +106,7 @@ export function usePhotosQuery() {
if (signal?.aborted) return if (signal?.aborted) return
try { try {
const page = await fetchCursorPage( const page = await fetchCursorPage(
{ per_page: PER_PAGE, cursor: nextCursor, ...filterParams }, { per_page: PER_PAGE_BACKGROUND, cursor: nextCursor, ...filterParams },
signal, signal,
) )
if (signal?.aborted) return if (signal?.aborted) return
@@ -114,7 +116,7 @@ export function usePhotosQuery() {
['photos', filterParams], ['photos', filterParams],
(prev) => (prev ? [...prev, ...more] : more) (prev) => (prev ? [...prev, ...more] : more)
) )
if (!nextCursor || more.length < PER_PAGE) return if (!nextCursor || more.length < PER_PAGE_BACKGROUND) return
// Yield a beat between pages so the main thread stays // Yield a beat between pages so the main thread stays
// responsive (thumbnail decode, scroll handling) while // responsive (thumbnail decode, scroll handling) while
// we're back-filling in the background. // we're back-filling in the background.

View File

@@ -49,14 +49,14 @@ export function useScanActivity() {
queryKey: ['scan-status'], queryKey: ['scan-status'],
queryFn: () => library.scanStatus(), queryFn: () => library.scanStatus(),
refetchInterval: (query) => refetchInterval: (query) =>
query.state.data?.is_scanning ? 2000 : 10000, query.state.data?.is_scanning ? 2000 : 30000,
}) })
const { data: workerStatus } = useQuery<WorkerStatus>({ const { data: workerStatus } = useQuery<WorkerStatus>({
queryKey: ['worker-status-progress'], queryKey: ['worker-status-progress'],
queryFn: () => library.maintenance.workerStatus(), queryFn: () => library.maintenance.workerStatus(),
refetchInterval: (query) => { refetchInterval: (query) => {
const data = query.state.data const data = query.state.data
return pendingTasks(data) + activeTasks(data) > 0 ? 3000 : 15000 return pendingTasks(data) + activeTasks(data) > 0 ? 3000 : 30000
}, },
}) })