Back to Blog

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:

AspectStandard ApproachEmerging Markets
JS Budget200-500KB50-100KB
Initial Load1-2MB150-300KB
LCP Target2.5s3-4s on 3G
Offline SupportNice to haveCritical
ImagesAuto-qualityAdaptive/optional
FrameworkReactPreact/vanilla
CachingCDN-firstDevice-first
FormsJS-enhancedHTML-native

The principles:

  1. Budget ruthlessly — Every KB costs users money
  2. Offline-first — Networks are unreliable
  3. Progressive enhancement — HTML works everywhere
  4. Adaptive loading — Detect and respond to conditions
  5. Memory-conscious — 2GB is the ceiling, not the floor
  6. 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?

Related Posts

© 2026 Vidhya Sagar Thakur. All rights reserved.