Flying Fox Project · Web Apps

Website today.
Web app for life.

A practical path from an ordinary website to a Progressive Web App (PWA): give it an app identity, design for unreliable connections, and keep it fresh as the website evolves.

HTTPSWeb app manifestService workerOffline experience
One websiteProgressively enhanced for app use
Three diagramsBuild · Requests · Updates
01 · Foundations

What makes it a PWA?

BASE

A usable website

Start with readable content, responsive layouts, accessible controls and working URLs. Visitors should still be able to use the site in a browser.

IDENTITY

An installable experience

A manifest describes the name, icons, launch URL and display mode. Serve over HTTPS; localhost is suitable for development. Installation behaviour varies across browsers.

RESILIENCE

Deliberate offline support

A service worker can serve saved resources when the network fails. Installation alone does not make a site work offline; these are separate capabilities.

Reference: MDN · Making PWAs installable.

02 · Build roadmap

From page to app

flowchart TD
    accTitle: Website to PWA build roadmap
    accDescr: Improve the website, serve securely, add identity, choose offline behaviour, then test and release.
    A["Existing website"] --> B["Check mobile layout, accessibility and links"]
    B --> C["Serve over HTTPS"]
    C --> D["Add manifest and app icons"]
    D --> E["Link manifest from app pages"]
    E --> F["Define offline content and fallback"]
    F --> G["Register a scoped service worker"]
    G --> H["Test installation, offline use and updates"]
    H --> I{"Checks pass?"}
    I -- No --> F
    I -- Yes --> J["Release and maintain"]
Build in small stages. For a first experiment, use a dedicated app folder so its service worker has a narrow default scope.
View Mermaid source
flowchart TD
    accTitle: Website to PWA build roadmap
    accDescr: Improve the website, serve securely, add identity, choose offline behaviour, then test and release.
    A["Existing website"] --> B["Check mobile layout, accessibility and links"]
    B --> C["Serve over HTTPS"]
    C --> D["Add manifest and app icons"]
    D --> E["Link manifest from app pages"]
    E --> F["Define offline content and fallback"]
    F --> G["Register a scoped service worker"]
    G --> H["Test installation, offline use and updates"]
    H --> I{"Checks pass?"}
    I -- No --> F
    I -- Yes --> J["Release and maintain"]
03 · Starter files

Give the app a home

Example implementation plan: create a separate field-notes/ folder. The snippets below are learning examples; this guide itself does not register a service worker.

Proposed folder structure
field-notes/
  index.html             # App entry page
  manifest.webmanifest   # App identity
  sw.js                  # Offline request handling
  offline.html           # Self-contained offline message
  icons/
    icon-192.png         # Actual 192 × 192 PNG
    icon-512.png         # Actual 512 × 512 PNG
manifest.webmanifest
{
  "id": "./",
  "name": "Flying Fox Field Notes",
  "short_name": "Field Notes",
  "start_url": "./index.html",
  "scope": "./",
  "display": "standalone",
  "background_color": "#161618",
  "theme_color": "#161618",
  "icons": [
    { "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
  ]
}

Keep these relative URLs under the same app folder, including when hosting under a repository subpath. Supply actual icon files at the declared sizes. Link the manifest in every page belonging to the app.

App HTML · inside the head
<link rel="manifest" href="./manifest.webmanifest">
<meta name="theme-color" content="#161618">
App HTML · before the closing body tag
<script>
if ("serviceWorker" in navigator) {
  window.addEventListener("load", () => {
    navigator.serviceWorker.register("./sw.js")
      .catch(error => console.error("Service worker registration failed:", error));
  });
}
</script>

The manifest scope describes app navigation; service worker scope controls which pages it can intercept. They are separate settings. See MDN · Using service workers.

04 · Offline design

Plan for the missing connection

flowchart TD
    accTitle: Network-first navigation with an offline fallback
    accDescr: An active worker attempts an online page request. On network failure it returns a precached offline page.
    A["Navigation in a controlled app page"] --> B["Active service worker"]
    B --> C["Try the network"]
    C --> D{"Network response received?"}
    D -- Yes --> E["Return server response"]
    D -- No --> F["Read precached offline.html"]
    F --> G["Show offline message"]
A minimal fallback keeps a failed navigation understandable. It does not make every page, image or feature available offline.
View Mermaid source
flowchart TD
    accTitle: Network-first navigation with an offline fallback
    accDescr: An active worker attempts an online page request. On network failure it returns a precached offline page.
    A["Navigation in a controlled app page"] --> B["Active service worker"]
    B --> C["Try the network"]
    C --> D{"Network response received?"}
    D -- Yes --> E["Return server response"]
    D -- No --> F["Read precached offline.html"]
    F --> G["Show offline message"]
sw.js · minimal navigation fallback
const CACHE = "ff-field-notes-offline-v1";
const OFFLINE = new URL("./offline.html", self.location).href;

self.addEventListener("install", event => {
  event.waitUntil(
    caches.open(CACHE).then(cache => cache.add(OFFLINE))
  );
});

self.addEventListener("activate", event => {
  event.waitUntil(
    caches.keys().then(keys => Promise.all(
      keys.filter(key => key.startsWith("ff-field-notes-offline-") && key !== CACHE)
        .map(key => caches.delete(key))
    ))
  );
});

self.addEventListener("fetch", event => {
  if (event.request.mode !== "navigate" || event.request.method !== "GET") return;
  event.respondWith(
    fetch(event.request).catch(async () => {
      const cache = await caches.open(CACHE);
      return (await cache.match(OFFLINE)) || new Response(
        "You are offline. Reconnect and try again.",
        { status: 503, headers: { "Content-Type": "text/plain; charset=utf-8" } }
      );
    })
  );
});

Create offline.html with inline styles and a clear reconnect message so it needs no external assets. Load the app online first, wait for worker activation, then reload to enter worker control before testing offline. A server error such as HTTP 500 is still a network response; handle that separately if needed.

Choose a caching strategy per resource
ResourceUseful starting strategyTradeoff
HTML / changing contentNetwork first, with a cached fallbackFresh online; consider a timeout for slow networks.
Versioned CSS, JS, iconsCache firstFast; change asset URLs when their contents change.
Public, frequently reused contentStale while revalidateFast cached result; refresh in the background.
Private data / submissionsExplicit application policyAvoid generic caching; define logout cleanup and retry behaviour.

Reference: MDN · Caching strategies. Offline diagrams and third-party fonts also need a deliberate asset-caching plan.

05 · App lifecycle

Ship the next version carefully

flowchart TD
    accTitle: Service worker update lifecycle
    accDescr: A changed worker installs and waits while the previous worker controls pages, then activates and removes old app caches.
    A["Browser detects changed sw.js"] --> B["Install new worker and prepare cache"]
    B --> C{"Installation succeeds?"}
    C -- No --> D["Existing worker remains active"]
    C -- Yes --> E["Wait while old worker controls pages"]
    E --> F["Old controlled tabs close"]
    F --> G["Activate new worker"]
    G --> H["Remove this app's old caches"]
    H --> I["Next app visit uses new worker"]
Default lifecycle shown. An explicit update action can activate sooner, but coordinate reloads to avoid mixing old pages with new assets.
View Mermaid source
flowchart TD
    accTitle: Service worker update lifecycle
    accDescr: A changed worker installs and waits while the previous worker controls pages, then activates and removes old app caches.
    A["Browser detects changed sw.js"] --> B["Install new worker and prepare cache"]
    B --> C{"Installation succeeds?"}
    C -- No --> D["Existing worker remains active"]
    C -- Yes --> E["Wait while old worker controls pages"]
    E --> F["Old controlled tabs close"]
    F --> G["Activate new worker"]
    G --> H["Remove this app's old caches"]
    H --> I["Next app visit uses new worker"]

For the example, increment the cache version when changing the fallback. Keep the worker URL stable, let browsers revalidate it, and remove only caches owned by this app. Test an update with two tabs open before releasing it.

Reference: MDN · Installation and updates.

06 · Test & release

Prove the experience

1

Inspect the basics

Use HTTPS or localhost. Check the browser’s Application tools for manifest errors, icon loading, start URL and worker scope.

2

Install on devices

Verify the launch icon, standalone window and navigation. Installation controls vary; iOS offers Add to Home Screen through the Share menu.

3

Go offline

After worker control, disable the network and navigate. Confirm the fallback appears. Also test a first visit with no cache and a slow connection.

4

Upgrade & recover

Deploy a changed worker, test the waiting state, close app tabs and reopen. Verify cleanup and recovery after clearing site data.

Use keyboard navigation and a narrow mobile viewport as part of the same review. Treat offline storage as disposable: browsers or users may clear it. Add an in-page install button only when the browser exposes a supported installation flow.

Installation reference: MDN · Browser installation behaviour.