DEV Community

Marcc Atayde
Marcc Atayde

Posted on

PWA Architecture Decisions That Actually Matter in Production: Manifests, Workbox Strategies, and Push Notifications Done Right

Most PWA tutorials get you to a Lighthouse score of 100 and call it a day. That's fine for a demo, but production is different. Users install your app, kill it, come back three days later expecting fresh data, and get a stale cache from 2023. Push notifications don't arrive. The install prompt never fires. The offline page is a blank white screen.

This article is about the decisions that matter after you've read the intro docs — the ones you only encounter when real users are actually using the thing.

The Manifest Is More Than a Checklist

Every PWA guide tells you to create a manifest.json. Few tell you that the display mode choice has real UX consequences.

{
  "name": "My App",
  "short_name": "App",
  "start_url": "/dashboard?source=pwa",
  "display": "standalone",
  "background_color": "#1a1a2e",
  "theme_color": "#0f3460",
  "icons": [
    { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
    { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
  ],
  "screenshots": [
    { "src": "/screenshots/desktop.png", "sizes": "1280x800", "form_factor": "wide" },
    { "src": "/screenshots/mobile.png", "sizes": "390x844", "form_factor": "narrow" }
  ]
}
Enter fullscreen mode Exit fullscreen mode

A few things worth noting here:

  • start_url with a query param lets you track installs in your analytics separately from regular web traffic. ?source=pwa is a simple convention that pays dividends.
  • purpose: "any maskable" on a single icon is laziness that costs you. Android's adaptive icons will crop aggressively. Use a proper maskable icon with safe-zone padding, and list it separately.
  • screenshots are not cosmetic fluff. Chrome on Android uses them to render a richer install bottom sheet. Include them.

Service Worker Caching: Pick the Right Strategy Per Resource Type

Workbox is the correct tool here. Writing raw service worker cache logic by hand is how you end up with race conditions that only manifest on slow 3G connections during a demo.

Install Workbox via the CLI or as a build plugin. Here's a realistic sw.js configuration for a Laravel-backed app:

import { registerRoute } from 'workbox-routing';
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { precacheAndRoute } from 'workbox-precaching';

// Precache compiled assets (Vite injects __WB_MANIFEST)
precacheAndRoute(self.__WB_MANIFEST);

// HTML pages: NetworkFirst so users always get fresh content if online
registerRoute(
  ({ request }) => request.destination === 'document',
  new NetworkFirst({
    cacheName: 'pages',
    plugins: [
      new ExpirationPlugin({ maxEntries: 30, maxAgeSeconds: 86400 })
    ]
  })
);

// API responses: StaleWhileRevalidate for dashboard data
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new StaleWhileRevalidate({
    cacheName: 'api-cache',
    plugins: [
      new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 300 })
    ]
  })
);

// Images: CacheFirst, long TTL
registerRoute(
  ({ request }) => request.destination === 'image',
  new CacheFirst({
    cacheName: 'images',
    plugins: [
      new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 2592000 })
    ]
  })
);
Enter fullscreen mode Exit fullscreen mode

The mental model: documents need freshness, API data needs speed with background updates, static assets need longevity. Mix these up and you'll get either stale UIs or waterfall loading on every page visit.

Handling Cache Invalidation on Deploy

This is where most teams hit problems. You deploy a new version, the service worker updates, but users are still seeing the old UI because the old service worker is controlling existing tabs.

Force the update:

// In your main JS file
if ('serviceWorker' in navigator) {
  const registration = await navigator.serviceWorker.register('/sw.js');

  registration.addEventListener('updatefound', () => {
    const newWorker = registration.installing;
    newWorker.addEventListener('statechange', () => {
      if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
        // New version is ready — prompt the user
        showUpdateBanner();
      }
    });
  });
}

function showUpdateBanner() {
  const banner = document.createElement('div');
  banner.innerHTML = `
    <div style="position:fixed;bottom:0;left:0;right:0;background:#1e40af;color:white;padding:12px;display:flex;justify-content:space-between;align-items:center;z-index:9999">
      <span>A new version is available.</span>
      <button onclick="window.location.reload()">Refresh</button>
    </div>
  `;
  document.body.appendChild(banner);
}
Enter fullscreen mode Exit fullscreen mode

Don't silently reload. That's jarring when a user is mid-form. The banner pattern respects their context.

Push Notifications: The Server-Side Is Where It Gets Real

The client-side subscription flow is documented everywhere. The server-side part — actually sending notifications reliably — is where implementations fall apart.

In Laravel, use the web-push library from minishlink:

composer require minishlink/web-push
Enter fullscreen mode Exit fullscreen mode
use Minishlink\WebPush\WebPush;
use Minishlink\WebPush\Subscription;

class SendPushNotification
{
    public function handle(User $user, string $message): void
    {
        $auth = [
            'VAPID' => [
                'subject' => 'mailto:hello@example.com',
                'publicKey' => config('services.vapid.public_key'),
                'privateKey' => config('services.vapid.private_key'),
            ]
        ];

        $webPush = new WebPush($auth);

        foreach ($user->pushSubscriptions as $sub) {
            $webPush->queueNotification(
                Subscription::create([
                    'endpoint' => $sub->endpoint,
                    'keys' => [
                        'p256dh' => $sub->p256dh,
                        'auth' => $sub->auth,
                    ]
                ]),
                json_encode(['title' => 'Update', 'body' => $message])
            );
        }

        foreach ($webPush->flush() as $report) {
            if ($report->isSubscriptionExpired()) {
                // Clean up stale subscriptions from DB
                $user->pushSubscriptions()
                     ->where('endpoint', $report->getEndpoint())
                     ->delete();
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The isSubscriptionExpired() check is critical. Without it, your push subscription table fills with dead endpoints and your notification delivery rate quietly collapses over months.

The Install Prompt: Don't Fight the Browser

The A2HS (Add to Home Screen) prompt is gated behind browser heuristics: engagement time, return visits, HTTPS. You cannot force it. What you can do is capture the beforeinstallprompt event and defer it until a moment that makes sense in your UX flow.

let deferredPrompt;

window.addEventListener('beforeinstallprompt', (e) => {
  e.preventDefault();
  deferredPrompt = e;
  // Show your own install button/banner now
  document.getElementById('install-btn').style.display = 'block';
});

document.getElementById('install-btn').addEventListener('click', async () => {
  if (!deferredPrompt) return;
  deferredPrompt.prompt();
  const { outcome } = await deferredPrompt.userChoice;
  console.log(`Install outcome: ${outcome}`);
  deferredPrompt = null;
});
Enter fullscreen mode Exit fullscreen mode

Trigger it after a meaningful user action — completing a form, reaching a dashboard — not the moment they land on the page.

Content Strategy Matters Too

Building a great PWA is only half the equation. The other half is making sure people actually find it. Teams at HanzWeb working on content and SEO strategy for web apps often find that PWAs get overlooked in organic search not because of technical issues, but because the discoverability work was never done — metadata, indexability of app shell routes, and structured data all need deliberate attention.

Conclusion

PWAs in production are less about the checklist and more about the edge cases: stale caches after deploy, push endpoints that silently expire, install prompts that fire at the wrong moment. The strategies here — Workbox routing by resource type, explicit update banners, server-side subscription hygiene — are the ones that move a PWA from a demo to something users actually trust.

Pick one pain point from your current PWA and fix it this week. The technology is solid. The execution is where the work is.

Top comments (0)