Performance Optimization
Strategies for optimal page load times and user experience
Overview
WAVIC implements multiple layers of caching, preloading, and optimization to ensure fast load times across the platform, crucial for a music collaboration tool where users frequently browse projects and stream audio.
Stack Optimization (February 2026)
The following upgrades directly improve runtime and build performance:
| Upgrade | Performance Impact |
|---|---|
| React 19.2 | Automatic batching, concurrent rendering, faster reconciliation |
| Next.js 16.1 | Turbopack default bundler, FS caching, View Transitions API |
| Tailwind CSS 4 | Lightning CSS engine (~10x faster builds), smaller CSS output |
| Node.js 22 | V8 engine improvements, faster startup, better memory management |
| ESLint 9 | Flat config — faster lint times |
Caching Architecture
┌──────────────────────────────────────────────────────────────────────┐
│ CACHING LAYERS │
├──────────────────────────────────────────────────────────────────────┤
│ │
│ Browser React Query Next.js Backend CDN │
│ Cache (Data Cache) (Image/Route) Headers Edge │
│ ┌─────────┐ ┌─────────────┐ ┌──────────┐ ┌────────┐ ┌─────┐ │
│ │ images │ │ API │ │ ISR/SSG │ │ 1-year │ │Azure│ │
│ │ static │◄──│ responses │◄──│ image │◄──│ cache │◄│Front│ │
│ │ assets │ │ 5min stale │ │ optimize │ │control │ │Door │ │
│ └─────────┘ └─────────────┘ └──────────┘ └────────┘ └─────┘ │
│ │
└──────────────────────────────────────────────────────────────────────┘React Query Integration
Provider Setup
Located at src/lib/react-query-provider.tsx:
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 30 * 60 * 1000, // 30 minutes
refetchOnWindowFocus: true, // Refresh on tab focus
refetchOnReconnect: true, // Refresh on network recovery
},
},
});Active Usage
React Query hooks are used for sidebar and navigation data, eliminating redundant API calls and skeleton flash on repeat visits:
| Hook | Query Key | Location | Data |
|---|---|---|---|
useRecentTracks() | ['recent-tracks'] | src/hooks/use-recent-tracks.ts | Recent track spaces (sidebar + wave page) |
useSidebarArtists() | ['sidebar-artists'] | src/hooks/use-sidebar-artists.ts | Own + collaborator artists (sidebar) |
How caching eliminates loading flicker:
// isLoading = true ONLY when there's no cached data (first visit ever).
// On subsequent renders, cached data is shown instantly while
// React Query silently revalidates in the background.
const { data: recentTracks = [], isLoading } = useRecentTracks();
// Show skeleton only on first load — never on navigation clicks
if (isLoading) return <MenuSkeleton />;Cache invalidation after mutations:
// After creating an artist, invalidate the sidebar cache.
// React Query refetches in the background — no skeleton shown.
const invalidateArtists = useInvalidateSidebarArtists();
invalidateArtists(); // triggers background refetch for all consumersShared cache across components:
All consumers of the same queryKey share a single cache entry. The sidebar (desktop), drawer (mobile), and wave dashboard all read from ['recent-tracks'] — the first component to mount fetches the data, and all others get it instantly from cache.
Remaining Migration
Some pages still use raw fetch() with useState (artist dashboard, project dashboard). These should be migrated to useQuery hooks following the same pattern as the sidebar hooks.
Future: Optimistic Updates
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: createArtist,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['artists'] });
},
});Image Optimization
Next.js Image Component
import Image from 'next/image';
import { isAzureUrl } from '@/utils/url-helpers';
// Use Next.js Image — skip optimization for Azure CDN images
<Image
src={artist.image}
alt={artist.name}
width={150}
height={150}
unoptimized={isAzureUrl(artist.image)}
/>Azure URL bypass: Images served through Azure Blob Storage or Front Door CDN are already optimized. Setting
unoptimized={true}for these URLs avoids SAS token double-encoding and ensures preloaded URLs match rendered URLs. TheisAzureUrl()helper insrc/utils/url-helpers.tsdetects bothblob.core.windows.netand.azurefd.nethostnames.
Image Variants
The backend generates multiple sizes:
| Variant | Size | Format | Use Case |
|---|---|---|---|
| Thumbnail | 150×150 | WebP | Grid views |
| Medium | 600px | WebP | Detail pages |
| Original | Full | WebP | Downloads |
Next.js Image Configuration
// next.config.mjs
images: {
minimumCacheTTL: 2592000, // 30 days
qualities: [100, 75],
formats: ['image/webp'],
deviceSizes: [640, 828, 1200, 1920],
imageSizes: [150, 300, 600],
remotePatterns: [
{ protocol: 'https', hostname: '*.blob.core.windows.net' },
{ protocol: 'https', hostname: '*.azurefd.net' },
],
}CDN & Edge Caching
Azure Front Door (Deployed ✅)
All images and audio files are served through Azure Front Door for global edge caching.
| Resource | Value |
|---|---|
| Profile | sonnance-cdn (Standard_AzureFrontDoor) |
| Endpoint | sonnance-images-hvcsepc8dkhrbjfp.z01.azurefd.net |
| Origin | sonnancewebappstorage00.blob.core.windows.net |
| Caching | SAS params (sv, sp, se, sr, sig, spr) as cache key |
| Compression | image/webp, jpeg, png, gif, svg |
| Metric | Without CDN | With CDN |
|---|---|---|
| Latency (EU) | 50-100ms | 10-20ms |
| Latency (US) | 150-200ms | 20-30ms |
| Cache hit ratio | 0% | 85-95% |
How CDN URL Rewriting Works
The backend blob-storage.service.ts handles CDN URL rewriting transparently:
// Backend: getCdnUrl() replaces blob hostname with CDN hostname
// Applied to: uploadFile(), uploadImageWithVariants(), signUrl()
// When CDN_URL env var is set:
// https://sonnancewebappstorage00.blob.core.windows.net/uploads/file.webp?sas=...
// Becomes:
// https://sonnance-images-hvcsepc8dkhrbjfp.z01.azurefd.net/uploads/file.webp?sas=...The frontend receives CDN URLs directly from the API — no client-side URL rewriting needed.
Cache Headers (Backend)
All uploaded files (images AND audio) include:
Cache-Control: public, max-age=31536000, immutableThis enables:
- Browser caching — 1 year cache
- CDN edge caching — Front Door caches at edge PoP
- Immutable — No revalidation needed (content-addressed URLs with SAS tokens)
Preloading & Async Loading
Dynamic Imports (Code Splitting)
Heavy components are dynamically imported to reduce initial bundle size:
import dynamic from 'next/dynamic';
// Modals loaded on-demand
const ProjectModal = dynamic(() => import('@/components/modal/project-modal'), {
ssr: false,
});
// Drawers loaded on-demand
const Drawer = dynamic(() => import('rizzui').then((mod) => mod.Drawer), {
ssr: false,
});Currently dynamically imported:
- Modal dialogs (ArtistModal, ProjectModal, ShareModal, SettingsDrawer)
- Table filters (Drawer, TableFilter, TablePagination)
- Support widgets (NeedSupport)
- SVG icons (React.lazy)
Image Preloading (Skeleton → Content Transition)
Images are preloaded into the browser cache before hiding the skeleton, preventing the "content loads but images pop in later" flash:
import { preloadImages } from '@/utils/url-helpers';
// In dashboard/artist data fetching:
const imageUrls = artists.map((a) => a.image).filter(Boolean);
await preloadImages(imageUrls); // Warms browser cache
setIsLoading(false); // Skeleton hides only after images are cachedKey details:
preloadImages()usesnew Image()to download images into the browser cache- 5-second timeout prevents broken images from blocking the UI forever
- Session-stable timestamps (
appendTimestamp()) ensure preloaded URLs match rendered URLs - Content containers use
animate-fade-inCSS class (200ms ease-out) to mask paint delay
Font Preloading
Fonts are preloaded via Next.js next/font:
// src/app/fonts.ts
import { Inter, Lexend_Deca } from 'next/font/google';
export const inter = Inter({ subsets: ['latin'], variable: '--font-inter' });
export const lexendDeca = Lexend_Deca({
subsets: ['latin'],
variable: '--font-lexend',
});Route Prefetching
Next.js 16 automatically prefetches linked routes on viewport intersection. Use <Link> components for navigation to benefit from this.
import Link from 'next/link';
// Automatic prefetch when link enters viewport
<Link href={`/p/${project.shortId}`}>
{project.name}
</Link>
// Disable prefetch for rarely-visited routes
<Link href="/user/profile-settings" prefetch={false}>
Settings
</Link>Route Loading States (Suspense Boundaries)
Each route segment has a loading.tsx that renders a skeleton UI during navigation:
| Route Segment | Skeleton Type |
|---|---|
(hydrogen)/loading.tsx | Grid of card skeletons (artists/projects/tracks) |
(hydrogen)/user/loading.tsx | Form fields skeleton (settings pages) |
(hydrogen)/wave/[trackId]/loading.tsx | Cover art + waveform + comments skeleton |
(hydrogen)/artist/[artistId]/loading.tsx | Avatar + bio + project grid skeleton |
(hydrogen)/project/[projectId]/loading.tsx | Cover + track list skeleton |
(other-pages)/loading.tsx | Minimal centered pulse |
Error Boundaries
Route-level error boundaries catch runtime errors gracefully:
| File | Scope |
|---|---|
(hydrogen)/error.tsx | Main app — shows retry + go home buttons with error digest |
global-error.tsx | Root fallback — renders own <html>/<body> if root layout fails |
Bundle Optimization
Standalone Output
The Next.js build uses standalone output for minimal Docker images:
// next.config.mjs
output: 'standalone',Package Import Optimization
Barrel exports from large packages are tree-shaken at build time:
// next.config.mjs
experimental: {
optimizePackageImports: [
'react-icons', // Thousands of icons — only import what's used
'rizzui', // UI library
'framer-motion', // Animation library
'jotai', // State management
'@tanstack/react-query',
'@tanstack/react-table',
'react-hook-form',
'react-hot-toast',
],
}This reduces bundle size by eliminating unused exports during compilation.
Tree Shaking
- RizzUI v2 supports tree shaking — only imported components are bundled
- Lodash is imported per-function (
lodash/debouncenotlodash) - React Icons uses ES module imports for per-icon tree shaking
CI Build Memory
GitHub Actions builds use increased heap size for the larger v4 stack:
# .github/workflows/deploy.yml
env:
NODE_OPTIONS: '--max-old-space-size=4096'Security Headers
Configured in next.config.mjs for all routes:
async headers() {
return [
{
source: '/(.*)',
headers: [
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), geolocation=(), microphone=()' },
],
},
{
source: '/fonts/(.*)',
headers: [
{ key: 'Cache-Control', value: 'public, max-age=31536000, immutable' },
],
},
];
}X-Powered-Byheader is disabled (poweredByHeader: false)- Font files are served with 1-year immutable cache
- API routes are not cached
Audio Streaming Optimization
Waveform Lazy Loading
WaveSurfer.js instances are created only when audio components mount, not during SSR.
Audio Preloading Strategy
// Preload next track in playlist
const preloadAudio = (url: string) => {
const audio = new Audio();
audio.preload = 'metadata'; // Only fetch headers + duration
audio.src = url;
};Recommendations for Large Audio Files
- Serve audio through CDN (Azure Front Door) for edge caching
- Use
preload="metadata"for track listings (don't buffer full audio) - Use
preload="auto"only for the currently playing track - Consider HLS/DASH adaptive streaming for files > 50MB (future)
Performance Checklist
Images
- [x] Use
thumbnailUrlin list views - [x] Use
mediumUrlin detail pages - [x] Add blur placeholder for progressive loading
- [x] 1-year cache headers on all uploaded files (images + audio)
- [x] WebP conversion on upload
- [x] Azure Front Door CDN deployed for global delivery
- [ ] Lazy load below-fold images with
loading="lazy"
Data Fetching
- [x] React Query with 5-minute staleTime
- [x] Cache invalidation on mutations
- [x] Background refetch on window focus
- [ ] Optimistic updates for instant UI feedback
- [ ] Prefetch data on hover for anticipated navigation
Code Splitting
- [x] Dynamic imports for modals and drawers
- [x] Route-based code splitting (automatic in Next.js 16)
- [x] Standalone output for optimized production builds
- [x]
loading.tsxSuspense boundaries for all route segments - [x]
error.tsx+global-error.tsxerror boundaries - [x]
optimizePackageImportsfor react-icons, rizzui, framer-motion, etc. - [ ] Lazy load WaveSurfer.js and Three.js (3D) only when needed
Security & Headers
- [x]
X-Content-Type-Options: nosniff - [x]
X-Frame-Options: DENY - [x]
Referrer-Policy: strict-origin-when-cross-origin - [x]
Permissions-Policyrestricting camera/geo/mic - [x]
poweredByHeader: false - [x] Font assets cached 1 year (immutable)
CDN & Caching
- [x] Cache-Control headers on blob storage (1 year, immutable)
- [x] Next.js image optimization with 30-day cache TTL
- [x] Azure Front Door CDN deployed (
sonnance-images-*.z01.azurefd.net) - [x] CDN_URL env var set on prod + dev Container Apps
- [ ] Service Worker for offline-first audio caching (future)
Performance Monitoring
Lighthouse Metrics
Target scores:
- LCP (Largest Contentful Paint): < 2.5s
- FID (First Input Delay): < 100ms
- CLS (Cumulative Layout Shift): < 0.1
- TTFB (Time to First Byte): < 800ms
DevTools Analysis
- Open Chrome DevTools → Network tab
- Check for "disk cache" on repeat visits
- Verify
Cache-Controlheaders on images - Look for duplicate API requests (React Query should dedupe)
- Check Coverage tab for unused JavaScript
Optimization Roadmap
✅ Completed
| Optimization | Impact | | ----------------------------------------------------------------- | --------------------------------------------- | --- | ---------------------------------------------- | -------------------------------------- | | Azure Front Door CDN for images + audio | Global edge caching, latency 5-10x reduction | | Cache-Control: immutable on all uploaded files (images + audio) | Browser + CDN caching | | loading.tsx route Suspense boundaries (6 files) | Better perceived performance | | error.tsx + global-error.tsx error boundaries | Graceful error recovery | | optimizePackageImports (8 packages) | Smaller JS bundles | | Security headers (X-Frame, CSP, Referrer) | Hardened frontend | | poweredByHeader: false | Reduced attack surface | | Font caching (1 year immutable) | Faster repeat visits | | Next.js minimumCacheTTL (30 days) | Reduced image re-optimization | | Image format/size optimization (WebP) | Smaller image payloads | | Image preloading before skeleton hide | No image pop-in flash | | Azure URL bypass (isAzureUrl + unoptimized) | Consistent preload/render URLs | | Session-stable cache-busting timestamps | Browser cache works across re-renders | | CSS fade-in transition on skeleton → content | Smooth visual transition | | Next.js 15 → 16 upgrade (Turbopack default, React 19.2) | Faster builds, smaller bundles | | Unified skeleton shimmer effect (skeleton.tsx) | Consistent loading states, reduced CSS | | Enhanced artist/project loading skeletons | Better layout match, less CLS on load | | Console.log cleanup in core navigation flow | Cleaner console, reduced noise in dev | | WaveSurfer AbortError handling (all 3 wave-form components) | No unhandled promise rejections | | Font preload optimization (PPMonumentExtended → preload: false) | Eliminated unused preload warnings | | Sidebar React Query migration (recent tracks + artists) | No skeleton flash on navigation, shared cache |
🔜 Pending
| Priority | Optimization | Effort | Impact |
|---|---|---|---|
| 🔴 High | Migrate dashboard pages to React Query useQuery | 1 day | Deduplication, caching, optimistic UI |
| 🟡 Medium | Optimistic updates on mutations | 1 day | Instant UI feedback |
| 🟡 Medium | Prefetch data on link hover | 2 hours | Faster navigation |
| 🟡 Medium | Lazy load WaveSurfer.js on demand | 2 hours | Reduced initial bundle |
| 🟢 Low | Service Worker for offline audio | 2 days | Offline capability |
| 🟢 Low | HLS/DASH adaptive streaming | 3 days | Better large-file streaming |
Troubleshooting
Images not caching?
Check:
- URL includes timestamp? → Remove cache-busting params
- Cache-Control header present? → Check backend blob upload
- Service worker interference? → Check registration
Duplicate API calls?
Check:
- Multiple QueryClientProviders? → Use single provider
- Missing queryKey? → Add unique keys
- staleTime too low? → Increase to 5+ minutes
Build running out of memory?
The production build requires ~3-4GB heap. Ensure NODE_OPTIONS=--max-old-space-size=4096 is set in CI environment.
Last Updated: February 2026