Designing Systems for Emerging Markets: Low Bandwidth, Low-End Devices
February 26, 20262 min read3 views
Designing Systems for Emerging Markets: Low Bandwidth, Low-End Devices
Performance architecture for 2G/3G networks, 512MB RAM devices, and the 4 billion users who aren't on the latest iPhone. Building for the next billion users means building differently.
The Reality of Global Connectivity
The developer experience bubble creates dangerous assumptions:
┌─────────────────────────────────────────────────────────────────┐
│ Developer vs. User Reality │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Developer Environment: │
│ ├── MacBook Pro M3, 16GB RAM │
│ ├── Gigabit fiber │
│ ├── 5ms latency to localhost │
│ └── Latest Chrome, no extensions │
│ │
│ Emerging Market User: │
│ ├── 2-3 year old Android, 2GB RAM │
│ ├── 3G connection (1-2 Mbps) │
│ ├── 300-500ms latency │
│ ├── Data caps (500MB-2GB/month) │
│ ├── Shared device, 50+ apps installed │
│ └── Browser with aggressive memory management │
│ │
│ Performance Gap: │
│ ├── CPU: 10-20x slower │
│ ├── Network: 50-100x slower │
│ ├── Memory: 8x less available │
│ └── Data cost: Actual money per MB │
│ │
└─────────────────────────────────────────────────────────────────┘
Network Conditions by Region
┌─────────────────────────────────────────────────────────────────┐
│ Network Reality by Region │
├─────────────────────────────────────────────────────────────────┤
│ │
│ India (1.4B users): │
│ ├── 4G coverage: 85%, but indoor/rural often falls to 3G │
│ ├── Average speed: 15 Mbps (varies 1-50 Mbps) │
│ ├── Data cost: ~$0.09/GB (among cheapest globally) │
│ └── But: Low income means 1GB = significant spend │
│ │
│ Sub-Saharan Africa (1.1B users): │
│ ├── 4G coverage: 15-40% depending on country │
│ ├── Average speed: 5-10 Mbps │
│ ├── Data cost: $2-10/GB (expensive relative to income) │
│ └── Frequent network switching and drops │
│ │
│ Southeast Asia (700M users): │
│ ├── 4G coverage: 70-90% │
│ ├── Average speed: 10-25 Mbps │
│ ├── High mobile-first usage │
│ └── Congested networks in urban areas │
│ │
│ Latin America (650M users): │
│ ├── 4G coverage: 60-80% │
│ ├── Average speed: 10-20 Mbps │
│ ├── Mix of modern and legacy infrastructure │
│ └── Significant urban/rural divide │
│ │
└─────────────────────────────────────────────────────────────────┘
Performance Budget for Emerging Markets
Standard budgets don't work. Recalibrate for reality:
// Performance budget configuration
interface EmergingMarketBudget {
// Network constraints
initialLoad: {
html: number; // KB
css: number; // KB
jsInitial: number; // KB
imagesAboveFold: number; // KB
total: number; // KB
};
// Time constraints (on 3G)
timing: {
ttfb: number; // ms
fcp: number; // ms
lcp: number; // ms
tti: number; // ms
};
// Device constraints
runtime: {
jsHeapLimit: number; // MB
longTaskLimit: number; // ms
bundleParseBudget: number; // ms
};
}
const EMERGING_MARKET_BUDGET: EmergingMarketBudget = {
initialLoad: {
html: 14, // Fits in first TCP packet
css: 20, // Inlined critical CSS
jsInitial: 50, // Minimal JS for interactivity
imagesAboveFold: 50, // Single hero, heavily compressed
total: 150, // Total initial payload
},
timing: {
ttfb: 600, // 3G latency reality
fcp: 1500, // First content in 1.5s
lcp: 2500, // Main content in 2.5s
tti: 3500, // Interactive in 3.5s
},
runtime: {
jsHeapLimit: 50, // Max 50MB heap
longTaskLimit: 50, // Break up tasks > 50ms
bundleParseBudget: 500, // 500ms parse time max
},
};
// Budget enforcement in build
export function checkBudget(stats: BuildStats): BudgetViolation[] {
const violations: BudgetViolation[] = [];
if (stats.jsSize > EMERGING_MARKET_BUDGET.initialLoad.jsInitial * 1024) {
violations.push({
metric: 'jsInitial',
actual: stats.jsSize / 1024,
budget: EMERGING_MARKET_BUDGET.initialLoad.jsInitial,
severity: 'critical',
});
}
if (stats.totalSize > EMERGING_MARKET_BUDGET.initialLoad.total * 1024) {
violations.push({
metric: 'totalInitial',
actual: stats.totalSize / 1024,
budget: EMERGING_MARKET_BUDGET.initialLoad.total,
severity: 'critical',
});
}
return violations;
}
Network-Resilient Architecture
Offline-First with Service Workers
// sw.ts - Service Worker for offline resilience
const CACHE_VERSION = 'v1';
const STATIC_CACHE = `static-${CACHE_VERSION}`;
const DYNAMIC_CACHE = `dynamic-${CACHE_VERSION}`;
const API_CACHE = `api-${CACHE_VERSION}`;
// Critical resources to precache
const PRECACHE_URLS = [
'/',
'/offline',
'/manifest.json',
'/icons/icon-192.png',
// Minimal CSS and JS
'/critical.css',
'/app-shell.js',
];
// Install: precache critical resources
self.addEventListener('install', (event: ExtendableEvent) => {
event.waitUntil(
caches.open(STATIC_CACHE).then((cache) => {
return cache.addAll(PRECACHE_URLS);
})
);
// Activate immediately
self.skipWaiting();
});
// Activate: clean old caches
self.addEventListener('activate', (event: ExtendableEvent) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys
.filter((key) => !key.includes(CACHE_VERSION))
.map((key) => caches.delete(key))
)
)
);
// Take control immediately
self.clients.claim();
});
// Fetch: network-first for API, cache-first for static
self.addEventListener('fetch', (event: FetchEvent) => {
const { request } = event;
const url = new URL(request.url);
// API requests: network-first with cache fallback
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirstWithCache(request, API_CACHE));
return;
}
// Static assets: cache-first
if (isStaticAsset(url)) {
event.respondWith(cacheFirstWithNetwork(request, STATIC_CACHE));
return;
}
// Pages: stale-while-revalidate
event.respondWith(staleWhileRevalidate(request, DYNAMIC_CACHE));
});
async function networkFirstWithCache(
request: Request,
cacheName: string
): Promise<Response> {
try {
const response = await fetchWithTimeout(request, 5000);
// Cache successful responses
if (response.ok) {
const cache = await caches.open(cacheName);
cache.put(request, response.clone());
}
return response;
} catch (error) {
// Network failed, try cache
const cached = await caches.match(request);
if (cached) {
return cached;
}
// No cache, return offline page for navigation
if (request.mode === 'navigate') {
return caches.match('/offline') as Promise<Response>;
}
throw error;
}
}
async function cacheFirstWithNetwork(
request: Request,
cacheName: string
): Promise<Response> {
const cached = await caches.match(request);
if (cached) {
// Update cache in background
fetchAndCache(request, cacheName);
return cached;
}
return fetchAndCache(request, cacheName);
}
async function staleWhileRevalidate(
request: Request,
cacheName: string
): Promise<Response> {
const cached = await caches.match(request);
const fetchPromise = fetchAndCache(request, cacheName);
// Return cached immediately, update in background
return cached || fetchPromise;
}
async function fetchWithTimeout(
request: Request,
timeout: number
): Promise<Response> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(request, { signal: controller.signal });
clearTimeout(timeoutId);
return response;
} catch (error) {
clearTimeout(timeoutId);
throw error;
}
}
async function fetchAndCache(
request: Request,
cacheName: string
): Promise<Response> {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(cacheName);
cache.put(request, response.clone());
}
return response;
}
function isStaticAsset(url: URL): boolean {
return /\.(js|css|png|jpg|jpeg|webp|avif|woff2|ico)$/i.test(url.pathname);
}
Background Sync for Resilient Mutations
// Background sync for form submissions
self.addEventListener('sync', (event: SyncEvent) => {
if (event.tag.startsWith('form-submit-')) {
event.waitUntil(syncFormSubmission(event.tag));
}
});
async function syncFormSubmission(tag: string): Promise<void> {
const formId = tag.replace('form-submit-', '');
// Get pending submission from IndexedDB
const pending = await getPendingSubmission(formId);
if (!pending) return;
try {
const response = await fetch(pending.url, {
method: pending.method,
headers: pending.headers,
body: pending.body,
});
if (response.ok) {
// Clear pending submission
await clearPendingSubmission(formId);
// Notify user
self.registration.showNotification('Submission successful', {
body: 'Your form was submitted successfully.',
icon: '/icons/success.png',
});
}
} catch (error) {
// Will retry on next sync
console.error('Sync failed, will retry:', error);
}
}
// Client-side: queue submissions when offline
export async function submitForm(
url: string,
data: FormData
): Promise<Response | void> {
if (navigator.onLine) {
return fetch(url, { method: 'POST', body: data });
}
// Queue for background sync
const formId = crypto.randomUUID();
await savePendingSubmission(formId, {
url,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(Object.fromEntries(data)),
timestamp: Date.now(),
});
// Register sync
const registration = await navigator.serviceWorker.ready;
await registration.sync.register(`form-submit-${formId}`);
// Show pending state to user
showToast('Saved offline. Will submit when connected.');
}
Data-Efficient Loading
Adaptive Loading Based on Connection
// Network-aware loading
export function useNetworkAwareLoading() {
const [connectionType, setConnectionType] = useState<string>('4g');
const [saveData, setSaveData] = useState(false);
useEffect(() => {
const connection = (navigator as any).connection;
if (!connection) return;
const updateConnection = () => {
setConnectionType(connection.effectiveType);
setSaveData(connection.saveData);
};
updateConnection();
connection.addEventListener('change', updateConnection);
return () => {
connection.removeEventListener('change', updateConnection);
};
}, []);
return {
connectionType,
saveData,
shouldLoadImages: connectionType !== '2g' && !saveData,
shouldLoadVideo: connectionType === '4g' && !saveData,
shouldPrefetch: connectionType === '4g' && !saveData,
imageQuality: getImageQuality(connectionType, saveData),
};
}
function getImageQuality(
connectionType: string,
saveData: boolean
): 'low' | 'medium' | 'high' {
if (saveData) return 'low';
switch (connectionType) {
case '4g':
return 'high';
case '3g':
return 'medium';
default:
return 'low';
}
}
// Adaptive image component
function AdaptiveImage({
src,
alt,
width,
height,
}: {
src: string;
alt: string;
width: number;
height: number;
}) {
const { shouldLoadImages, imageQuality } = useNetworkAwareLoading();
const [loaded, setLoaded] = useState(false);
// Don't load images on 2G or save-data
if (!shouldLoadImages) {
return (
<div
className="image-placeholder"
style={{ width, height, backgroundColor: '#e0e0e0' }}
role="img"
aria-label={alt}
>
<button onClick={() => setLoaded(true)}>
Load image ({estimateSize(src, imageQuality)}KB)
</button>
</div>
);
}
const optimizedSrc = getOptimizedUrl(src, imageQuality, width);
return (
<img
src={optimizedSrc}
alt={alt}
width={width}
height={height}
loading="lazy"
decoding="async"
/>
);
}
function getOptimizedUrl(
src: string,
quality: 'low' | 'medium' | 'high',
width: number
): string {
const qualityMap = { low: 30, medium: 60, high: 80 };
const widthMap = { low: Math.min(width, 400), medium: Math.min(width, 800), high: width };
// Assuming Cloudinary or similar
return `${src}?w=${widthMap[quality]}&q=${qualityMap[quality]}&f=auto`;
}
Aggressive Compression
// next.config.js - Compression configuration
module.exports = {
compress: true,
images: {
formats: ['image/avif', 'image/webp'],
deviceSizes: [320, 420, 768, 1024], // Smaller sizes for mobile-first
imageSizes: [16, 32, 48, 64, 96],
minimumCacheTTL: 31536000, // 1 year
},
experimental: {
optimizeCss: true,
},
webpack: (config, { isServer }) => {
if (!isServer) {
// Aggressive code splitting
config.optimization.splitChunks = {
chunks: 'all',
minSize: 10000, // 10KB min chunk
maxSize: 50000, // 50KB max chunk
cacheGroups: {
// Separate vendors into smaller chunks
react: {
test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/,
name: 'react',
priority: 20,
},
commons: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
priority: 10,
maxSize: 50000,
},
},
};
}
return config;
},
};
Critical CSS Extraction
// Extract and inline critical CSS
import { extractCritical } from '@emotion/server';
import { renderToString } from 'react-dom/server';
export async function renderPage(Component: React.ComponentType): Promise<string> {
// Render component
const html = renderToString(<Component />);
// Extract critical CSS
const { css, ids } = extractCritical(html);
// Return with inlined critical CSS
return `
<!DOCTYPE html>
<html>
<head>
<style data-emotion="${ids.join(' ')}">${css}</style>
<link rel="preload" href="/styles/main.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/styles/main.css"></noscript>
</head>
<body>
<div id="root">${html}</div>
<script src="/app.js" defer></script>
</body>
</html>
`;
}
Memory-Efficient Components
Virtual Lists for Large Data Sets
// Memory-efficient virtual list
import { useVirtualizer } from '@tanstack/react-virtual';
function ProductList({ products }: { products: Product[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: products.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 100, // Estimated row height
overscan: 3, // Render 3 extra items for smooth scrolling
});
return (
<div
ref={parentRef}
className="product-list"
style={{ height: '100vh', overflow: 'auto' }}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
<ProductCard product={products[virtualRow.index]} />
</div>
))}
</div>
</div>
);
}
// Memory cleanup on unmount
function ProductCard({ product }: { product: Product }) {
const [imageLoaded, setImageLoaded] = useState(false);
const imageRef = useRef<HTMLImageElement>(null);
// Release image memory when scrolled out of view
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting && imageRef.current) {
// Release image memory
imageRef.current.src = '';
setImageLoaded(false);
}
},
{ rootMargin: '100px' }
);
if (imageRef.current) {
observer.observe(imageRef.current);
}
return () => observer.disconnect();
}, []);
return (
<article className="product-card">
<img
ref={imageRef}
src={imageLoaded ? product.imageUrl : undefined}
data-src={product.imageUrl}
alt={product.name}
loading="lazy"
onLoad={() => setImageLoaded(true)}
/>
<h3>{product.name}</h3>
<p>{product.price}</p>
</article>
);
}
Component-Level Memory Management
// Memory-conscious data fetching
import { useQuery, useQueryClient } from '@tanstack/react-query';
function useMemoryEfficientQuery<T>(
key: string[],
fetcher: () => Promise<T>,
options?: {
maxAge?: number; // Max time in cache
maxItems?: number; // Max items to keep in memory
}
) {
const queryClient = useQueryClient();
return useQuery({
queryKey: key,
queryFn: fetcher,
// Aggressive garbage collection
gcTime: options?.maxAge ?? 5 * 60 * 1000, // 5 minutes default
// Don't keep stale data
staleTime: 30 * 1000, // 30 seconds
// Limit memory usage
structuralSharing: false, // Disable for large objects
// Clean up on success
onSuccess: () => {
// Prune cache if too large
const cache = queryClient.getQueryCache();
const queries = cache.getAll();
if (queries.length > (options?.maxItems ?? 50)) {
// Remove oldest queries
const sortedByAge = queries.sort(
(a, b) => (a.state.dataUpdatedAt ?? 0) - (b.state.dataUpdatedAt ?? 0)
);
const toRemove = sortedByAge.slice(0, queries.length - 50);
toRemove.forEach((query) => {
queryClient.removeQueries({ queryKey: query.queryKey });
});
}
},
});
}
// Cleanup hook for heavy components
function useCleanupOnUnmount(cleanup: () => void) {
useEffect(() => {
return () => {
cleanup();
// Force garbage collection hint (non-standard but helps)
if ('gc' in window) {
(window as any).gc();
}
};
}, [cleanup]);
}
Lightweight Framework Alternatives
When React is too heavy, consider alternatives:
// Preact - 3KB alternative to React
// preact.config.js
export default {
webpack(config) {
// Alias React to Preact
config.resolve.alias = {
...config.resolve.alias,
react: 'preact/compat',
'react-dom': 'preact/compat',
};
return config;
},
};
// Or use Preact directly
import { h, render, Component } from 'preact';
import { useState, useEffect } from 'preact/hooks';
function ProductCard({ product }) {
const [expanded, setExpanded] = useState(false);
return (
<article class="product-card">
<h3>{product.name}</h3>
<p>{product.price}</p>
<button onClick={() => setExpanded(!expanded)}>
{expanded ? 'Less' : 'More'}
</button>
{expanded && <p>{product.description}</p>}
</article>
);
}
HTML-First with Minimal JS
// Server-rendered HTML with progressive enhancement
// pages/products.tsx
export default function ProductsPage({ products }: { products: Product[] }) {
return (
<html>
<head>
<title>Products</title>
{/* Minimal critical CSS */}
<style dangerouslySetInnerHTML={{ __html: criticalCSS }} />
</head>
<body>
<main>
<h1>Products</h1>
{/* Works without JS */}
<ul className="product-grid">
{products.map((product) => (
<li key={product.id}>
<article>
<h2>{product.name}</h2>
<p className="price">{formatPrice(product.price)}</p>
{/* Native HTML form - works without JS */}
<form action="/api/cart" method="POST">
<input type="hidden" name="productId" value={product.id} />
<button type="submit">Add to Cart</button>
</form>
</article>
</li>
))}
</ul>
{/* Progressive enhancement */}
<script
type="module"
dangerouslySetInnerHTML={{
__html: `
// Only enhance if JS is available
document.querySelectorAll('form[action="/api/cart"]').forEach(form => {
form.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(form);
try {
await fetch('/api/cart', {
method: 'POST',
body: formData,
});
// Update cart count without page reload
updateCartCount();
} catch {
// Fall back to form submission
form.submit();
}
});
});
`,
}}
/>
</main>
</body>
</html>
);
}
export async function getServerSideProps() {
const products = await db.products.findMany({ take: 20 });
return { props: { products } };
}
Aggressive Code Splitting
// Route-based splitting with size limits
const routes = {
'/': () => import(/* webpackChunkName: "home", webpackMaxSize: 30000 */ './pages/Home'),
'/products': () => import(/* webpackChunkName: "products" */ './pages/Products'),
'/product/:id': () => import(/* webpackChunkName: "product-detail" */ './pages/ProductDetail'),
'/cart': () => import(/* webpackChunkName: "cart" */ './pages/Cart'),
'/checkout': () => import(/* webpackChunkName: "checkout" */ './pages/Checkout'),
};
// Feature-based splitting
const features = {
// Heavy features loaded only when needed
richTextEditor: () => import(/* webpackChunkName: "editor" */ './features/RichTextEditor'),
imageGallery: () => import(/* webpackChunkName: "gallery" */ './features/ImageGallery'),
maps: () => import(/* webpackChunkName: "maps" */ './features/Maps'),
charts: () => import(/* webpackChunkName: "charts" */ './features/Charts'),
};
// Load feature on demand
function useFeature<T>(
loader: () => Promise<{ default: T }>,
condition: boolean
): T | null {
const [feature, setFeature] = useState<T | null>(null);
useEffect(() => {
if (condition && !feature) {
loader().then((module) => setFeature(module.default));
}
}, [condition, feature, loader]);
return feature;
}
// Usage
function ProductPage({ product }: { product: Product }) {
const hasGallery = product.images.length > 1;
const ImageGallery = useFeature(features.imageGallery, hasGallery);
return (
<div>
{hasGallery && ImageGallery ? (
<ImageGallery images={product.images} />
) : (
<img src={product.images[0]} alt={product.name} />
)}
</div>
);
}
Bundle Analysis and Optimization
// analyze-bundle.js
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
module.exports = {
webpack: (config, { isServer }) => {
if (process.env.ANALYZE && !isServer) {
config.plugins.push(
new BundleAnalyzerPlugin({
analyzerMode: 'static',
reportFilename: '../bundle-report.html',
})
);
}
// Eliminate large dependencies
config.resolve.alias = {
...config.resolve.alias,
// Use date-fns instead of moment
moment: 'date-fns',
// Use lodash-es for tree shaking
lodash: 'lodash-es',
};
return config;
},
};
// CI budget check
// scripts/check-bundle-size.js
const fs = require('fs');
const path = require('path');
const BUDGETS = {
'main.js': 50 * 1024, // 50KB
'vendor.js': 100 * 1024, // 100KB
'total': 150 * 1024, // 150KB
};
function checkBudgets() {
const buildDir = path.join(__dirname, '../.next/static/chunks');
const files = fs.readdirSync(buildDir);
let totalSize = 0;
const violations = [];
files.forEach((file) => {
if (!file.endsWith('.js')) return;
const stats = fs.statSync(path.join(buildDir, file));
totalSize += stats.size;
// Check individual file budgets
for (const [pattern, budget] of Object.entries(BUDGETS)) {
if (file.includes(pattern) && stats.size > budget) {
violations.push({
file,
actual: stats.size,
budget,
over: stats.size - budget,
});
}
}
});
// Check total budget
if (totalSize > BUDGETS.total) {
violations.push({
file: 'TOTAL',
actual: totalSize,
budget: BUDGETS.total,
over: totalSize - BUDGETS.total,
});
}
if (violations.length > 0) {
console.error('Bundle budget violations:');
violations.forEach((v) => {
console.error(` ${v.file}: ${(v.actual / 1024).toFixed(1)}KB (budget: ${(v.budget / 1024).toFixed(1)}KB, over by ${(v.over / 1024).toFixed(1)}KB)`);
});
process.exit(1);
}
console.log(`Bundle size OK: ${(totalSize / 1024).toFixed(1)}KB`);
}
checkBudgets();
Testing on Real Devices
Device Lab Setup
// Test matrix for emerging markets
const DEVICE_PROFILES = {
// Budget Android (India, Southeast Asia)
budgetAndroid: {
name: 'Redmi 9A',
cpu: '4x slow',
memory: '2GB',
network: '3G',
browser: 'Chrome 90',
},
// Entry Android (Africa)
entryAndroid: {
name: 'Samsung A01',
cpu: '6x slow',
memory: '1GB',
network: '2G',
browser: 'Chrome 85',
},
// Feature phone browser (KaiOS)
featurePhone: {
name: 'JioPhone',
cpu: '10x slow',
memory: '512MB',
network: '2G',
browser: 'KaiOS Browser',
},
};
// Chrome DevTools throttling presets
const NETWORK_PRESETS = {
'2G': {
downloadThroughput: 50 * 1024, // 50 KB/s
uploadThroughput: 25 * 1024, // 25 KB/s
latency: 500, // 500ms
},
'3G': {
downloadThroughput: 200 * 1024, // 200 KB/s
uploadThroughput: 100 * 1024, // 100 KB/s
latency: 300, // 300ms
},
'4G-slow': {
downloadThroughput: 1000 * 1024, // 1 MB/s
uploadThroughput: 500 * 1024, // 500 KB/s
latency: 100, // 100ms
},
};
// Playwright test with throttling
import { test, expect } from '@playwright/test';
test.describe('Emerging market performance', () => {
test.beforeEach(async ({ page, context }) => {
// Emulate slow CPU
await page.context().setCPUThrottlingRate(4);
// Emulate 3G network
await context.route('**/*', (route) => {
route.continue();
});
});
test('loads within budget on 3G', async ({ page }) => {
const metrics = await page.evaluate(() => {
return new Promise((resolve) => {
new PerformanceObserver((list) => {
const lcp = list.getEntries().find(
(e) => e.entryType === 'largest-contentful-paint'
);
if (lcp) {
resolve({
lcp: lcp.startTime,
fcp: performance.getEntriesByName('first-contentful-paint')[0]?.startTime,
});
}
}).observe({ entryTypes: ['largest-contentful-paint'] });
});
});
// Budget: 2.5s LCP on 3G
expect(metrics.lcp).toBeLessThan(2500);
});
test('works offline after initial load', async ({ page, context }) => {
// Load page
await page.goto('/products');
await page.waitForLoadState('networkidle');
// Go offline
await context.setOffline(true);
// Should still work
await page.reload();
await expect(page.locator('h1')).toContainText('Products');
// Should show offline indicator
await expect(page.locator('[data-offline]')).toBeVisible();
});
});
Summary
Building for emerging markets requires fundamentally different architectural decisions:
| Aspect | Standard Approach | Emerging Markets |
|---|---|---|
| JS Budget | 200-500KB | 50-100KB |
| Initial Load | 1-2MB | 150-300KB |
| LCP Target | 2.5s | 3-4s on 3G |
| Offline Support | Nice to have | Critical |
| Images | Auto-quality | Adaptive/optional |
| Framework | React | Preact/vanilla |
| Caching | CDN-first | Device-first |
| Forms | JS-enhanced | HTML-native |
The principles:
- Budget ruthlessly — Every KB costs users money
- Offline-first — Networks are unreliable
- Progressive enhancement — HTML works everywhere
- Adaptive loading — Detect and respond to conditions
- Memory-conscious — 2GB is the ceiling, not the floor
- Test on real devices — Emulation lies
The next billion users aren't waiting for better networks. They're using what they have now. Build for them.
What did you think?