PatternsMobile & PWA
Mobile Performance Optimization
Optimize for mobile devices with limited resources
Mobile devices have slower CPUs, less memory, and variable networks compared to desktop. Optimizing for mobile is critical—50% of users abandon sites that take >3 seconds to load.
The Mobile Performance Gap
Desktop:
- 8-16 GB RAM
- Fast multi-core CPU
- Stable WiFi
- Large battery
Mobile:
- 2-6 GB RAM
- Slower CPU (esp. mid-range)
- Variable network (2G-5G)
- Battery constraintsJavaScript Optimization
Reduce Bundle Size
// ❌ BAD: Import entire library
import _ from 'lodash';
import moment from 'moment';
// ✅ GOOD: Import only what you need
import debounce from 'lodash/debounce';
import { format } from 'date-fns';
// Or use tree-shaking friendly libraries
import { useState } from 'react'; // Tree-shakes automaticallyCode Splitting
// Split by route
import { lazy, Suspense } from 'react';
const Home = lazy(() => import('./pages/Home'));
const Profile = lazy(() => import('./pages/Profile'));
const Settings = lazy(() => import('./pages/Settings'));
export function App() {
return (
<Suspense fallback={<Loading />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/profile" element={<Profile />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
);
}Defer Non-Critical JS
<!-- Critical: Load immediately -->
<script src="/app.js"></script>
<!-- Non-critical: Defer -->
<script src="/analytics.js" defer></script>
<script src="/chat-widget.js" defer></script>
<!-- Low priority: Load on idle -->
<script>
if ('requestIdleCallback' in window) {
requestIdleCallback(() => {
import('./non-critical.js');
});
}
</script>CSS Optimization
Inline Critical CSS
<!DOCTYPE html>
<html>
<head>
<!-- Inline critical CSS for above-the-fold content -->
<style>
/* Critical styles */
body { margin: 0; font-family: sans-serif; }
.header { height: 60px; background: #000; }
.hero { height: 400px; }
</style>
<!-- Load full CSS async -->
<link rel="preload" href="/styles.css" as="style" onload="this.rel='stylesheet'">
</head>
</html>Remove Unused CSS
# Use PurgeCSS
npm install -D @fullhuman/postcss-purgecss
# postcss.config.js
module.exports = {
plugins: [
require('@fullhuman/postcss-purgecss')({
content: ['./src/**/*.{js,jsx,ts,tsx}'],
defaultExtractor: content => content.match(/[\w-/:]+(?<!:)/g) || [],
}),
],
};Image Optimization
Responsive Images
// components/ResponsiveImage.tsx
interface Props {
src: string;
alt: string;
width: number;
height: number;
}
export function ResponsiveImage({ src, alt, width, height }: Props) {
// Generate srcset for different screen sizes
const srcset = [
`${src}?w=320 320w`,
`${src}?w=640 640w`,
`${src}?w=960 960w`,
`${src}?w=1280 1280w`,
].join(', ');
return (
<img
src={`${src}?w=640`}
srcSet={srcset}
sizes="(max-width: 640px) 100vw, 640px"
alt={alt}
width={width}
height={height}
loading="lazy"
/>
);
}Modern Formats
<picture>
<!-- AVIF: Best compression -->
<source srcset="/image.avif" type="image/avif" />
<!-- WebP: Good compression -->
<source srcset="/image.webp" type="image/webp" />
<!-- Fallback: JPEG -->
<img src="/image.jpg" alt="Description" loading="lazy" />
</picture>Lazy Loading
// Lazy load images below the fold
export function LazyImage({ src, alt }: Props) {
const [isVisible, setIsVisible] = useState(false);
const imgRef = useRef<HTMLImageElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
observer.disconnect();
}
},
{ rootMargin: '100px' } // Load 100px before visible
);
if (imgRef.current) {
observer.observe(imgRef.current);
}
return () => observer.disconnect();
}, []);
return (
<img
ref={imgRef}
src={isVisible ? src : '/placeholder.jpg'}
alt={alt}
loading="lazy"
/>
);
}Reduce Main Thread Work
Web Workers for Heavy Tasks
// worker.ts
self.addEventListener('message', (e) => {
const { type, data } = e.data;
if (type === 'PROCESS_DATA') {
// Heavy computation off main thread
const result = processLargeDataset(data);
self.postMessage({ type: 'RESULT', result });
}
});
function processLargeDataset(data: any[]) {
// CPU-intensive work
return data.map(item => /* ... */);
}// main.ts
const worker = new Worker('/worker.js');
worker.postMessage({ type: 'PROCESS_DATA', data: largeDataset });
worker.addEventListener('message', (e) => {
if (e.data.type === 'RESULT') {
console.log('Processed:', e.data.result);
}
});Debounce Expensive Operations
import { debounce } from 'lodash';
// ❌ BAD: Runs on every keystroke
function handleSearch(query: string) {
fetchSearchResults(query); // Expensive!
}
// ✅ GOOD: Debounced
const handleSearch = debounce((query: string) => {
fetchSearchResults(query);
}, 300);Virtual Scrolling for Long Lists
// Use react-window for long lists
import { FixedSizeList } from 'react-window';
export function VirtualList({ items }: Props) {
return (
<FixedSizeList
height={600}
itemCount={items.length}
itemSize={50}
width="100%"
>
{({ index, style }) => (
<div style={style}>
{items[index].name}
</div>
)}
</FixedSizeList>
);
}Network Optimization
Prefetch Critical Resources
<!-- DNS prefetch -->
<link rel="dns-prefetch" href="https://api.example.com" />
<!-- Preconnect (DNS + TCP + TLS) -->
<link rel="preconnect" href="https://cdn.example.com" />
<!-- Prefetch resources -->
<link rel="prefetch" href="/next-page.js" />
<!-- Preload critical resources -->
<link rel="preload" href="/critical.js" as="script" />
<link rel="preload" href="/hero.jpg" as="image" />Compress Assets
// next.config.js
module.exports = {
compress: true, // Enable gzip
webpack(config) {
// Enable compression
if (process.env.NODE_ENV === 'production') {
config.plugins.push(
new CompressionPlugin({
algorithm: 'brotliCompress', // Better than gzip
test: /\.(js|css|html|svg)$/,
threshold: 10240,
})
);
}
return config;
},
};Cache Aggressively
// sw.js - Service Worker caching
const CACHE_NAME = 'v1';
const STATIC_ASSETS = [
'/',
'/app.js',
'/app.css',
'/logo.svg',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(STATIC_ASSETS);
})
);
});
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((response) => {
return response || fetch(event.request);
})
);
});Touch Interactions
Passive Event Listeners
// ❌ BAD: Blocks scrolling
document.addEventListener('touchstart', handleTouch);
// ✅ GOOD: Non-blocking
document.addEventListener('touchstart', handleTouch, { passive: true });Optimize Touch Targets
/* Minimum 48x48px touch targets */
button {
min-width: 48px;
min-height: 48px;
padding: 12px 16px;
}
/* Adequate spacing between targets */
.button-group button {
margin: 8px;
}Battery Optimization
Reduce Animations on Low Battery
export function useLowBattery() {
const [isLowBattery, setIsLowBattery] = useState(false);
useEffect(() => {
const checkBattery = async () => {
if ('getBattery' in navigator) {
const battery = await (navigator as any).getBattery();
const updateBatteryStatus = () => {
setIsLowBattery(battery.level < 0.2 && !battery.charging);
};
battery.addEventListener('levelchange', updateBatteryStatus);
battery.addEventListener('chargingchange', updateBatteryStatus);
updateBatteryStatus();
}
};
checkBattery();
}, []);
return isLowBattery;
}
// Use in component
export function AnimatedComponent() {
const isLowBattery = useLowBattery();
return (
<div className={isLowBattery ? 'no-animation' : 'animated'}>
Content
</div>
);
}Performance Monitoring
// Track mobile performance metrics
export function trackMobilePerformance() {
if ('PerformanceObserver' in window) {
// Largest Contentful Paint
const lcpObserver = new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
console.log('LCP:', lastEntry.renderTime || lastEntry.loadTime);
});
lcpObserver.observe({ entryTypes: ['largest-contentful-paint'] });
// First Input Delay
const fidObserver = new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
console.log('FID:', entry.processingStart - entry.startTime);
});
});
fidObserver.observe({ entryTypes: ['first-input'] });
// Cumulative Layout Shift
let clsScore = 0;
const clsObserver = new PerformanceObserver((list) => {
list.getEntries().forEach((entry: any) => {
if (!entry.hadRecentInput) {
clsScore += entry.value;
console.log('CLS:', clsScore);
}
});
});
clsObserver.observe({ entryTypes: ['layout-shift'] });
}
// Device info
console.log('Device:', {
memory: (navigator as any).deviceMemory || 'unknown',
cores: navigator.hardwareConcurrency || 'unknown',
connection: (navigator as any).connection?.effectiveType || 'unknown',
});
}Best Practices Checklist
- Bundle size < 200 KB (gzipped)
- Time to Interactive < 3s on 3G
- Images lazy loaded and optimized
- Critical CSS inlined
- JavaScript deferred or async
- Service Worker for offline
- Touch targets ≥ 48x48px
- Passive event listeners
- Virtual scrolling for lists
- Compress assets (Brotli/gzip)
- Prefetch critical resources
- Monitor Core Web Vitals
Testing on Real Devices
# Chrome Remote Debugging
1. Enable USB debugging on Android
2. Connect device via USB
3. Open chrome://inspect in Chrome
4. Click "Inspect" on your device
# Network throttling
1. DevTools → Network tab
2. Select "Slow 3G" or "Fast 3G"
3. Test performance
# Lighthouse on mobile
lighthouse https://example.com --preset=mobile --viewCommon Pitfalls
❌ Only testing on desktop: Misses mobile issues
✅ Test on real mid-range devices
❌ Large JavaScript bundles: Slow parsing on mobile
✅ Code split, defer non-critical
❌ Unoptimized images: Slow load, wastes data
✅ Responsive, lazy, modern formats
❌ Blocking scroll: Poor UX
✅ Passive listeners, optimize touch
❌ No offline support: Fails on poor networks
✅ Service worker, caching
Mobile performance is critical—optimize aggressively for the devices most of your users have!