How We Optimized a Production Vue PWA Without Changing a Single Pixel
A collaborative work-management Vue 3 PWA — tasks, folders, chat, offline storage — got a performance-only release: same UI, faster loads, cleaner bundles. No redesign, no new features.
Where the weight was
Two chunks dominated the production build:
| Chunk | Gzip (before) | Problem |
|---|---|---|
Entry index |
862 KB | Layout, Firebase, i18n, ui-kit — all eager |
MainFolderView |
466 KB | Table, Kanban, Gantt, Calendar, Chat in one file |
Route-level lazy loading existed, but three patterns broke it:
PanelLayoutstatic-imported → full app shell in entryMainFolderViewsync-imported in folder routes- All view modes compiled together despite
v-ifshowing one at a time
PWA install precache mirrored this: ~6.3 MB including routes users might never open.
What we changed
Code splitting
- Lazy
PanelLayout— dynamic import in router parent - Lazy
MainFolderView—defineAsyncComponentin folder views - Split view modes — async Table, Kanban, Card, Calendar, Dashboard, Gantt, Chat, Activity
- Deferred Firebase — dynamic import in
onMounted; skip on HTTP when push is disabled
Vendor chunks (manualChunks)
Split Vue, Firebase, Dexie, ApexCharts, emoji-mart, i18n, ui-kit, and icons into separate cacheable chunks.
PWA precache
| Before | After | |
|---|---|---|
| Install precache | ~6.3 MB | 3.3 MB (−48%) |
| Lazy route chunks | Precached on install | Excluded; runtime cache on first use |
maximumFileSizeToCacheInBytes |
50 MB | 2 MB |
Offline shell unchanged — entry, vendors, layout, CSS still precached.
IndexedDB (Dexie)
No UI changes; faster inbox on large accounts:
- Added
isInChatListandchatIdindexes (schema v2, auto-migrates) - Fixed query builder:
INfilter andcount()no longer scan full tables - Reduced socket watcher churn on chat/notification stores
Shared libraries
- Analytics — ~6 KB main thread plugin; Dexie/flush logic in a Web Worker; single Dexie instance via build externals
- UI kit — icons externalized in lib build; stylesheet MD5 verified unchanged vs baseline
Dependencies
Removed unused recordrtc and video.js; patched firebase, vite, dexie, axios. npm audit: 27 → 0.
Admin console
Same manualChunks pattern — entry gzip 41 KB, Vuetify 98 KB, Vue 44 KB. No theme changes.
Results
| Metric | Before | After | Change |
|---|---|---|---|
Entry index gzip |
862 KB | 177 KB | −79% |
MainFolderView gzip |
466 KB | 71 KB | −85% |
| PWA install precache | ~6.3 MB | 3.3 MB | −48% |
| npm audit | 27 | 0 | Fixed |
| UI kit CSS / icons MD5 | baseline | identical | Parity ✓ |
User journey: login downloads ~79% less JS; opening a folder loads a thin shell first; Kanban/Gantt fetch on demand; PWA install is half the size.
Trade-off: dist/ total slightly larger (more chunk files) — intentional. Critical path bytes down; vendors cached separately.
Lessons
- Route lazy loading is not enough — sync-imported layout or parent SFC still creates a monolith entry.
- One SFC per “mode” is an anti-pattern — split with
defineAsyncComponenteven when only one view is visible. - PWA precache is a budget — exclude lazy routes; cache them at runtime.
- IndexedDB needs indexes — missing indexes become silent full scans.
- Parity gates enable aggression — MD5 checks on CSS/icons let you refactor chunks without visual drift.
What we skipped
Tailwind 4, Vite 8, Vue Router 5, locale lazy-split, backend query tuning, mobile bundle work — all higher risk or separate tracks.
Summary
A mature Vue PWA can cut ~79% of entry JavaScript (gzip) and ~85% of its heaviest workspace chunk without touching the design. Same product, stronger load performance.
June 2026