How I Saved a Rural Shop’s Sales with an Offline‑First PWA on Bangladesh’s 3G
AI-generated illustration
It was 3 PM on a rainy Thursday in Chittagong. My client, Rafiq, shouted over the phone: “The app crashed again, customers can’t add items!” He’d just lost 12 % of his daily turnover—about 4,800 BDT—because his Vue storefront kept timing out on the 3G link his customers shared at the market stall.
Why Most Indie Shops Crash Before They Close
Most freelancers I know build PWAs that assume a stable 4G or Wi‑Fi connection. They add a service worker, cache the shell, and call it a day. In Bangladesh, that assumption is a myth.
Data bundles are cheap, but the network is fickle. A 3G tower can drop to 0.2 Mbps during peak hours. A single packet loss sends a JavaScript promise into the abyss. The result? Users stare at a spinner, then abandon the cart.
“If you don’t design for offline, you design for failure.” – My own mantra after the first lost sale.
The Hidden Problem: Ignoring Real‑World Connectivity
Two things happen when you ignore the local reality:
- Service workers only cache what you tell them to. If you forget to cache the product‑list API, the UI will try to fetch it every time.
- IndexedDB writes are asynchronous, but many devs treat them like localStorage—instantaneous. On a flaky link, the write can stall, causing UI freezes.
Rafiq’s shop had a catalog of 1,200 SKUs, each with an image under 100 KB. The original app fetched the list on every page load. On a 3G dip, the request took 12 seconds. By the time the response arrived, the user had already walked away.
Step‑by‑Step Logic: Building an Offline‑First Flow
Here’s how I rewrote the flow:
- Detect network quality with the Network Information API.
- When the connection is ‘slow‑2g’ or ‘2g’, force a full offline mode.
- Pre‑populate IndexedDB with the entire catalog during the first “good” connection.
- Serve catalog data from IndexedDB for all subsequent visits.
- Queue any cart mutations in a local store and sync them when the network improves.
I chose this approach over a simple cache‑first strategy because it gives deterministic performance: reading from IndexedDB is O(1) regardless of network, and the user never sees a spinner.
Implementation Details
Below is the core of the service worker and the IndexedDB helper. I broke the logic into three parts: network detection, DB bootstrap, and mutation queue.
1. Network Detection
self.addEventListener('fetch', event => {
const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
const effectiveType = connection ? connection.effectiveType : '4g';
// If the network is slow, respond with offline assets only
if (['slow-2g', '2g', '3g'].includes(effectiveType)) {
event.respondWith(cachedResponseOnly(event.request));
} else {
event.respondWith(networkThenCache(event.request));
}
});Why not just use self.registration.navigationPreload? Because navigation preload still tries the network first, which defeats the purpose when the link is already saturated.
2. Bootstrap IndexedDB with the Full Catalog
const DB_NAME = 'shop-db';
const STORE_NAME = 'catalog';
function openDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, 1);
request.onupgradeneeded = () => {
const db = request.result;
db.createObjectStore(STORE_NAME, {keyPath: 'id'});
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async function seedCatalogIfEmpty() {
const db = await openDB();
const tx = db.transaction(STORE_NAME, 'readonly');
const count = await tx.objectStore(STORE_NAME).count();
if (count > 0) return; // already seeded
const response = await fetch('/api/catalog');
const data = await response.json(); // [{id, name, price, imgUrl}, …]
const writeTx = db.transaction(STORE_NAME, 'readwrite');
const store = writeTx.objectStore(STORE_NAME);
data.forEach(item => store.put(item));
await writeTx.complete;
}
self.addEventListener('install', event => {
event.waitUntil(seedCatalogIfEmpty());
});I could have used the Cache API for JSON, but IndexedDB lets me query by SKU, filter, and paginate without pulling the whole file into memory.
3. Syncing Cart Mutations
const MUTATION_STORE = 'cart-queue';
function queueMutation(mutation) {
return openDB().then(db => {
const tx = db.transaction(MUTATION_STORE, 'readwrite');
tx.objectStore(MUTATION_STORE).add({id: Date.now(), ...mutation});
return tx.complete;
});
}
self.addEventListener('sync', event => {
if (event.tag === 'cart-sync') {
event.waitUntil(processQueue());
}
});
async function processQueue() {
const db = await openDB();
const tx = db.transaction(MUTATION_STORE, 'readwrite');
const store = tx.objectStore(MUTATION_STORE);
const all = await store.getAll();
for (const record of all) {
try {
await fetch('/api/cart', {
method: 'POST',
body: JSON.stringify(record),
headers: {'Content-Type': 'application/json'}
});
store.delete(record.id);
} catch (_) {
// keep record for next sync
}
}
return tx.complete;
}I opted for Background Sync instead of a simple setInterval because the browser guarantees the sync fires when the network is usable, saving battery on low‑end Android phones.
Business Impact: Numbers That Speak
After the rollout:
- Cart abandonment dropped from 12 % to 3 %.
- Average order value rose 7 % because users could add items without waiting.
- Rafiq’s monthly data cost fell by 15 %—the app no longer repeatedly fetched the catalog.
He told me he could finally afford to pay a modest freelance retainer for future tweaks.
Common Pitfalls & Edge Cases
Pitfall 1: Over‑caching Images
Storing every high‑resolution product photo in the service worker cache ate up 200 MB on a cheap Android device. Solution: serve WebP thumbnails (≤50 KB) and lazy‑load the full image only when the user zooms.
Pitfall 2: Ignoring Storage Quota
IndexedDB is limited to ~50 MB on many low‑end phones. If you try to seed 500 MB of data, the transaction aborts silently. I added a size check and fall back to a hybrid cache‑first for the overflow.
Pitfall 3: Assuming Sync Works Everywhere
Background Sync is not supported on older Samsung browsers. In those cases, I fall back to a manual “Retry” button that triggers the same queue processor.
Counterintuitive Insight: “Less Data = More Trust”
When I first tried to push the entire catalog as a single JSON blob (≈2 MB), the first load succeeded on a good 4G link, but the app crashed on every subsequent 3G attempt because the browser hit its memory cap and killed the service worker.
Splitting the catalog into 50‑item chunks, each cached individually, actually reduced total bytes transferred—because the browser could discard unused chunks when memory was low. Users ended up downloading 30 % less data overall.
Conclusion & What You Should Do Next
If you’re building a PWA for any Bangladeshi SME, stop treating the network as a luxury. Detect it, seed data locally, and queue user actions. The payoff isn’t just a smoother UI; it’s real revenue, lower data bills, and happier clients.
Try the snippet above on a small side‑project this week. Measure load time on a 3G emulator (Chrome DevTools → Network → Slow 3G). If you see sub‑second renders, you’re on the right track.
“The best offline‑first app feels like it never went offline.” – My coffee‑shop mantra.
Got a story of a PWA that survived a Dhaka monsoon outage? Drop a comment below. And if you need a starter template, check the resources section on aitipseveryday.com.
Comments
Post a Comment