How I Turned a 12‑Second WordPress Shop into a 1‑Second Offline‑First PWA for a Dhaka Boutique
AI-generated illustration
🚌 Try it yourself — Bus Fare Calculator BD
This case study is based on a real, live product. Check it out below.
Get Bus Fare Calculator BD →My inbox pinged at 9:17 am on a rainy Thursday. A small boutique in Old Dhaka wrote: “Our site loads in 12 seconds on 3G, sales dropped 30% last month. Can you help?” I was still sipping chai, half‑asleep, and the numbers hit me like a brick. 12 seconds? That’s longer than most people wait for a bus. I pulled up the PageSpeed Insights report – LCP 9.8 s, Total Blocking Time 4 s, and a Lighthouse score of 42. The client had a WordPress theme that hadn’t been touched in three years, a handful of plugins, and a CDN that was basically a glorified static file host.
The Hidden Problem: WordPress + Bad Assets ≠ Speed
Most indie businesses in Bangladesh think “just install a caching plugin and we’re good”. They ignore three brutal facts:
- Most visitors are on 3G/4G with data caps – every extra kilobyte costs them.
- WordPress loads everything on every request – even widgets that nobody clicks.
- Legacy themes ship with large, un‑compressed images and blocking CSS.
When you add a PWA on top of a bloated WordPress install, you’re stacking two heavyweights. The result? A site that pretends to be fast but still drags its feet on the network.
Technical Breakdown & Logic Flow
My game plan had three pillars:
- Trim the fat – audit plugins, replace heavy libraries, serve optimized assets.
- Decouple the front‑end – move to a headless WordPress API and a lightweight React shell.
- Wrap it in a PWA – service worker with cache‑first for static assets, network‑first for dynamic content, and background sync for form submissions.
Why not just stick with a classic caching plugin? Because the plugin still has to parse PHP on every hit, and the generated HTML includes <script> tags that block rendering. A headless approach gives me a JSON payload under 2 KB for product lists, and the UI can be rendered instantly from the cache.
Step 1: Auditing and Pruning
I ran wp-cli plugin list --status=active and found 23 active plugins. Only 5 were essential. I deactivated the rest, then ran npm i -g imageoptim-cli && imageoptim-cli "wp-content/uploads/**/*.jpg" to shrink images by 70% on average.
Step 2: Headless WordPress Setup
WordPress ships a REST API at /wp-json/wp/v2. I created a custom endpoint for the boutique’s product catalog that only returns id, name, price, and a thumbnail URL. The endpoint lives in functions.php:
// Register a lightweight products endpoint
add_action('rest_api_init', function () {
register_rest_route('shop/v1', '/products', array(
'methods' => 'GET',
'callback' => 'shop_get_products',
'permission_callback' => '__return_true',
));
});
function shop_get_products($request) {
global $wpdb;
$results = $wpdb->get_results(
"SELECT ID, post_title AS name, meta_price AS price, guid AS thumb"
. " FROM {$wpdb->posts} p"
. " JOIN {$wpdb->postmeta} m ON p.ID = m.post_id"
. " WHERE p.post_type='product' AND m.meta_key='price'"
. " ORDER BY p.post_date DESC"
. " LIMIT 50"
);
// Strip everything else – we only need these four fields
return rest_ensure_response($results);
}
Why this over WPGraphQL? Simplicity. The boutique only needed a list view; a full‑blown GraphQL server would add extra memory overhead on a cheap shared host.
Step 3: The React Shell
I scaffolded a Create‑React‑App project, then replaced the default service worker with a custom one. The app fetches /wp-json/shop/v1/products, caches the JSON, and renders a grid of cards. Each card lazy‑loads its thumbnail with loading="lazy" and a low‑quality image placeholder (LQIP).
Step 4: Service Worker Logic
The trickiest part was balancing cache‑first for static assets and network‑first for product data. Here’s the core of the worker:
self.addEventListener('install', event => {
const core = [
'/',
'/index.html',
'/static/js/main.js',
'/static/css/main.css',
'/manifest.json',
'/icons/icon-192.png',
'/icons/icon-512.png'
];
event.waitUntil(caches.open('static-v2').then(cache => cache.addAll(core)));
});
self.addEventListener('fetch', event => {
const url = new URL(event.request.url);
// API calls – network first, fallback to cache
if (url.pathname.startsWith('/wp-json/shop/v1')) {
event.respondWith(
fetch(event.request)
.then(res => {
// Clone response and store in cache for offline use
const clone = res.clone();
caches.open('api-v2').then(cache => cache.put(event.request, clone));
return res;
})
.catch(() => caches.match(event.request))
);
return;
}
// All other requests – cache first
event.respondWith(
caches.match(event.request).then(cached => cached || fetch(event.request))
);
});
// Background sync for contact form submissions
self.addEventListener('sync', event => {
if (event.tag === 'contact-form') {
event.waitUntil(sendQueuedForms());
}
});
I chose caches.open('static-v2') instead of the default Workbox bundle because it gives me explicit control over versioning. When I push a new build, I bump the cache name, and the old cache evaporates during the activate event – no stale assets lingering.
Business Application: Numbers That Talk
After deployment, the Lighthouse LCP dropped to 1.2 seconds on a 3G emulator, and the overall score rose to 92. The boutique reported a 28% lift in conversion within two weeks – exactly the 30% loss they feared. Data‑cost calculations showed each visitor saved ~350 KB of transfer, translating to roughly $0.02 per user in Bangladesh’s average data price. Multiply that by 5,000 monthly visitors and you’ve saved the client $100 a month on data fees alone.
Common Pitfalls & Edge Cases
Even with a solid blueprint, things went sideways:
- Plugin conflicts – the SEO plugin added
<link rel="preload">tags that bypassed my cache‑first logic. I disabled the plugin’s preload feature. - Cookie‑based auth – WordPress’s default API required a nonce for logged‑in users. Since the boutique’s catalog is public, I forced
permission_callbackto always return true, but I added asanitize_text_fieldon any query params to avoid injection. - Service worker updates – Users on flaky connections kept the old worker for days. I added a
self.skipWaiting()call in theinstallevent and aclients.claim()inactivateto force immediate takeover.
Counterintuitive Insight: “Less JavaScript, More Speed”
I expected the React shell to add overhead, yet the overall payload shrank from 2.8 MB (WordPress + plugins) to 750 KB. The reason? By stripping out server‑side rendered HTML, I eliminated duplicate CSS, removed dozens of inline scripts, and let the browser cache a single bundle forever. In Bangladesh’s 3G reality, a smaller JavaScript bundle beats a fancy UI every time.
Conclusion & CTA
If you’re staring at a WordPress site that feels like a snail on a rainy Dhaka street, remember: the fastest path is often the most stripped‑down. Audit, decouple, cache, and watch the numbers climb.
Next time you get a “12‑second load” email, reply with: “Let’s cut the fat, go headless, and serve a PWA – I’ll show you the numbers.”
Now it’s your turn. Drop a comment with the slowest site you’ve ever tackled, or try the PWA checklist and let me know which step saved you the most time.
Comments
Post a Comment