Performance

The gallery is optimized for a static-hosted, mobile-first photo browsing path. The current performance work was based on a PageSpeed mobile report from 2026-04-26 22:43 UTC, where the main bottlenecks were oversized first-screen thumbnails and JavaScript that was loaded before the user opened the viewer or special image formats.

Image Pipeline

The builder cache generates responsive thumbnail resources for every photo:

  • 360w WebP for narrow mobile columns.
  • 640w WebP for larger cards and higher-density screens.
  • 640w JPEG as a source and development fallback.

The source manifest keeps thumbnailUrl as the JPEG fallback and adds optional responsive fields:

interface PhotoManifestItem {
  thumbnailUrl: string
  thumbnailSrcSet?: string
  thumbnailWebpSrcSet?: string
}

thumbnailSrcSet points at the JPEG fallback and thumbnailWebpSrcSet points at both WebP variants. Development keeps that contract so builder consumers and local debugging still have a compatibility fallback.

Production output uses a narrower contract. During vite build, manifest, static-page, and sitemap data are normalized so thumbnailUrl points at the 640w WebP candidate and the JPEG-only thumbnailSrcSet is omitted. A post-build plugin then removes only copied dist/thumbnails/*.jpg files; it never modifies apps/web/public/thumbnails or builder caches. This avoids shipping a duplicate JPEG thumbnail set while keeping source data reusable.

The masonry gallery treats the first six items as first-screen candidates:

  • First six thumbnails use eager loading and high fetch priority.
  • Later thumbnails remain lazy-loaded with normal priority.
  • Each gallery item uses <picture> with responsive WebP sources. The production fallback is also WebP; development retains JPEG fallback behavior.
  • The Vite manifest injection plugin preloads the first two homepage thumbnails into index.html, so the browser can discover likely LCP images before React runs.

Manifest Loading

The build loads a hashed lightweight manifest script before the application module in index.html and emits the full manifest as a separate hashed JSON asset. It also emits the exact same full production payload at the stable /photos-manifest.json path for external API consumers; that copy uses revalidation caching and allows cross-origin reads. The lightweight manifest keeps fields needed for sorting, filtering, thumbnails, default Chinese descriptions, camera/lens display names, and first-screen rendering. English photo text loads separately for English, Japanese, and Korean interfaces. Detail views and the map fetch the hashed full manifest through __FULL_MANIFEST_URL__ only when needed. The manifest inspection page can also fetch it during development, but that route is excluded from production builds.

This keeps the initial viewport image budget low and avoids embedding detail-only metadata in the homepage payload.

Static Photo Metadata

Production builds also generate static HTML shells for every photo detail route at photos/<photo-id>/index.html. These files reuse the SPA entrypoint but replace the title, description, canonical URL, OpenGraph tags, and Twitter Card tags with photo-specific metadata.

That metadata is generated from the manifest after content/photo-descriptions.json has been merged, so crawlers and link unfurlers can read localized manual descriptions before React loads. JPEG and PNG photos use the original image for social previews; videos and browser-incompatible originals such as HEIC and TIFF use the production WebP poster. The feature adds HTML files to the static output, but it does not add extra JavaScript to the homepage path.

JavaScript Loading

The homepage path should avoid pulling viewer-only and conversion-heavy code:

  • Photo detail routes are no longer preloaded unconditionally from App.tsx.
  • Direct /photos/:id loads skip the masonry implementation until the user returns to the gallery. Homepage navigation still preloads that split, and the bundle-budget script explicitly includes it in homepage startup accounting.
  • The command palette is lazy-imported only when opened.
  • Live Photo and Motion Photo helpers dynamically import the image loader manager.
  • HEIC and TIFF converters are registered through dynamic imports, so ordinary JPEG and PNG viewer paths do not load heic-to or TIFF conversion code.
  • Vite generates the service worker with injectRegister: false and registerType: 'prompt'. AppUpdateProvider registers /sw.js after the window load event, then prompts the user when an update is ready. It also checks for updates on focus, visibility changes and network recovery.

The gallery uses the system sans-serif font stack defined in apps/web/src/styles/tailwind.css and the initial HTML shell. It does not request a Google Fonts stylesheet or swap to a downloaded Geist font.

Regeneration

After changing thumbnail formats, manifest fields, or first-screen loading behavior, regenerate and verify the generated files:

pnpm run build:manifest -- --force-thumbnails --force-manifest
pnpm --filter web type-check
pnpm build
pnpm run bundle:budget

Use pnpm --filter web analyze when checking chunk boundaries. The home route should not eagerly include HEIC/TIFF converter chunks, command palette code, or the photo detail route.

Bundle Budgets

pnpm run bundle:budget checks application code and gallery data separately, and reports the actual combined transfer size for each startup language and route. Adding photos or replacing repetitive placeholder captions with distinct descriptions increases the data payload even when application code stays unchanged.

The fixed application budgets cover JavaScript, CSS, and interface translations:

PathgzipBrotli
Homepage, each supported language270 KiB235 KiB
Photo viewer without GPS130 KiB130 KiB
Photo viewer with GPS520 KiB445 KiB
Map430 KiB380 KiB

The photo-viewer and map code budgets cover additional resources beyond the English homepage baseline. The GPS viewer and map include both the MapLibre main program and worker. Route reports show both additional transfer size and cumulative totals, including the homepage baseline and full metadata manifest.

Gallery-data budgets use validated photo counts, a fixed overhead, and an absolute cap. The index and full manifest must contain the same unique, non-empty photo IDs. English text counts only entries with a non-empty title or description whose ID belongs to that gallery. The allowance never depends on the measured size of the asset being checked:

Assetgzip allowanceBrotli allowance
Startup index8 KiB + 192 bytes per photo, capped at 128 KiB6 KiB + 144 bytes per photo, capped at 96 KiB
English photo text1 KiB + 80 bytes per translated photo, capped at 64 KiB1 KiB + 64 bytes per translated photo, capped at 48 KiB
Full metadata manifest8 KiB + 384 bytes per photo, capped at 256 KiB6 KiB + 288 bytes per photo, capped at 192 KiB

Both sets of checks must pass. Increasing application code cannot consume unused photo-data allowance, and unusually large captions or EXIF data still fail their own limits. Individual heavy-chunk, service-worker precache, and generated photo-HTML checks remain separate.

Pull-request CI uses a small public photo fixture, while production builds use the private gallery. Budget regression tests therefore also exercise 400 distinct synthetic metadata records, oversized data, and code growth. Before publishing changes to photo data or its loading path, run the budget check against the real gallery build as well as the fixture checks.

Validation Checklist

Before publishing a performance change, check:

  • Mobile masonry thumbnails render through responsive WebP sources.
  • Development still exposes the source JPEG fallback, while a production build contains no dist/thumbnails/*.jpg files.
  • Opening the photo viewer still works for ordinary images.
  • Live Photo and Motion Photo items still load their media.
  • Map markers and the development-only manifest page still have usable thumbnails through thumbnailUrl.
  • The hashed and stable full production manifests, the light manifest, sitemap.xml, and static photo pages contain no references to pruned JPEG thumbnails.
  • A production build serves the not-found experience at /manifest while the hashed full-manifest asset remains available to runtime features.
  • index.html contains image preloads for the first two homepage thumbnails.
  • /photos/<photo-id>/index.html files contain photo-specific title, description, canonical URL, and social image tags.
  • The production app registers /sw.js after window load through AppUpdateProvider; no injected registerSW.js is required. An available update is applied through the update prompt.
  • pnpm run bundle:budget passes after production build output exists.
Created At
Last Modified