Skip to content

Paginate folder listings: keep huge folders fast end to end - #24

Merged
shivsarthak merged 1 commit into
mainfrom
folder-pagination
Jul 11, 2026
Merged

shivsarthak merged 1 commit into
mainfrom
folder-pagination

Conversation

@shivsarthak

Copy link
Copy Markdown
Owner

Why

The README promises Holdfast "stays fast even with lots of files." Benchmarks showed the folder listing endpoint returned every file in one unbounded JSON response — 23 MB and ~95 ms server-side at 100k files — and the web app then mounted every tile at once. This PR makes folder browsing O(page) at every layer, with no visible change to the design or UX.

What

Backend

  • Keyset pagination on GET /api/folders/{id} and the shared-folder endpoints. Files page on (lower(name), name) — exactly the order files_folder_name_idx stores — 500 per page (file_limit caps at 1000). Small folders respond byte-for-byte as before: one response, no cursor.
  • The keyset condition is written as a redundant range term plus tie filter (lower(name) >= ? AND (lower(name) > ? OR name > ?)) because SQLite will not seek the index for a row-value comparison with bound parameters — it silently scans the folder from the top. Caught by benchmark, not by tests: the last page of a 100k-file folder was 14 ms before, ~1 ms after.
  • Cursor pages carry only the files window; the folder/ancestors/share-ids envelope ships once, on page one.
  • gzip for application/json responses (new middleware, downloads/previews pass through). Listing JSON compresses ~20×: a 500-file page is ~5.5 KB on the wire.

Frontend

  • Page one paints immediately; remaining pages stream in the background. The full listing stays in memory, so select-all, sorting, and preview navigation still see every file.
  • Windowed rendering: only ~200 tiles/rows are mounted at a time; an IntersectionObserver sentinel extends the window two viewports ahead of scroll. Applied to the dashboard grid, list view, and the anonymous share page.
  • Stale-response guards (loadSequence) drop pages still streaming for a folder you've navigated away from; in-place refresh assembles all pages off-screen and swaps once.

Numbers

Scenario Before After
List 200-file folder, 100k files elsewhere 34 ms* 0.45 ms
First page of a 100k-file folder 95 ms / 23 MB 1.1 ms / 5.5 KB wire
Deepest page of a 100k-file folder — 1.1 ms
Tiles mounted opening a 2,500-file folder 2,504 ~200
Select-all in that folder 2500 ✓ 2500 ✓ (unchanged)

*before the covering-index fix in migration 012; benchmarks live in internal/server/scale_bench_test.go.

Verified

  • go test ./..., new pagination + gzip tests, race-free vet
  • svelte-check / lint / build clean; all 16 Playwright full-stack e2e pass
  • Live browser session against a seeded 2,500-file folder: 5 requests × 5.5 KB, window extends seamlessly to the last file, ⌘A selects all 2,500 with only 400 tiles mounted; repeated with 2,500 image thumbnails (lazy-loaded, ~190 requests for the visited viewport, zero on revisit via HTTP cache)

Known trade-off: the scrollbar lengthens as the render window extends (standard infinite-scroll behavior); true virtualization with reserved heights is the future upgrade if 50k-image folders on low-RAM phones become a target.

🤖 Generated with Claude Code

Large folders previously arrived as one unbounded JSON response (23 MB at
100k files) and mounted every tile at once. Three changes make folder
browsing O(page) end to end with no visible UX change:

- Keyset pagination on GET /api/folders/{id} and the shared-folder
  endpoints: files page on (lower(name), name) via files_folder_name_idx,
  500 per page (file_limit caps at 1000). The keyset condition is spelled
  as a range term plus tie filter because SQLite will not seek the index
  for a row-value comparison with bound parameters. Cursor pages carry
  only the files window; the folder envelope ships once on page one.
- gzip for application/json responses (listing JSON compresses ~20x, a
  500-file page is ~5.5 KB on the wire); downloads pass through untouched.
- The web app streams remaining pages in the background after painting
  page one, keeping the full listing in memory so select-all, sorting,
  and preview navigation still see every file, while only a window of
  tiles/rows is mounted — an IntersectionObserver sentinel extends it
  two viewports ahead of scroll.

Benchmarks (BenchmarkScaleList*): first and deepest page of a 100k-file
folder both serve in ~1.1 ms / ~115 KB raw; the 200-file-folder listing
stays flat as unrelated files grow 1k -> 100k.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@shivsarthak
shivsarthak merged commit 2bd443e into main Jul 11, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant