Designing Systems for Emerging Markets: Low Bandwidth, Low-End Devices
February 26, 20262 min read3 views
emerging markets
low bandwidth optimization
performance engineering
progressive enhancement
offline first
frontend architecture
scalable systems
mobile performance
edge computing
system design
accessibility
modern web
product engineering
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?