Skip to content

perf(web): resolve dashboard thumbnail URLs during the page render - #3531

Merged
richiemcilroy merged 6 commits into
mainfrom
perf/dashboard-seeded-thumbnails
Oct 29, 2026
Merged

richiemcilroy merged 6 commits into
mainfrom
perf/dashboard-seeded-thumbnails

Conversation

@richiemcilroy

@richiemcilroy richiemcilroy commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

Dashboard thumbnails used to wait for hydration, then a VideosGetThumbnails RPC, and only then the image request. The caps and space pages now start that same lookup during the server render and stream the URLs to the client, so the image request goes out as soon as the page hydrates.

sequenceDiagram
  participant B as Browser
  participant S as Server
  Note over B,S: main
  B->>S: GET /dashboard/caps
  S-->>B: HTML + RSC
  B->>B: hydrate
  B->>S: POST /api/erpc VideosGetThumbnails
  S-->>B: signed URLs
  B->>B: load images
  Note over B,S: this PR
  B->>S: GET /dashboard/caps
  S-->>B: HTML + RSC, URL promise streams in the same response
  B->>B: hydrate
  B->>B: load images
Loading
  • apps/web/lib/seeded-thumbnails.ts runs Videos.getThumbnailURL for the page's caps with the same policy check, storage lookup and concurrency of 10 as the RPC handler. It does not await the result. It returns { videoIds, urls: Promise }, and the promise never rejects: an id that fails or has no thumbnail is left out of the map.
  • caps/page.tsx starts it as soon as the page's video rows are loaded, in parallel with the shared-spaces query. spaces/[spaceId]/page.tsx starts it for both the space and the org-wide variants. Caps with an active upload are excluded, because their thumbnail query stays disabled until the upload finishes.
  • VideoThumbnail reads the promise from SeededThumbnailsProvider with use() inside its own Suspense. A seeded URL becomes the query's initialData and is treated as fresh for five minutes (staleTime), so mounting it does not trigger a second lookup. Later mounts re-sign it once it is older than that, as main does on every mount. Signed URLs last an hour. Ids not in the map go through the RPC exactly as before, including retries and the error gradient.
  • The server HTML is unchanged: seeded cards render the same loading state, and the image mounts after hydration (useSyncExternalStore, components/VideoThumbnail.tsx:121). Putting the <img> in the HTML made the images download alongside the page's JS. On a 10 Mbps profile that pushed hydration back by about 400 ms, while the image still could not show before hydration because CapCard keeps it at opacity-0 until onLoad runs.
  • Pages without the provider (PublicCapCard, the space VideoCard, folder pages) behave as before.

Numbers

Production builds of main and this branch on a Mac mini. Both run against the same seeded MySQL and MinIO, with injected round-trip latency (4 ms to the database, 20 ms to S3). Headless Chromium at 1440x900, cold load, median of 7 runs, base and head alternating. "Throttled" is 4x CPU and 10 Mbps / 40 ms. "Thumbnails shown" means every thumbnail on the page is loaded and visible.

Page Profile Metric main this PR
/dashboard/caps, no folders fast first thumbnail shown 481 ms 367 ms
/dashboard/caps, no folders fast thumbnails shown 528 ms 417 ms
/dashboard/caps, 30 folders fast thumbnails shown 571 ms 457 ms
/dashboard/spaces/[id] fast thumbnails shown 573 ms 447 ms
/dashboard/caps, no folders throttled first thumbnail shown 2,211 ms 2,058 ms
/dashboard/caps, 30 folders throttled first thumbnail shown 2,324 ms 2,110 ms
/dashboard/spaces/[id] throttled first thumbnail shown 2,240 ms 2,047 ms
/dashboard/caps, /dashboard/spaces/[id] fast TTFB 71 / 72 ms 82 / 81 ms
/dashboard/caps, /dashboard/spaces/[id] fast hydrated 230 / 231 ms 243 / 239 ms
/dashboard/caps, /dashboard/spaces/[id] fast DOMContentLoaded 91 / 106 ms 110 / 149 ms
all of the above throttled hydrated, TBT within ±12 ms

Client navigation, with the thumbnails settled after the click:

Navigation main this PR
caps to space 542 ms 390 ms
back to caps 251 ms 60 ms
caps page 1 to page 2 455 ms 363 ms
caps page 2 to page 3 445 ms 299 ms

Cost

The lookups now run inside the page request, so they share the request's database pool with the dashboard layout. At concurrency 10, the per-video policy queries can take every connection in the default 10-connection pool while the layout still has queries waiting, which adds about 10 ms of TTFB unthrottled (15 runs each, freshly warmed servers). The HTML response also stays open until the lookups finish, which moves DOMContentLoaded. The space page starts its lookups as soon as its video rows load, so that tail stays short. Lower concurrency trades TTFB for a longer open response (concurrency 3: TTFB +1 to +3 ms, DOMContentLoaded +130 to +200 ms), so this PR keeps the RPC handler's concurrency of 10. With the layout query change in #3535 applied as well, TTFB on /dashboard/caps goes from 72 ms to 44 ms. A bulk video lookup for thumbnails would remove the contention entirely and is left for a follow-up.

On "back to caps", main refetches every cached thumbnail URL on remount and re-signs it, which forces a fresh download. Here a URL younger than five minutes is reused, and an older cached one is re-signed on mount as before. This was checked with a fake clock: no refetch one minute after leaving, and one refetch when returning to a page whose URLs are ten minutes old.

Signed URL hosts are unchanged. /api/erpc runs its handlers outside the HTTP request context, so on main the RPC does not apply the LAN rewrite for a localhost storage endpoint either. Loading the dashboard from another LAN address gives localhost:9011 thumbnail URLs on both builds.

With a deleted thumbnail object and a cap mid-upload on page 1, the settled page is pixel-identical to main. Only those two ids go through the RPC; on main all 15 do.

RetriggerView in GreptileConfidence Score: 5/5

The PR appears safe to merge; no blocking issue was established.

Summary

The PR starts dashboard thumbnail lookups during the server render and streams their URLs to the cards.

  • Dashboard cards load thumbnails sooner after page hydration.

Reviews (6) · Last reviewed commit: "fix(web): seed at most 50 thumbnail URLs..." · Reviewed by Greptile

@vercel

vercel Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
cap-status Skipped Skipped Oct 29, 2026 11:05am UTC

@vercel
vercel Bot temporarily deployed to Preview – cap-status October 29, 2026 00:06 Inactive
@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai, please re-review the PR

Comment thread apps/web/lib/seeded-thumbnails.ts Outdated
@vercel
vercel Bot temporarily deployed to Preview – cap-status October 29, 2026 00:10 Inactive
@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai, please re-review the PR

@capy-ai capy-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Capy found 2 potential issues (2 medium).

View 1 other finding in Capy.

Open in Capy

Comment thread apps/web/components/VideoThumbnail.tsx Outdated
if (videoId && Option.isSome(url)) seeded[videoId] = url.value;
});
return seeded;
}).pipe(Effect.provide(makeCurrentUserLayer(user)), runPromise);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Server-seeded storage URLs lose LAN hostname rewriting

Medium Severity

When another LAN device opens the dashboard with MinIO configured at http://localhost:9000, S3BucketAccess.getPublicSigningClient relies on HttpServerRequest to replace localhost with the request's private-network hostname. The existing RPC executes in the HTTP request context, but this helper supplies only CurrentUser through runPromise, whose runtime attaches password cookies but no HttpServerRequest. The seed therefore contains localhost URLs pointing at the viewer's own device. These count as successful seeds, suppress the RPC fallback, and produce broken thumbnails. Carry the incoming request context into signing, or omit seeding when the endpoint needs request-dependent rewriting.

Open in Capy

@capy-ai capy-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Capy found no issues.

View 1 other finding in Capy.

Open in Capy

@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai, please re-review the PR

@vercel
vercel Bot temporarily deployed to Preview – cap-status October 29, 2026 01:58 Inactive
@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai, please re-review the PR

@superagent-security superagent-security Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Superagent found 1 security concern(s).

Comment thread apps/web/lib/seeded-thumbnails.ts

@capy-ai capy-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Capy found no issues.

View 1 other finding in Capy.

Open in Capy

@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai, please re-review the PR

@capy-ai capy-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Capy found 1 potential issue (1 medium).

View 1 other finding in Capy.

Open in Capy

user: Parameters<typeof makeCurrentUserLayer>[0],
allVideoIds: readonly Video.VideoId[],
): SeededThumbnails | undefined {
const videoIds = allVideoIds.slice(0, MAX_SEEDED_THUMBNAILS);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seed cap routes excess ids into an RPC batch that rejects over 50

Medium Severity

The fallback this cap hands excess ids to batches unchunked in apps/web/lib/Requests/ThumbnailRequest.ts, where dataLoader runs with only window: "10 millis" and no maxBatchSize (@effect/experimental@0.56.0 defaults it to Infinity), and the VideosGetThumbnails payload schema rejects more than 50 ids (a.length <= 50, packages/web-domain/src/Video.ts:383). The dashboard pages read limit from the query string with no upper bound (Number(searchParams.limit) || 15) and render a card per fetched row, so ?limit=101 renders 101 thumbnails: the first 50 seed, and every remaining id mounts its query in the same hydration commit, lands in one dataLoader window, and goes out as a single VideosGetThumbnails call with 51+ ids, which the payload schema rejects; the resolver's catchAll then fails every request in the batch, and TanStack's retries use identical delays so each retry re-forms the same oversized batch and fails again, leaving every non-seeded thumbnail stuck on the error gradient. At the previous head all page ids were seeded server-side one at a time and rendered, so this is a regression for large-limit pages. Seed misses among the first 50 join the same batch, so even ?limit=100 fails wholesale from a single miss (50 fallback ids + 1 miss = 51). Chunk the client batch to the payload limit — pass maxBatchSize: 50 to dataLoader in ThumbnailRequest.ts (or wrap the resolver in RequestResolver.batchN(50)) — so the documented fallback actually works.

Open in Capy

@vercel
vercel Bot temporarily deployed to Preview – cap-status October 29, 2026 11:05 Inactive
@richiemcilroy
richiemcilroy merged commit bba8e42 into main Oct 29, 2026
38 checks passed

@capy-ai capy-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Capy found no issues.

Open in Capy

This branch was successfully deployed

1 active and 1 inactive deployments
Preview – cap-web — a54a0915 Deployed Oct 29, 2026 by vercel[bot]
Preview – cap-status — a54a0915 Deployed Oct 29, 2026 by vercel[bot]
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