Adding PWA Support to Your Flutter Web App

Progressive Web Apps have become a powerful way to deliver app-like experiences through the browser. When I decided to add PWA support to my Atlantis Kid developer site, I discovered that the process involves more nuance than most tutorials cover. Here’s a detailed walkthrough of how I implemented PWA support, the decisions I made along the way, and the gotchas I encountered.

What Are PWAs and Why Should You Care?

A Progressive Web App is a web application that uses modern browser APIs to deliver an experience that feels like a native app. The key features include:

  • Installability: Users can add the app to their home screen
  • Offline support: The app works without an internet connection (or with a poor one)
  • Push notifications: Engage users even when they’re not actively using the app
  • Fast loading: Service workers cache assets for near-instant load times

For indie developers, PWAs are particularly attractive because they let you reach users across platforms without maintaining separate codebases for web, iOS, and Android. Flutter’s web support makes this even more compelling — you can share code between your mobile app and your PWA.

Flutter Web Build for PWA

Flutter generates PWA-ready scaffolding when you create a web build. Running flutter build web produces a build/web directory that includes a basic manifest.json and a flutter_service_worker.js file.

However, the defaults are minimal. To create a polished PWA experience, you’ll need to customize several components.

# Build your Flutter web app
flutter build web --release

# The output structure
build/web/
├── index.html
├── manifest.json
├── flutter_service_worker.js
├── favicon.png
├── icons/
│   ├── Icon-192.png
│   ├── Icon-512.png
│   └── Icon-maskable-192.png
└── assets/
    └── ...

Configuring the Web App Manifest

The manifest.json file tells the browser how your app should behave when installed. Here’s the configuration I use:

{
  "name": "Atlantis Kid - App Developer",
  "short_name": "Atlantis Kid",
  "description": "Flutter apps and games by Atlantis Kid",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#0a0a0a",
  "theme_color": "#6366f1",
  "orientation": "any",
  "icons": [
    {
      "src": "icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "icons/icon-512x512.png",
      "sizes": "512x512",
      "type": "image/png"
    },
    {
      "src": "icons/icon-maskable-512x512.png",
      "sizes": "512x512",
      "type": "image/png",
      "purpose": "maskable"
    }
  ]
}

A few important decisions in this configuration:

display: "standalone" makes the app look and feel like a native app — no browser chrome, no URL bar. For a content-heavy site, you might prefer "minimal-ui" which keeps a small navigation bar.

background_color should match your app’s initial background to prevent a flash of white during launch. My site uses a dark theme (#0a0a0a), so I set this to match.

theme_color affects the status bar and task switcher color on mobile. I use my brand’s indigo accent (#6366f1).

Maskable icons are essential for Android. They allow the OS to apply its own shape mask (circle, squircle, etc.) to your icon. Without a maskable icon, your app icon may look awkward on some devices. Make sure your maskable icon has adequate padding — the safe zone is the inner 80% of the image.

Service Worker Setup

The service worker is where the real PWA magic happens. It intercepts network requests and serves cached content, enabling offline functionality and faster load times.

Flutter generates a basic service worker, but for a static site like mine, I wrote a custom one with a cache-first strategy:

const CACHE_NAME = 'atlantiskid-cache-v1';
const urlsToCache = [
  '/',
  '/index.html',
  '/css/style.css',
  '/privacy/balloon-pop.html',
  '/manifest.json'
];

// Install event - cache core assets
self.addEventListener('install', (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then((cache) => {
        console.log('Opened cache');
        return cache.addAll(urlsToCache);
      })
  );
  // Activate immediately without waiting
  self.skipWaiting();
});

// Activate event - clean up old caches
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames.map((cacheName) => {
          if (cacheName !== CACHE_NAME) {
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
  // Take control of all pages immediately
  self.clients.claim();
});

// Fetch event - serve from cache, fall back to network
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then((response) => {
        if (response) {
          return response;
        }
        return fetch(event.request).then((response) => {
          // Cache new requests dynamically
          if (response.status === 200) {
            const responseClone = response.clone();
            caches.open(CACHE_NAME)
              .then((cache) => {
                cache.put(event.request, responseClone);
              });
          }
          return response;
        });
      })
  );
});

Choosing a Caching Strategy

There are several caching strategies, and the right one depends on your content type:

  • Cache First: Serve from cache, fall back to network. Best for static assets that rarely change (CSS, images, fonts).
  • Network First: Try network, fall back to cache. Best for dynamic content that should be fresh when possible.
  • Stale While Revalidate: Serve from cache immediately, then update the cache from the network in the background. Good balance of speed and freshness.

For my static developer site, Cache First works well because the content changes infrequently. When I do update the site, I increment the cache version name (v1 to v2) which triggers the activate event and clears the old cache.

Making the App Installable

For a PWA to be installable, it needs to meet several criteria:

  1. Served over HTTPS (Cloudflare Pages handles this automatically)
  2. Has a valid manifest.json with required fields
  3. Has a registered service worker
  4. Meets browser-specific engagement heuristics

Register the service worker in your HTML:

<script>
  if ('serviceWorker' in navigator) {
    window.addEventListener('load', () => {
      navigator.serviceWorker.register('/sw.js')
        .then((registration) => {
          console.log('SW registered:', registration.scope);
        })
        .catch((error) => {
          console.log('SW registration failed:', error);
        });
    });
  }
</script>

You can also add a custom install prompt to guide users:

let deferredPrompt;

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

document.getElementById('install-btn').addEventListener('click', () => {
  deferredPrompt.prompt();
  deferredPrompt.userChoice.then((choiceResult) => {
    if (choiceResult.outcome === 'accepted') {
      console.log('User accepted the install prompt');
    }
    deferredPrompt = null;
  });
});

Implementing Offline Support

Beyond caching assets, true offline support means your app handles the offline state gracefully. For a content site, this means showing cached pages instead of the browser’s default offline error.

Create a simple offline fallback page:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Offline - Atlantis Kid</title>
  <style>
    body {
      font-family: 'Inter', sans-serif;
      display: flex;
      justify-content: center;
      align-items: center;
      min-height: 100vh;
      background: #0a0a0a;
      color: #ffffff;
      text-align: center;
    }
    h1 { color: #6366f1; }
  </style>
</head>
<body>
  <div>
    <h1>You're Offline</h1>
    <p>Please check your internet connection and try again.</p>
  </div>
</body>
</html>

Then update your service worker’s fetch handler to serve this page when both the cache and network fail:

self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
      .then((response) => {
        return response || fetch(event.request);
      })
      .catch(() => {
        // Return offline page for navigation requests
        if (event.request.mode === 'navigate') {
          return caches.match('/offline.html');
        }
      })
  );
});

Testing PWA Features

Testing PWAs requires checking multiple dimensions. Here’s my testing checklist:

Chrome DevTools Application Panel: Check the manifest is parsed correctly, the service worker is registered, and the cache storage contains expected assets.

Lighthouse Audit: Run a Lighthouse audit specifically for the PWA category. It checks installability, offline capability, and best practices.

# Run Lighthouse from the command line
npx lighthouse https://your-site.com --only-categories=pwa --output=html

Device Testing: Install the PWA on actual devices — Android and iOS behave differently. On iOS, PWAs have more limitations (no push notifications, limited background sync).

Offline Testing: Toggle airplane mode in DevTools and verify the app still loads and displays cached content correctly.

Running a Lighthouse Audit

Lighthouse is the gold standard for PWA validation. A perfect PWA score requires:

  • Fast and reliable on mobile networks
  • Installable with proper manifest
  • Optimized for all screen sizes
  • HTTPS everywhere
  • Proper offline fallback

My initial Lighthouse score was 72 for PWA. After implementing proper caching, adding maskable icons, and setting up the offline fallback page, I reached a score of 98. The remaining two points were related to iOS-specific meta tags that I later added:

<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="apple-touch-icon" href="/icons/icon-192x192.png">

Deploying PWA to Cloudflare Pages

Cloudflare Pages is an excellent platform for hosting PWAs. It provides HTTPS by default, has a global CDN for fast loading, and supports custom headers through a _headers file.

One important consideration: set proper cache headers for your service worker file. The service worker should not be aggressively cached by the browser, or updates won’t propagate:

# _headers file
/sw.js
  Cache-Control: no-cache, no-store, must-revalidate

For other static assets, Cloudflare’s default caching works well. The service worker handles application-level caching, while Cloudflare handles CDN-level caching.

Deployment is straightforward — push to your main branch and Cloudflare Pages automatically builds and deploys. No special configuration is needed for PWA support.

Lessons from My Implementation

Adding PWA support to my Atlantis Kid developer site taught me several things:

Start simple. You don’t need every PWA feature from day one. I started with just the manifest and basic service worker, then added offline support and install prompts incrementally.

Cache versioning matters. Forgetting to update the cache version after deploying changes means users see stale content. I now include cache version updates in my deployment checklist.

Test on real devices. The DevTools PWA simulation is helpful but doesn’t catch everything. iOS Safari in particular has its own set of PWA quirks that only surface on actual hardware.

Maskable icons are not optional. On Android, a non-maskable icon looks out of place next to other app icons. It’s worth the extra effort to create proper maskable variants.

Service worker debugging can be tricky. When things go wrong with caching, use Chrome’s chrome://serviceworker-internals/ to inspect and unregister service workers during development.

PWA support is one of those features that requires relatively little effort but significantly improves the user experience. For indie developers looking to maximize their web presence, it’s a worthwhile investment that pays dividends every time a user installs your app from the browser.