How I Turned a 12‑Second WordPress Shop into a 1‑Second Offline‑First PWA for a Dhaka Boutique

wordpress-to-pwa

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:

  1. Trim the fat – audit plugins, replace heavy libraries, serve optimized assets.
  2. Decouple the front‑end – move to a headless WordPress API and a lightweight React shell.
  3. 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_callback to always return true, but I added a sanitize_text_field on 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 the install event and a clients.claim() in activate to 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

Popular posts from this blog

How to Use Notion to Improve Your Blog: A Step-by-Step Guide 🌱

I Built a BFIU-Compliant AML Detection System in Python (Here's Why the Kaggle Approach Doesn't Work)

How to Start Freelancing with AI in 2025 for Beginners