Performance investigation notes for the jellyfin-vue fork.
This file is now the combined performance document. It folds in the measured audit from perf.md on 2026-05-23 and keeps the earlier planning context from FORK_ROADMAP.md section 5 / section 6.
Goal context: reduce the "loading-heavy" feel relative to Jellyfin Web while keeping the existing architecture recognizable.
Date: 2026-05-23
Repo path: /Users/brooklyn/data/jellyfin-vue
Checkout audited: develop at 49582bc0, with a dirty working tree. The build and source audit include the current local changes, including untracked pages under packages/frontend/src/pages/photo/ and packages/frontend/src/pages/settings/plugins.vue.
Primary measurement command:
pnpm -C packages/frontend buildRuntime used for the build: Node v24.15.0, pnpm 11.1.2.
Limitations: this audit did not attach to a real Jellyfin server, so API latency, server payload byte counts, image response sizes, and Lighthouse field timings were not measured. The conclusions below are based on production bundle output, generated HTML, and source-level startup/data-flow inspection.
Do not rebuild these systems:
- IndexedDB response cache in a Web Worker under
packages/frontend/src/store/dbs/. - Stale-while-revalidate fetching, request cancellation (
cancellableWrapper), and argument-level dedup (deepEqual) inpackages/frontend/src/composables/apis.ts. - Effect teardown on route change (
JView_isRouting). - Session memoization for
fetchIndexPage()inpackages/frontend/src/composables/use-index-page.ts. - Virtualized item grids through
JVirtual. - Blurhash placeholders decoded and drawn off-thread.
- Vendor chunking in
packages/vite-plugins/src/bundle.ts.
The work is tuning and rendering, not cache-building.
The current first load is dominated by work that does not belong on the first screen.
- The production build is
4.0 MiBraw,1.16 MiBgzip,974.8 KiBbrotli across 141 files. - The generated
index.htmlreferences 39 initial resources totaling3.04 MiBraw,801.0 KiBgzip,651.8 KiBbrotli before the app can be useful. packages/frontend/vite.config.ts:31-40setsvue-router/vitetoimportMode: 'sync', so every page component is statically imported into the startup graph.- The first page is told to preload playback-only and route-only libraries:
hls.js,libpgs,assjs,audiomotion-analyzer,swiper,sortablejs,marked,dompurify, the full Vuetify runtime, date-fns locale data, and most app code. packages/frontend/src/App.vue:15renders<PlayerElement v-if="remote.auth.currentUser.value" />. Because component auto-imports are static, this pulls the player, HLS, ASS/PGS subtitle renderers, and playback stores into the base app even when nothing is playing.packages/frontend/src/main.ts:48-52blocks Vue mounting on font loading even though the built font CSS usesfont-display: swap.packages/frontend/src/layouts/default.vue:30awaitsuseIndexPage()just to render the default layout drawer. That function fetches home-page data too, so many non-home routes wait on home data.packages/ui-toolkit/src/components/JImg.vue:3-19uses<link rel="preload" as="image">for every mounted image and renders images withdecoding="sync". Off-screen cards are therefore pushed toward eager image fetching instead of native lazy loading.
Highest ROI implementation order:
- Make page routes async by default and verify the initial HTML no longer preloads feature-route chunks.
- Lazy-load the root player and move HLS/ASS/PGS imports to the playback/subtitle paths that actually need them.
- Stop blocking
app.mount()on font readiness and reduce the startupconfig.jsonround trip cost. - Split default-layout library views from full home-page data and render home sections progressively.
- Replace image-wide preloading with visible/hero-only priority, native lazy images, explicit image sizes, and bounded server image widths.
- Lazy-load date-fns locales and reduce all-fields/all-image-types API requests after measurement.
Build result summary:
| Area | Files | Raw | gzip | brotli |
|---|---|---|---|---|
| All JS | 130 | 3457.8 KiB | 1054.7 KiB | 890.6 KiB |
| All CSS | 4 | 538.1 KiB | 73.7 KiB | 49.4 KiB |
| Fonts | 2 | 29.7 KiB | 29.7 KiB | 29.7 KiB |
| Other | 5 | 20.2 KiB | 6.0 KiB | 5.1 KiB |
| Total build | 141 | 4045.9 KiB | 1164.2 KiB | 974.8 KiB |
Generated initial page:
packages/frontend/dist/index.htmlhas 1 module script, 32 modulepreload links, and 4 stylesheets.- Initial resources referenced by HTML total
3113.5 KiBraw,801.0 KiBgzip,651.8 KiBbrotli. config.jsonis not linked in the HTML, butpackages/frontend/src/utils/external-config.ts:55-67fetches it during module evaluation, adding a separate startup round trip.
Largest initial resources:
| Resource | Raw | gzip | brotli | Why it is concerning |
|---|---|---|---|---|
assets/vendor/hls.js-*.js |
497.4 KiB | 152.3 KiB | 124.6 KiB | Playback-only, loaded before playback |
assets/main-*.js |
464.0 KiB | 117.7 KiB | 93.6 KiB | App and all sync route code |
assets/vendor/vuetify-*.js |
416.5 KiB | 129.8 KiB | 106.0 KiB | Framework runtime |
assets/vendor/vuetify-*.css |
393.5 KiB | 48.6 KiB | 28.6 KiB | Render-blocking CSS |
assets/vendor/date-fns-*.js |
319.4 KiB | 72.8 KiB | 54.2 KiB | Date functions plus locale imports |
assets/vendor/jellyfin-*.js |
241.6 KiB | 31.4 KiB | 24.2 KiB | SDK, needed, but benefits from route/API splitting |
assets/main-*.css |
139.7 KiB | 23.8 KiB | 19.8 KiB | Includes route/component CSS due sync imports |
assets/vendor/swiper-*.js |
111.9 KiB | 32.6 KiB | 28.9 KiB | Home/player carousel only |
assets/vendor/i18next-vue-*.js |
84.6 KiB | 32.9 KiB | 29.6 KiB | Core i18n integration |
assets/vendor/libpgs-*.js |
61.4 KiB | 22.0 KiB | 19.3 KiB | PGS subtitle playback only |
assets/vendor/marked-*.js |
40.4 KiB | 12.2 KiB | 11.1 KiB | Markdown/HTML rendering only |
assets/vendor/sortablejs-*.js |
35.8 KiB | 12.4 KiB | 11.2 KiB | Playlist/queue drag only |
assets/vendor/assjs-*.js |
33.0 KiB | 12.2 KiB | 11.0 KiB | ASS subtitle playback only |
assets/vendor/audiomotion-analyzer-*.js |
29.7 KiB | 11.7 KiB | 10.5 KiB | Music visualizer only |
assets/vendor/dompurify-*.js |
23.1 KiB | 9.2 KiB | 8.2 KiB | Sanitized HTML/markdown only |
The raw JS parse/compile cost matters even when compressed transfer looks acceptable. On mobile and low-power TV browsers, 3.0 MiB of initially referenced JS/CSS is a large startup tax.
The current startup path is roughly:
index.htmlloads the generated entry.- The generated entry imports splashscreen and main.
main.tsimports i18n, routes, router, remote, Vuetify, styles, and root app.external-config.tsfetchesconfig.jsonwithcache: 'no-store'at top level.remote/auth.tsconstructs the auth singleton and immediately starts_bootstrap().main.tsregisters all generated routes, installs plugins, then waits forrouter.isReady(),font.load()for every document font, anddocument.fonts.ready.MainView.vuewraps layouts and pages in Suspense, so top-level awaits in layout/page setup block the visible routed page.
Important source references:
packages/frontend/src/utils/external-config.ts:55-67fetches and awaitsconfig.json.packages/frontend/src/main.ts:35-42adds all generated routes and installs app plugins.packages/frontend/src/main.ts:48-52waits for route readiness and fonts before mounting.packages/frontend/src/plugins/remote/auth.ts:399-407refreshes current user and servers immediately after singleton construction.packages/frontend/src/components/Layout/MainView.vue:6-27suspends route/layout rendering and shows the splashscreen fallback only at root.packages/frontend/src/layouts/default.vue:30awaitsuseIndexPage()before the default layout resolves.
| Ref | Bottleneck | Likely cause | Severity |
|---|---|---|---|
| P0-A | Startup downloads most of the app | vue-router/vite uses importMode: 'sync' |
Critical |
| P0-B | Playback libraries are initial | Root App.vue statically pulls PlayerElement; player store imports HLS/ASS/PGS |
Critical |
| P0-C | Default layout waits on full home data | layouts/default.vue awaits useIndexPage() |
Critical |
| P1-A | App mount waits for fonts | main.ts waits on all font faces and document.fonts.ready |
High |
| P1-B | Startup has a blocking config fetch | external-config.ts top-level awaits config.json with cache: 'no-store' |
High |
| P1-C | date-fns locale data is initial | use-datefns.ts imports all generated locale exports synchronously |
High |
| P1-D | Images are too eager | JImg preloads every image and decodes synchronously |
High |
| P1-E | Item API payloads are likely oversized | apis.ts requests every ItemFields and every ImageType |
High, needs server measurement |
| P2-A | CSS is render blocking and mostly initial | Sync routes pull scoped CSS; Vuetify CSS is global | Medium |
| P2-B | WebSocket subscriptions may be broad | socket subscribes to tasks/activity/sessions immediately | Medium, needs server measurement |
| P2-C | Warm navigation still revalidates | SWR refreshes even when cached data exists | Medium, behavior-sensitive |
| P2-D | Page re-navigation remounts views | No route <KeepAlive> |
Medium, behavior-sensitive |
| P2-E | Large-library memory/jank uncertain | Grid virtualizes, but route data may still hold large arrays | Medium, needs profiling |
Evidence:
packages/frontend/vite.config.ts:31-40configuresVueRouter({ importMode: 'sync' }).- The generated
dist/index.htmlpreloads 32 JS chunks for the first page. - Initial resources include route-specific dependencies for playback, settings, search, metadata, playlists, and home carousels.
Impact:
- Users visiting
/server/login,/server/select, or any single route still download and parse most of the app. - Admin/settings pages and playback libraries affect first load even when the user is unauthenticated.
- CSS code splitting is largely defeated because route-scoped styles are pulled into initial CSS.
Recommendation:
- Switch generated route imports to async by default.
- If needed, keep only tiny server/login/wizard routes eager after measurement. The default should be lazy route chunks.
- Group chunks by product area if the default split is too fragmented:
server,home,item-detail,library,playback,settings-admin. - After the change, rebuild and check that
dist/index.htmlno longer preloadshls.js,swiper,sortablejs,libpgs,assjs, admin settings chunks, or route CSS for pages that are not needed initially.
Expected result:
- The initial JS/CSS transfer should drop substantially. A reasonable first target is below
350 KiBgzip initial JS+CSS for unauthenticated server pages, excluding images and fonts.
Risks:
- Route metadata and guards must still work before component code is loaded. Verify generated route records still include
<route>block metadata. - Suspense fallback behavior may become more visible during route chunk loads. That is acceptable if the fallback is fast and stable.
Evidence:
packages/frontend/src/App.vue:15includes<PlayerElement v-if="remote.auth.currentUser.value" />.- Component auto-import makes that a static import, even though the template has
v-if. packages/frontend/src/components/Playback/PlayerElement.vue:45-46statically importshls.jsand the HLS worker URL.packages/frontend/src/store/player-element.ts:7-9statically importsassjs,libpgs, and the PGS worker URL.- Initial build output preloads
hls.js(497.4 KiBraw),libpgs(61.4 KiBraw),assjs(33.0 KiBraw), and the playback-related app code.
Impact:
- The base app pays for video playback, HLS, ASS subtitles, and PGS subtitles before playback starts.
- The unauthenticated login/select flow pays for playback code because all routes and root components are sync.
Recommendation:
- Replace the static root player with an explicit async component:
const PlayerElement = defineAsyncComponent(() =>
import('#/components/Playback/PlayerElement.vue')
);- Gate loading more narrowly than
currentUserif possible. For example, mount the player when playback state first needs a local media element, not immediately on login. - Move
hls.jsimport into the branch that needs HLS playback. Native direct-play audio/video should not require loading HLS. - Move
assjsinto_applySsaSubtitles()andlibpgsinto_applyPgsSubtitles(). - Make
MusicVisualizerlazy whenviewMode === 'visualizer'inpackages/frontend/src/pages/playback/music.vue:46-49, sinceaudiomotion-analyzeris otherwise a first-load dependency. - Make
DraggableQueueand playlist drag support lazy on queue dialog / playlist route activation, sincesortablejsis only needed for drag interactions.
Expected result:
- Remove roughly
210 KiBgzip from the initial graph just from HLS, subtitle renderers, visualizer, Swiper, Sortable, and nearby playback code, before accounting for app chunk shrinkage.
Risks:
- Player persistence across route changes is important. Keep the player mounted once active; just do not load it before it is needed.
- Dynamic HLS import must not delay first frame too much. Preload HLS only when the user initiates video playback or when playback info indicates HLS/transcode is required.
Evidence:
packages/frontend/src/layouts/default.vue:30doesconst { views } = await useIndexPage();.packages/frontend/src/composables/use-index-page.ts:30-33memoizesfetchIndexPage(), which fixed the duplicate home fetch problem.packages/frontend/src/utils/items.ts:594-652still fetches home data as one large unit:- First
getUserViews. - Then latest media per library.
- Then seven home rows: resume video, latest media carousel, next up, resume audio, favorite albums, favorite artists, favorite songs.
- First
MainView.vueSuspense means this top-level await delays layout/page resolution.
Impact:
- Any default-layout route may wait for home-page sections just to draw the navigation drawer.
- Home first paint waits for the slowest library/latest/favorite request instead of rendering rows as they arrive.
- The memoization prevents duplicate work, but it also makes a broad request bundle the default dependency for many routes.
Recommendation:
- Split
useIndexPage()into at least two layers:useLibraryViews()for the default layout drawer.useHomeSections()for the home page only.
- In
layouts/default.vue, await only user views or render the shell immediately with a drawer skeleton. - In
pages/index.vue, render the carousel and each section progressively as data resolves instead of awaiting all rows before the route resolves. - Keep the existing session memoization, but memoize each section independently so one slow library does not block unrelated sections.
- Record route resolve time before and after using marks in
MainView.vueSuspense@pendingand@resolve.
Expected result:
- Logged-in non-home routes should stop waiting on home data.
- Home should show the layout and first available rows sooner, even if later sections are still loading.
Risks:
- Progressive sections change render order and loading states. Treat this as behavior-changing until validated.
- Avoid layout shift by reserving stable row/carousel heights.
Evidence:
packages/frontend/src/main.ts:48-52waits for every current font face toload()and then waits fordocument.fonts.ready.packages/frontend/src/assets/styles/index.css:1imports@fontsource-variable/figtree.- The built font CSS uses
font-display: swap, but waiting in JS defeats much of that benefit. - Font files are small (
29.7 KiBtotal), but they still add network and font subsystem latency to app mount.
Impact:
- The user stays on the splashscreen until fonts are ready, even though the app could render with fallback fonts and swap later.
- This is especially noticeable on cold mobile connections or offline/poor-cache cases.
Recommendation:
- Do not block
app.mount()on font loading. - Mount after
router.isReady()and let CSSfont-display: swaphandle font replacement. - If typography flash is unacceptable, preload only the primary Latin font in HTML and continue to mount without waiting.
- Consider loading non-default font choices after initial mount.
Evidence:
packages/frontend/src/utils/external-config.ts:55-67fetchesconfig.jsonat top level.- The fetch uses
cache: 'no-store'. packages/frontend/public/config.jsonis only 86 bytes, but it is still a separate request that blocks modules importingjsonConfig.- Router creation and default server logic depend on
jsonConfig.
Impact:
- Every cold load has an extra network round trip before main app setup can finish.
no-storeprevents normal browser caching and conditional reuse.
Recommendation:
- Prefer an inline config script in
index.htmlfor hosted deployments, withconfig.jsonas fallback. - If the file must remain external, add an early preload:
<link rel="preload" as="fetch" href="./config.json" crossorigin>- Reconsider
cache: 'no-store'.cache: 'no-cache'still revalidates but lets the browser use conditional requests. - Keep the runtime override behavior, but do not make an 86 byte file a blind startup barrier when defaults are adequate.
Evidence:
- Initial build preloads
assets/vendor/date-fns-*.jsat319.4 KiBraw /72.8 KiBgzip. packages/frontend/src/composables/use-datefns.ts:1imports* as datefnslocalesfromvirtual:locales/date-fns.packages/i18n/src/vite.ts:47-58and101-104generate exports for all matching date-fns locales.packages/frontend/src/utils/time.ts:5-13imports date-fns functions anduseDateFns, and common components useutils/time.ts.
Impact:
- Locale support for every bundled language becomes part of the startup path.
- Route splitting will help settings-only date formatting, but common media/time helpers can still keep this chunk initial.
Recommendation:
- Change
virtual:locales/date-fnsto export async locale loaders, similar to i18next resources. - Keep
ensynchronous if needed, then lazy-load the current locale after i18n initializes. - For simple visible time strings, prefer built-in
Intl.DateTimeFormat/Intl.RelativeTimeFormatwhere behavior is good enough. - Audit
utils/time.tsso basicformatTime()/ tick formatting stays date-fns-free.
Risk:
useDateFns()is currently synchronous. Making locales async will require either a current-locale store or graceful fallback to English while the locale module loads.
Evidence:
packages/ui-toolkit/src/components/JImg.vue:3-9creates<link rel="preload" as="image">whenever an image has asrcand is not shown.packages/ui-toolkit/src/components/JImg.vue:13-19renders the final<img>only after preload and usesdecoding="sync".packages/frontend/src/components/Layout/Images/Blurhash/BlurhashImage.vue:47-53computes an image URL with no loading policy.packages/frontend/src/components/Item/Card/ItemCard.vue:10-12does not pass width/height toBlurhashImage.packages/frontend/src/utils/images.ts:241-268defaults toquality = 90, with optionalwidthbut no global size cap.
Impact:
- Every mounted card image becomes a preload candidate.
- Off-screen images compete with critical JS/CSS and the hero image.
- Synchronous image decoding can add main-thread jank.
- Cards without width constraints can request larger server images than necessary.
Recommendation:
- Replace default preload behavior with regular
<img>loading:- default
loading="lazy" - default
decoding="async" - default
fetchpriority="auto"or omitted
- default
- Add explicit props for priority images:
- hero/backdrop:
loading="eager",fetchpriority="high", possibly one preload - above-fold cards:
loading="eager"only if measured useful - all other cards: lazy
- hero/backdrop:
- Pass width/height/sizes from card shape and breakpoint into
BlurhashImage. - Reduce default image quality from 90 after visual comparison, or make it responsive by image role.
- Intersection-gate Blurhash canvas drawing for large lists if worker traffic becomes visible in profiles.
Evidence:
packages/frontend/src/store/dbs/api/index.ts:21-24definesapiEnums.fieldsas everyItemFieldsvalue andapiEnums.imagesas everyImageType.packages/frontend/src/composables/apis.ts:150-159adds those full fields/image types to base item requests.- Earlier notes already identified this as P5, but no payload measurement exists yet.
Impact:
- Every item row/card/list may download fields needed only by detail pages, playback, metadata editing, or admin screens.
- Larger JSON increases transfer, parse time, IndexedDB write cost, and Vue reactive object size.
Recommendation:
- Instrument payload size before changing behavior.
- Add optional
fieldsandimageTypesoverrides touseBaseItem/fetchAndAdd. - Keep the current broad field set as the compatibility default initially.
- Tune high-volume requests first: home rows, library grids, search results, related-item carousels.
- Define field presets by view contract, for example
cardFields,detailFields,playbackFields,metadataEditorFields.
Risk:
- Missing fields can create subtle UI regressions. This should land behind measurement and route-by-route validation.
Evidence:
- Build CSS total is
538.1 KiBraw /73.7 KiBgzip. - Initial CSS includes Vuetify CSS (
393.5 KiBraw /48.6 KiBgzip), main app CSS (139.7 KiBraw /23.8 KiBgzip), and Swiper CSS (4.7 KiBraw). packages/frontend/src/plugins/vuetify.ts:6-7importsvuetify/styles.- Synchronous route imports pull scoped CSS for many pages into the initial CSS.
Recommendation:
- Fix async route splitting first; this should move route-scoped CSS into route chunks where Vite can split it.
- Audit
main-*.cssafter route splitting for large inline Iconify masks and route-specific utility classes. - Avoid importing Swiper CSS globally through sync route components.
- Treat Vuetify CSS reduction as a later, harder task; it may require Vuetify-specific style strategy changes.
Evidence:
packages/frontend/src/plugins/remote/socket.ts:42-49subscribes toScheduledTasksInfoStart,ActivityLogEntryStart, andSessionsStarton every WebSocket connection._updateIntervalis'0,1'atpackages/frontend/src/plugins/remote/socket.ts:38.
Recommendation:
- Verify actual server semantics and message volume.
- Subscribe only when consumers are mounted, or centralize reference-counted subscriptions.
- Keep session/activity/task streams off by default for non-admin users and non-admin routes if possible.
Evidence:
packages/frontend/src/composables/apis.ts:484-493checks cached data and still runs a refresh when cached data exists.
Recommendation:
- Instrument request count and route resolve timing first.
- Add an optional TTL/refresh policy for high-volume routes after measurement.
- Keep default behavior fresh until a measured policy proves better.
| Optimization | Behavior change? | Ship as |
|---|---|---|
| Async route chunks | No intended behavior change | Always-on after metadata/guard verification |
| Lazy playback/player imports | No intended behavior change | Always-on after playback regression testing |
| Stop waiting on fonts before mount | Minor visual timing change | Always-on if acceptable |
| Config preload/cache tuning | No intended behavior change | Always-on |
De-duplicate fetchIndexPage |
No | Already done |
| Scroll-after-suspense | No, fixes a bug | Always-on |
| Profiled rerender/dependency fixes | No | Always-on after profiling |
| Image lazy-load hardening | No if priority images are explicit | Always-on after visual validation |
| Progressive home sections | Yes, render order/loading states | Experimental, then default on if proven |
Route <KeepAlive> cache |
Yes, staleness/memory | Experimental |
| Session data cache | Yes, staleness | Experimental |
| Reduced metadata fields | Yes, missing-field risk | Experimental, fork-only first |
| Refresh-policy / TTL changes | Yes, staleness | Experimental |
| Prefetch on hover/focus | Yes, extra speculative requests | Experimental |
| Compact item models | Yes, feature-loss risk | Experimental, fork-only first |
Performance-related toggles from FORK_ROADMAP.md section 6:
- Route component cache (
<KeepAlive>). - Home page section cache.
- Progressive home section rendering.
- Reduced metadata fetching.
- Refresh policy / cache TTL.
- Prefetch on hover/focus.
- Compact item models.
- Debug playback stats overlay / API request inspector / render timing overlay.
- Transcode/debug visibility.
Rule: pure no-risk fixes must not get a toggle. Toggles are for behavior-changing work only.
- Change router import mode from sync to async.
- Rebuild and record:
- number of initial modulepreloads
- initial raw/gzip/brotli size
- whether playback/admin/swiper/sortable chunks are absent from login/select HTML
- Make
PlayerElementasync inApp.vue. - Dynamically import
hls.js,assjs,libpgs,audiomotion-analyzer, andsortablejsat their use sites. - Remove font readiness from the blocking mount path.
Acceptance targets:
- Initial login/select page does not preload HLS, Swiper, Sortable, ASS, PGS, or admin settings code.
- Initial JS+CSS gzip for unauthenticated server pages is below
350 KiB. - App shell mounts without waiting for document fonts.
- Split
useLibraryViews()from fulluseHomeSections(). - Default layout waits only for views, or renders a stable shell while views load.
- Home page fetches each section independently and renders rows progressively.
- Add route timing instrumentation around router navigation and
MainView.vueSuspense resolution.
Acceptance targets:
- Non-home default-layout routes do not trigger all home section requests.
- Home first visible shell appears before all library/latest/favorites requests finish.
- Route timing logs show pending -> resolve duration per route.
- Replace global image preload with explicit image priority props.
- Add width/height/sizes to card and carousel image calls.
- Instrument image request counts and decoded sizes on home/library.
- Instrument API response size and duration in
fetchAndAdd(). - Add optional field/image presets and tune high-volume card/list endpoints.
Acceptance targets:
- Off-screen card images are lazy by default.
- Home/library image requests are close to visible images plus a small buffer.
- API payload size reductions are measured before becoming default.
- Extend
JBundleSizeReportto print gzip and brotli sizes. Current report only prints raw size. - Add a small script that parses
dist/index.htmland reports initial resources and top offenders. - Set soft budgets:
- initial gzip JS+CSS
- largest initial JS chunk
- total CSS gzip
- no playback-only vendors in unauthenticated initial graph
- Keep
pnpm -C packages/frontend analyze:bundlefor source-level deep dives, but make the fast size budget script run in normal local checks.
Before changing API behavior, measure:
- Route load time: mark
performance.now()at router navigation start and at Suspense@resolveinMainView.vue. - API volume: count calls in
apis.tsfetchAndAdd; record method name, args hash, duration, cache hit/miss, and response byte count. - Duplicate requests: group counted requests by
funcName + stringifiedArgswithin a short window. - Payload size: log
JSON.stringify(response.data).lengthorContent-Lengthper endpoint; compare full vs. trimmedItemFields. - Home composition: time
fetchIndexPagestart to resolve and each inneruseBaseItem. - Player init: time from
play()/ route enter to medialoadeddataand firsttimeupdate. - Render: Vue DevTools component render flamegraph for
library/[itemId],index, andvideo; browser Performance panel for long tasks during scroll. - Memory: heap snapshot after scrolling a large library; count retained
BaseItemDtoobjects. - Images: image request count and dimensions by route.
Read-only developer overlay candidates:
- Route load time, last and rolling average.
- API request count, session and current route.
- Duplicate request count.
- Image request count.
- Cache hit/miss count and ratio.
- Slowest endpoint.
- Playback info request timing.
- Player initialization time.
- Render timing for large views.
Keep instrumentation read-only and off in production by default.
Routing / rendering:
MainView.vueSuspense pending to resolve timing per route.- Re-mount cost of
pages/index.vue,pages/library/[itemId].vue,pages/playback/video.vue. - Transition (
JTransition) overlap with data loading.
API:
apis.tsrequest count, cache hit ratio, and revalidation frequency.fetchIndexPagefan-out and section timing.- Payload size with full
ItemFields. apidb.worker.tsround-trip latency for cache reads.
Images:
utils/images.tsrequest count, sizes, blurhash decode time, and lazy-load coverage.JImg.vuepreload behavior after replacing global preload.
Playback:
getPostedPlaybackInfotiming and how often it re-fires.- Player route enter to first frame.
- Source-swap timing.
- HLS.js level switching and buffer health.
Library:
- Item count held in memory for a large library.
JVirtualwindowing efficiency.- Scroll FPS and long tasks.
After each optimization:
pnpm -C packages/frontend build
pnpm -C packages/frontend checkThen compare:
dist/index.htmlmodulepreload count.- initial raw/gzip/brotli size from the generated HTML references.
- top initial chunks.
- route timing logs on:
/server/select/server/login//library/:itemId/item/:itemId/playback/video
- network request count on cold home and warm back navigation.
- image request count on home/library at desktop and mobile widths.
Current measured finding, 2026-05-23:
pnpm -C packages/frontend buildcompleted successfully.- Generated
dist/index.htmlhad 1 module script, 32 modulepreload links, and 4 stylesheets. - Initial resources referenced by generated HTML totaled
3113.5 KiBraw,801.0 KiBgzip,651.8 KiBbrotli. - Full build totaled
4045.9 KiBraw,1164.2 KiBgzip,974.8 KiBbrotli. - Largest initial offenders were
hls.js,main, Vuetify JS/CSS,date-fns, Jellyfin SDK, Swiper,libpgs,marked,sortablejs,assjs, andaudiomotion-analyzer.
Bottom line: the biggest performance win is to stop treating the SPA as one synchronous application. Async route boundaries plus lazy playback imports should remove the largest incorrect first-load costs. After that, the next real user-facing wins are progressive home rendering and image priority fixes. API payload trimming is promising but should be measured and phased carefully because the app currently relies on broad item metadata for correctness.
Enforced by pnpm -C packages/frontend check:budget
(packages/frontend/scripts/initial-budget.ts). Tighten as wins land; the
script reads dist/index.html and walks every modulepreload + stylesheet.
| Budget | Threshold | Why |
|---|---|---|
| Total initial gzip JS+CSS | ≤ 550 KiB | Headroom for date-fns locales + Vuetify until P1-C lands; tighten to 350 KiB once locales go async. |
| Largest initial chunk (gzip) | ≤ 150 KiB | Forces big vendors (Vuetify, date-fns) to stay split; flags accidental merges. |
| Playback-only vendors in initial graph | 0 | hls.js, libpgs, assjs, audiomotion-analyzer, sortablejs, swiper must never preload before the user touches playback. |
Phase 1–3 measured outcome (post-implementation):
- Initial modulepreloads: 32 → 53 (more chunks, but each tiny — see below).
- Initial raw: 3113.5 KiB → ~2050 KiB.
- Initial gzip: 801.0 KiB → ~503 KiB.
- Initial brotli: 651.8 KiB → ~408 KiB.
main-*.js: 497.94 KiB → ~36 KiB (route-scoped code moved out of entry).- HLS / libpgs / assjs / audiomotion / sortablejs / swiper: removed from initial graph; load on demand.
Remaining initial weight is dominated by Vuetify runtime + date-fns (locale data), which are out of scope for this round; tracked as P1-C and the Vuetify CSS reduction in the audit above.