⚡ Frontend Performance
Where we stand — measured performance baseline for the Finaisse tenant UI (blitz-ui), plus the tracked work to fix it.
| Owner | Sekhar Prakash — Security & Infrastructure |
| First measured | 2026-08-19 |
| Scope | blitz-ui tenant UI (host ui + federated remotes) |
| Method | Lighthouse 13.4.1, local production build, native arm64 Chrome |
| Tracking issue | blitz-ui#837 |
One dominant problem, app-wide
The host ui app ships a single 8.36 MB entry chunk (2.89 MB gzipped) that blocks first paint on every route. Lighthouse scores 51–55 across login and all authenticated views, with ~3.2–3.5 MB of unused JavaScript downloaded per page load. Root cause is eager global registration of 18 Syncfusion plugins, dominated by PdfViewerPlugin.
The good news
This is a packaging problem, not an application problem. FCP ≈ LCP (within 0.6 s) means once the JS arrives the page renders almost instantly, and cold Total Blocking Time is healthy (40–230 ms). The app is well-built and badly bundled — which is the far more tractable of the two.
Baseline — cold loads
Fresh browser per target, auth cookies + localStorage seeded before navigation, HTTP cache cleared via CDP.
| View | Score | FCP | LCP | TBT | CLS | Payload | Unused JS |
|---|---|---|---|---|---|---|---|
/login | 55 | 24.6 s | 30.5 s | 80 ms | 0 | 5,185 KiB | 3,255 KiB |
/closehub/preparer/dashboard | 51 | 23.1 s | 23.7 s | 230 ms | 0 | 5,924 KiB | 3,510 KiB |
/recon | 55 | 22.5 s | 28.0 s | 40 ms | 0 | 4,868 KiB | 3,191 KiB |
/invoicehub | 55 | 22.5 s | 28.0 s | 40 ms | 0 | 4,868 KiB | 3,191 KiB |
/recon and /invoicehub return identical metrics — verified as distinct finalDisplayedUrls, not a copy/paste artifact. Both are thin shells whose cost is almost entirely the shared entry chunk.
Reading these numbers
The seconds above are throttled — do not quote them as user-facing load times
Lighthouse applies simulated mobile throttling by default (slow 4G + throttled CPU ≈ a mid-range phone). Treat those seconds as a stress-test lens for comparison, not a customer-experience figure.
The same page measured with throttling disabled on a real M-series CPU scores 87, with LCP 1.7 s — see What a real user experiences below. The throttled figure is dominated by Lighthouse's simulated network, not simulated CPU: measured JS parse/execute is only 0.2 s.
What is device-independent and unambiguously real in both runs: the byte counts.
What a real user experiences
Same build, same page, --preset=desktop --throttling-method=provided (real local CPU, no simulated network):
| Metric | Throttled (mobile sim) | Real desktop, unthrottled |
|---|---|---|
| Score | 55 | 87 |
| FCP | 24.6 s | 0.3 s |
| LCP | 30.5 s | 1.7 s |
| TTI | 30.5 s | 1.1 s |
| TBT | 80 ms | 160 ms |
| JS bootup (parse/exec) | — | 0.2 s |
| Payload | 5,185 KiB | 5,192 KiB (unchanged) |
Both runs were on localhost — neither includes real transfer time
This is the variable that actually decides user experience, and no measurement on this page captures it. Users download ~3.5 MB gzipped over a real connection.
Modelling transfer on top of the measured 1.7 s render (arithmetic estimates, not measurements; cold cache assumed):
| Connection | Download | Estimated LCP |
|---|---|---|
| Office fibre 100 Mbps | 0.3 s | ~2.0 s |
| Typical broadband 25 Mbps | 1.1 s | ~2.9 s |
| Good 4G 10 Mbps | 2.8 s | ~4.6 s |
| Slow 4G 3 Mbps | 9.5 s | ~11.3 s |
| Weak 3G 1 Mbps | 28.4 s | ~30.4 s |
Severity is network-dependent, not universal:
- Office desktop on good broadband — ~2–3 s. Not great, not alarming. Probably most Finaisse users.
- Home / mobile 4G — ~5 s. Noticeably sluggish, past Google's 4 s "poor" LCP threshold.
- Weak mobile — 10–30 s. Genuinely broken; this is where the throttled figure stops being hypothetical.
- Repeat visits are fast regardless, since the chunk caches — but every deploy changes the content hash, so the cold cost recurs every release, not once per user.
For a finance product used mainly at desks this skews severity down from what the throttled score suggests. It does not make 3.5 MB before first paint defensible, and the fix is cheap relative to the risk.
- Score — weighted composite. 90+ good, 50–89 needs improvement, <50 poor.
- FCP (First Contentful Paint) — until anything renders. Blank white screen before this.
- LCP (Largest Contentful Paint) — until the main content block renders. A Core Web Vital; good <2.5 s, poor >4 s.
- TBT (Total Blocking Time) — main-thread blocking by long JS tasks. The responsiveness metric; good <200 ms.
- Unused JS — downloaded and parsed but never executed. Pure waste.
Warm-cache runs — recorded to prevent a misreading
The same routes measured with the cache warm (the state immediately after logging in):
| View | Score | FCP | LCP | TBT | Payload |
|---|---|---|---|---|---|
/closehub/preparer/dashboard | 76 | 1.7 s | 1.7 s | 840 ms | 120 KiB |
/recon | 83 | 1.7 s | 2.8 s | 450 ms | 90 KiB |
/invoicehub | 91 | 1.7 s | 2.8 s | 230 ms | 14 KiB |
These are not evidence the app is fast
The 14–120 KiB payloads show the 8.36 MB entry chunk was already downloaded and parsed during login; these measure only incremental route chunks on an SPA navigation. A first-time visitor pays the cold cost above first. Recorded here only so anyone reproducing a warm run isn't misled into closing blitz-ui#837.
Root cause
packages/shared/src/plugins/syncfusion/index.ts statically imports and app.use()-registers 18 Syncfusion plugins. It is picked up automatically by registerPlugins in packages/shared/src/@core/utils/plugins.ts, which globs plugin modules with { eager: true }:
const imports = import.meta.glob<{ default: (app: App) => void }>(
['../../plugins/*.{ts,js}', '../../plugins/*/index.{ts,js}'],
{ eager: true }, // ← every plugin is statically bundled into the entry chunk
)Eager static imports cannot be tree-shaken or code-split, so all 18 plugins land in the entry chunk regardless of whether a route uses them.
Marker frequency inside index-42wuwHUI.js:
| Component | Evidence |
|---|---|
| PdfViewer | pdfViewer ×6,605, pdfViewerBase ×1,270, ej2-pdf-lib, unloadFPDF — dominant |
| Charts | chart ×580, stockChart ×91, accumulationchart ×42, isBulletChartControl ×43 |
| FileManager, QueryBuilder, CircularGauge, Timeline | registered in the same file, all eager |
Why PdfViewer is the biggest single win
Globally registered, but consumed in only three places:
apps/ui/src/pages/cashapp/analyst/remittances/[id]/index.vueapps/invoicehub/src/pages/invoicehub/processor/invoice/components/InvoicePdfReview.vuepackages/shared/src/components/molecules/FinMatchDocumentPane.vue
All three are deep, specific views. Every user — including one who only ever sees the login screen — pays for it. Note the heavy render lib is already CDN-loaded at runtime via resourceUrl (cdn.syncfusion.com/.../ej2-pdfviewer-lib), so the bundled weight is wrapper code that is pure overhead for most sessions.
Chart suite pulls unused components
stockChart, isBulletChartControl, and accumulationchart markers are all present, but the components actually in use are Bubble / Area / Doughnut / StackedBar / Bar. ChartPlugin pulls the whole family.
Why this grew unnoticed
apps/ui/vite.config.ts sets chunkSizeWarningLimit: 5000 — 10× Vite's 500 kB default — so no warning fired until the chunk passed 8 MB.
The fix — in payoff order
See blitz-ui#837 for the tracked work.
- Remove
PdfViewerPluginfrom global registration. Register locally or viadefineAsyncComponentin the three consuming views. Expected to cut multiple MB from the entry chunk on its own — the single biggest win. - Replace
ChartPlugin/AccumulationChartPluginwith per-component imports for the four chart types actually used. - Same treatment for
FileManagerPluginandQueryBuilderPlugin(likely single-view each). - Add
build.rollupOptions.output.manualChunksto split vendor code; lowerchunkSizeWarningLimittoward 1000 so regressions surface early. - Investigate the 634 KB CSS bundle —
apps/ui/src/main.tseagerly imports several full Syncfusion bootstrap5 theme sheets.
Rough expectation if 1–3 land: dropping ~3 MB of unused JS should move scores from ~53 into the 80s and cut LCP by most of its current value, since download is the overwhelming majority of it.
Effort caveat — this is a refactor, not a config flip
Global plugin registration means <ejs-pdfviewer> / <ejs-chart> tags resolve implicitly anywhere in the app. Making them local requires touching each consuming component and verifying no tag silently fails to resolve — Syncfusion registration failures can be quiet (no console error, component just doesn't render). Budget verification time per view.
Second, separate problem: main-thread blocking on mount
TBT is the one metric caching does not flatter:
/closehub/preparer/dashboard— 840 ms TBT (warm), vs 80 ms on login/recon— 450 ms TBT (warm)- Cold TBT is lower (230 / 40 ms) because the long download pushes work outside the measurement window
840 ms of blocking means visible input lag after paint. This is component mount cost, not download cost — Syncfusion grids/charts doing synchronous work on initialization.
Fix items 1–4 will not address this. Lazy-loading changes when code arrives, not how long a grid takes to mount once it does. Likely needs virtualized/paged grid rendering, deferred non-critical widget mount, or trimming per-grid feature modules. Not yet filed as its own issue.
Not yet measured
- Staging / production — all numbers here are local. Staging is capped at 0.5 vCPU per service and scale-downs nightly, so it would measure the caps rather than the app.
- Backend API load (k6 / RPS, Temporal workflow throughput) — see Load Testing.
- Real-network transfer time — every run on this page was served from
localhost, so no measurement here includes actual download time. The per-connection table above is modelled arithmetic, not measured. A run against deployed staging (or Chrome DevTools network throttling) would replace it with real figures. - Real mobile hardware — the unthrottled run used an M-series Mac. A genuine mid-range phone sits between the two columns above.
How to reproduce
See Measuring for the full harness, including the authenticated-run scripts and two non-obvious gotchas.