4.1 KiB
4.1 KiB
Frontend Performance Optimization — Design Spec
Date: 2026-05-24 Author: opencode Status: Draft
Problem
Lighthouse performance score: 0.51
| Metric | Value | Score |
|---|---|---|
| FCP | 5.5s | 0 |
| LCP | 10.8s | 0 |
| TBT | 10ms | 1 |
| CLS | 0.122 | 0.84 |
Root causes:
- 9.9 MB total transfer size (dev mode, 368 requests)
- maplibre-gl (1.3 MB) loaded on every page, used only in About + AddressMapPicker
- 18 @dicebear packages (~2 MB) statically imported, never code-split
- TipTap (116 KB) always loaded, used only in Messages/Reviews
- Only 2 of 14+ routes lazy-loaded
- Zero React.memo usage — uncontrolled re-renders
Architecture
1. Code-split maplibre-gl
Files affected:
client/src/main.tsx— remove globalimport 'maplibre-gl/dist/maplibre-gl.css'client/src/features/address-map-picker/ui/AddressMapPicker.tsx— dynamic importclient/src/pages/about/ui/AboutPage.tsx— dynamic import
Implementation:
- Remove CSS import from
main.tsx - In components that use map, load CSS + JS via
import()insideuseEffectorReact.lazy - Show loading skeleton while map loads
2. Code-split TipTap
Files affected:
client/src/shared/ui/RichTextMessageContent.tsx— lazy wrapperclient/src/shared/ui/RichTextMessageEditor.tsx— lazy wrapperclient/src/features/order-chat/— add Suspense boundaryclient/src/widgets/reviews-block/— add Suspense boundary
Implementation:
- Create lazy exports in
shared/ui/index.ts - Wrap TipTap-dependent components in
<Suspense fallback={<Skeleton>}>
3. Code-split dicebear avatar styles
Files affected:
client/src/shared/lib/avatar-styles.ts— dynamic import map
Implementation:
- Replace static imports with factory map:
{ styleName: () => import('@dicebear/style') } - Cache loaded styles in
Map<string, Style> - Load style on first use, not at module initialization
4. Lazy-load all public routes
Files affected:
client/src/app/routes/index.tsx— convert all routes to lazy + Suspense
Routes to lazy-load:
/(HomePage)/auth(AuthPage)/auth/callback(AuthCallbackPage)/cart(CartPage)/checkout(CheckoutPage)/about(AboutPage)/info/*(InfoPage)/privacy(PrivacyPolicyPage)/terms(TermsPage)/products/:id(ProductPage)
Exception: 404 route stays synchronous (lightweight).
Fallback: SkeletonPage for consistency with existing admin/me lazy routes.
5. React.memo on key components
Files affected:
client/src/entities/product/ui/ProductCard.tsxclient/src/shared/ui/OptimizedImage.tsxclient/src/shared/ui/UserAvatar.tsxclient/src/app/layout/AppHeader.tsx
Implementation:
ProductCard: memo with custom comparator comparingproduct.idandonClickreferenceOptimizedImage: memo comparingsrc,alt,priorityUserAvatar: memo comparinguserId,styleAppHeader: memo (default shallow comparison sufficient)
Data Flow
User navigates → Router loads lazy route chunk → Component mounts
↓
Dynamic imports (maplibre/TipTap) fire
↓
Suspense shows fallback → Content renders
Error Handling
- All
React.lazycomponents wrapped in<Suspense>with fallback - Dynamic imports wrapped in try/catch with error boundary fallback
- Dicebear style loading: fallback to default style if dynamic import fails
Testing
- Verify routes still work after lazy conversion (manual navigation)
- Verify map loads correctly in AboutPage and AddressMapPicker
- Verify TipTap renders in Messages and Reviews
- Verify avatars render with correct styles
- Run
npm run lintandnpm testafter changes - Run
npm run buildto verify production bundle
Success Criteria
- Initial bundle size reduced by ~3-4 MB (dev mode)
- FCP improved from 5.5s to < 2.5s
- LCP improved from 10.8s to < 4.0s
- No regressions in functionality
- All tests pass
- Lint passes