How to Build PWAs for MTN MoMo, Offline Ordering & Low-Bandwidth Users in Accra

Beyond the Webview: How Progressive Web Apps (PWAs) Are Solving West Africa’s Mobile Connectivity Challenge
1. Executive Summary & Introduction
For a CTO in San Francisco, a native app is a convenience choice. For a user in Kaneshie, Accra, it is an economic decision.
West Africa’s digital economy is mobile-first, but not mobile-native. With over 83% of internet access in Ghana occurring via smartphones, predominantly budget Android devices with 16GB-32GB of storage, the traditional Native App (APK) distribution model introduces structural friction that directly kills conversion, retention, and trust.
If you are building for this market, you are not fighting for features. You are fighting against three physical friction points:
Friction 1: The Cost-Per-Megabyte Data Penalty. The average cost of 1GB of mobile data in Ghana hovers around $0.59, but for the informal economy worker buying 100MB daily bundles at GHS 5-7, the marginal cost is far higher. A 45MB e-commerce APK represents 45% of that daily bundle. Users will not spend it on an unproven app. Every megabyte must prove ROI before download.
Friction 2: The Storage Cliff. Tecno, Infinix, and 【entity-Samsung¦canonical_name=Samsung】 A-series devices dominate. On a 32GB device, after OS, WhatsApp media, and photos, available storage is often <800MB. Android’s INSTALL_FAILED_INSUFFICIENT_STORAGE error is a daily reality. When users must delete photos to install your app, you have already lost.
Friction 3: The Network Handoff Drop. Our users are constantly moving through 4G, 3G, EDGE, and offline tunnels – trotro rides from Kasoa to Circle, intermittent power affecting cell towers, rainy season signal degradation. A standard native API call that fails on onFailure() is a failed transaction, a lost order, a dropped Susu contribution. Reliability cannot assume connectivity.
Progressive Web Apps are not a compromise technology for West Africa. They are the native architecture for this environment.
Native vs. PWA Installation Flow: The Drop-off Analysis
NATIVE ANDROID (APK) FLOW User clicks ad/link -> Redirect to Play Store (8-15MB page load) -> Views 30MB-80MB APK size -> Checks data balance -> [DROP 45%] -> Clicks Install -> "Insufficient Storage" -> [DROP 25%] -> Waits 3-5 min download -> Grant Permissions -> [DROP 15%] -> First open (cold start, no cache) Effective Conversion: ~15% OPTIMIZED PWA FLOW User clicks link -> Loads site < 1.5s (cached shell < 150KB) -> Uses core features instantly (Add to Cart, Read Article) -> System triggers BeforeInstallPrompt (after 2nd visit / 45s engagement) -> "Add to Home Screen" -> Installs < 350KB (Manifest + Icons + SW) -> Opens in standalone mode, no URL bar, offline-ready Effective Conversion: ~65-75% Time to First Value: < 3 seconds vs. 4+ minutes
The strategic implication is clear: the PWA does not ask for commitment upfront. It earns installation through value delivered.
2. Architectural Foundations
A PWA is not a website bookmarked to a home screen. It is an application with three technical primitives that make it resilient in low-trust networks.
2.1 Service Workers: The Client-Side Network Proxy
A Service Worker is a JavaScript proxy layer that sits between your application and the network. It intercepts all fetch events, allowing you to programmatically decide what goes to the network, what comes from cache, and what gets queued.
This is critical for compliance and cost control under Ghana’s regulatory environment. With oversight from the Data Protection Commission (DPC) and Cyber Security Authority (CSA), minimizing unnecessary data transmission is not just UX, it is a liability reduction.
The Service Worker lifecycle is deterministic: install -> activate -> fetch.
+----------------+ +------------------+ +----------------+
| Web App (UI) | ---> | Service Worker | ---> | Network / |
| React/Vue | <--- | (sw.js) | <--- | CacheStorage |
| Thread | | Proxy Thread | | IndexedDB |
+----------------+ +------------------+ +----------------+
^ |
| v
| +------------------+
+----------------| Background Sync |
| Manager (Queue) |
+------------------+
When integrated correctly, it reduces repeat data consumption by 60-80% for returning users.
2.2 Caching Strategies: The Right Tool for the Right Payload
You cannot apply one caching strategy globally. A production PWA in Accra uses three strategies concurrently.
Strategy 1: Stale-While-Revalidate (For App Shell & Non-Critical APIs)
Ideal for CSS, JS bundles, product category lists, and editorial content that can tolerate slight staleness.
Logic: Return cached version instantly (0ms latency, 0KB data), then fetch fresh version in background and update cache for next load.
Use Case: Your marketplace header, navigation, and product filters. User gets instant paint, even on 2G.
Strategy 2: Network First with Cache Fallback (For User-Critical Dynamic Data)
Ideal for cart totals, wallet balances, order status, MoMo transaction verification.
Logic: Try network first. If it fails within 3000ms timeout (critical for network handoffs), fall back to last known cached value with a clear offline UI flag.
Use Case: A user checks their Susu contribution balance in an area with intermittent connectivity. They see the last synced balance with a timestamp, rather than a blank error screen.
Strategy 3: Cache First with Network Fallback (For Immutable Assets)
Ideal for images, fonts, icons, logos that have versioned filenames.
Logic: If asset exists in cache, never hit network. If not, fetch, cache, and return.
Use Case: Product images in your e-commerce catalog. Once viewed, they load offline and cost zero data on repeat views. This alone cuts data use by 40% for repeat shoppers.
2.3 Web App Manifest: From Browser Tab to Standalone Asset
The manifest.json is what tells Android that your site is an app. Without it, you are just a webview. With it, you are installed, standalone, and present in the app switcher.
This is a production-ready manifest configured for Ghanaian deployment:
{
"name": "MarketAccra - Ghana's Marketplace",
"short_name": "MarketAccra",
"description": "Buy and sell online with offline ordering and MoMo Pay",
"start_url": "/?source=pwa",
"scope": "/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#0a0a0a",
"theme_color": "#FFD700",
"lang": "en-GH",
"categories": ["shopping", "business", "finance"],
"icons": [
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"shortcuts": [
{
"name": "Pay with MoMo",
"short_name": "MoMo Pay",
"description": "Quick MoMo checkout",
"url": "/checkout/momo?source=pwa-shortcut",
"icons": [{ "src": "/icons/momo-96.png", "sizes": "96x96" }]
}
]
}Tactical Takeaways:
display: standalone removes browser chrome and delivers native-equivalent immersion.
purpose: maskable is non-negotiable for Tecno/Infinix launchers which force adaptive icons.
start_url with query param is essential for analytics attribution to separate PWA users in your GRA VAT-compliant invoicing.
3. Localized Case Studies: PWA Patterns That Actually Work Here
Case Study 1: Multi-Vendor E-Commerce – Offline Order Queues
Problem: A user in Kasoa adds 3 items to cart on 4G, enters a trotro (drops to EDGE), and checkout fails. Order lost.
PWA Solution Architecture: IndexedDB + Background Sync API
- Cart state and order payload are written to IndexedDB (
orders-queuestore), not just held in memory. - When user clicks
Place Order, Service Worker attemptsfetch('/api/orders'). - If network fails, it registers
self.registration.sync.register('sync-orders'). - Once connectivity returns (even if app is closed), the SyncManager automatically replays the queue.
- Integrates with MTN MoMo API and Telecel Cash via server-side callback: order status remains
PENDING_VERIFICATIONuntil provider webhook confirms.
Result: +22% order completion rate during peak traffic hours (5-8pm) where network congestion is highest.
Case Study 2: Regional Editorial & News Portals – The 10KB Payload Rule
News sites lose 70% of users due to ad-heavy pages weighing 3MB+.
PWA Solution: Pre-fetched, compressed JSON. On install, Service Worker pre-caches top 20 articles as raw JSON (/api/articles?limit=20&fields=title,excerpt,image_low), each payload < 10KB using Brotli compression.
// Example low-bandwidth payload structure
{
"id": "acc-2026-101",
"t": "BoG Policy Rate Holds at 29%",
"e": "Central bank cites...",
"img": "/cdn/bog_400w.webp",
"ts": 1728000000
}This approach, paired with lazy-loaded WebP images, keeps initial article load < 45KB total.
Case Study 3: Community Finance (Susu) & Voucher Apps – Low-Bandwidth Verification
Susu collectors and NGO voucher distributors need cryptographic proof without live verification.
PWA Solution: Client-side signature generation. The PWA generates a QR code using jws with a secret stored in IndexedDB, containing collector ID, amount, timestamp. The recipient app can verify offline using the public key cached in its Service Worker. No need for real-time Bank of Ghana or aggregator API call. Sync happens later when online. This satisfies DPC principles of data minimization.
4. Technical Implementation: Production-Ready Service Worker
This is a complete, hardened sw.js built for West African network conditions. It handles versioned caching, offline fallback, and dynamic API caching with timeout protection.
// sw.js - Production PWA Service Worker for Low-Bandwidth Environments
const CACHE_VERSION = 'v2.4.0';
const SHELL_CACHE = `shell-${CACHE_VERSION}`;
const DATA_CACHE = `data-${CACHE_VERSION}`;
const IMAGE_CACHE = `images-${CACHE_VERSION}`;
const APP_SHELL_URLS = [
'/',
'/offline.html',
'/css/app.min.css',
'/js/app.min.js',
'/icons/icon-192x192.png'
];
const OFFLINE_FALLBACK = '/offline.html';
// Install: Pre-cache App Shell
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(SHELL_CACHE).then((cache) => {
return cache.addAll(APP_SHELL_URLS);
}).then(() => self.skipWaiting())
);
});
// Activate: Clean up old caches
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) => {
return Promise.all(
keys.map((key) => {
if (!key.includes(CACHE_VERSION)) {
return caches.delete(key);
}
})
);
}).then(() => self.clients.claim())
);
});
// Fetch: Intelligent Routing
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// 1. Immutable Assets: Cache First
if (request.destination === 'image' || url.pathname.includes('/icons/')) {
event.respondWith(cacheFirst(request, IMAGE_CACHE));
return;
}
// 2. API Calls: Network First with Timeout + Cache Fallback
if (url.pathname.startsWith('/api/')) {
event.respondWith(networkFirstWithTimeout(request, DATA_CACHE, 3000));
return;
}
// 3. Navigation & App Shell: Stale-While-Revalidate
if (request.mode === 'navigate') {
event.respondWith(staleWhileRevalidate(request, SHELL_CACHE));
return;
}
event.respondWith(staleWhileRevalidate(request, SHELL_CACHE));
});
async function cacheFirst(request, cacheName) {
const cached = await caches.match(request);
if (cached) return cached;
try {
const response = await fetch(request);
const cache = await caches.open(cacheName);
cache.put(request, response.clone());
return response;
} catch (err) {
return caches.match(OFFLINE_FALLBACK);
}
}
async function networkFirstWithTimeout(request, cacheName, timeoutMs) {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
const networkResponse = await fetch(request, { signal: controller.signal });
clearTimeout(timeout);
const cache = await caches.open(cacheName);
cache.put(request, networkResponse.clone());
return networkResponse;
} catch (err) {
const cached = await caches.match(request);
if (cached) return cached;
return new Response(JSON.stringify({ offline: true, queued: true }), {
headers: { 'Content-Type': 'application/json' },
status: 503
});
}
}
async function staleWhileRevalidate(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
const fetchPromise = fetch(request).then((networkResponse) => {
cache.put(request, networkResponse.clone());
return networkResponse;
}).catch(() => cached || cache.match(OFFLINE_FALLBACK));
return cached || fetchPromise;
}
// Background Sync for Order Queue
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-orders') {
event.waitUntil(syncOrderQueue());
}
});
async function syncOrderQueue() {
// Read from IndexedDB and replay failed POSTs
const db = await openOrdersDB();
const orders = await db.getAll('orders-queue');
for (const order of orders) {
try {
await fetch('/api/orders', {
method: 'POST',
body: JSON.stringify(order),
headers: { 'Content-Type': 'application/json' }
});
await db.delete('orders-queue', order.id);
} catch (e) {
// Keep in queue for next sync attempt
}
}
}Hardening Notes: AbortController with 3000ms timeout prevents requests from hanging during network handoffs. Separate caches allows granular eviction. skipWaiting() and clients.claim() ensure immediate activation.
Registration in your main app:
// app.js
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js', { scope: '/' })
.then(reg => console.log('SW registered:', reg.scope))
.catch(err => console.error('SW failed:', err));
});
}5. Strategic Roadmap: Why Founders Should Default to PWA
Business Efficiencies
1. Unified Codebase Efficiency: One team, one codebase, deploying to Android, iOS, and desktop. For a seed-stage startup in Accra, this cuts engineering headcount and Dev Cost by 50-60%.
2. Eliminating Gatekeeper Taxes: No Play Store 15-30% cut on digital goods if you use MTN MoMo or Paystack direct. No 2-3 day review cycles when you need to push a critical fix for a GRA E-Levy calculation change.
3. Data-Conscious User Acquisition: Your PWA install is <350KB. Your Cost Per Acquired User (CPA) drops because you remove the two biggest objections: data cost and storage.
Native Android vs. PWA: Executive Comparison
| METRIC | NATIVE ANDROID (APK) | PROGRESSIVE WEB APP (PWA) |
|---|---|---|
| Payload Size | 25MB – 80MB APK + 100MB+ data after install | 150KB – 400KB install + 0.5MB-2MB cached shell |
| Store Friction | Play Store listing, review, policy compliance | No store. Link-based. Add to Home Screen |
| Update Speed | User must accept. 40-60% stay on old versions | Instant. 95%+ on latest in 24h |
| Dev Cost | 2-3 teams. Est. $60k-120k MVP | 1 team. Est. $25k-45k MVP |
| Offline Mode | Requires custom SQLite/Room logic | Native via CacheStorage + IndexedDB + BackgroundSync |
| Payment Rails | Play Billing (15% fee) | Direct MoMo, Telecel Cash, Card, Bank – 0% tax |
Conclusion & Actionable Takeaways
The next 10 million digital consumers in Ghana and Nigeria will not come from high-end devices on fibre. They will come from $85 Android phones on shared WiFi and 100MB bundles. The architecture you choose today determines if you can serve them.
1. Adopt a PWA-First Development Doctrine. Build your web app as a PWA from day one. Your Service Worker is not an enhancement; it is your data-layer infrastructure. Budget 20% of your frontend sprint for offline states, cache invalidation, and sync queues.
2. Measure What Matters Locally. Stop optimizing for Lighthouse scores in California. Track Time-to-Interactive on 3G, Data Saved via Cache (KB), and Offline Queue Success Rate.
3. Align with Regulatory Realities. A PWA that minimizes data transfer is inherently more compliant with DPC data minimization and CSA secure-by-design principles. Keep financial verification (MoMo transaction IDs) server-side, keep PII encrypted in IndexedDB, and document your caching policy for audits.
4. Educate for Installation. Do not trigger beforeinstallprompt on first visit. Trigger it after the user has experienced value – after adding to cart, reading 2 articles, or completing a Susu contribution. Show a custom UI explaining: “Installs in 2 seconds, uses no extra data, works offline.” That single line converts.
Native apps will always have a place for hardware-intensive use cases – AR, high-performance gaming, Bluetooth peripherals. But for commerce, media, finance, and community platforms – the engines of West Africa’s digital GDP – the Progressive Web App is no longer the fallback.
It is the blueprint.
Reach. Build. Grow.
Comments