cf.completefrontend
My progress
FUNDAMENTALS · MODULE 14

Progressive web apps

A website that the browser can install as an app: a manifest gives it a name, icons, and its own window, a service worker makes it open without the network, and the result lives on the home screen without an app store.

The problem PWAs solve

Picture a small chain of gyms. Members book classes on the gym's website from their phones. The site works well, but members keep asking for "the app". They want an icon on the home screen, a full-screen view without the address bar, and a booking page that opens even in the basement changing room, where the signal drops to nothing.

The gym looks at a native app. It would need an iOS team and an Android team (or a cross-platform framework), two store reviews for every release, and a download of 40 to 80 MB that many members will never finish. The website already does everything the app would do. What it lacks is a home: a place on the device, a window of its own, and a way to start without the network.

A progressive web app (PWA) is a website that fills exactly that gap. It is the same HTML, CSS, and JavaScript, served from the same URL. With two small additions, a web app manifest and a service worker, the browser lets the user install it. After that it has an icon, opens in its own window, and can start offline.

What changes for the user

The code is the same. What changes is how the app is reached and how it looks:

  • An icon on the home screen, in the dock, or in the Start menu, next to native apps. No URL to remember.
  • Its own window, without the address bar and tabs, shown as a separate app in the task switcher.
  • A fast, reliable start. A service worker serves the app's shell from a cache, so the first paint does not wait for the network. In the basement it still opens and shows the last known bookings.
  • Some app powers. Depending on the platform, it can show notifications, put a badge on its icon, receive shared files, or open files of a type it handles.
Two phone screens side by side showing the same gym booking page. On the left, in a browser tab: an address bar with the URL gym.example/app, a tab strip, and the page below, which disappears offline. On the right, installed: no address bar, the app's own header in its theme color, the page filling the screen, and a note that the shell opens from cache even offline.in a browser tabBrowsergym.example/appBook a classoffline: error pageinstalledNorthsideBook a classoffline: from cacheinstall
Same URL, same code. Installing removes the browser chrome, gives the app its own icon and window, and, with a service worker, lets it open without the network.

Why "progressive"

The word means the app gets better as the platform gets more capable, and never worse. A browser that cannot install apps still gets a working website; one that can gets an installable app; one with push or file handling gets those too. Every feature is checked before it is used, so no user gets a broken page.

A native app either runs on a platform or does not. A PWA runs everywhere and gets as much as each platform allows, which differs a lot between Chrome on Android and Safari on iOS. A big part of this lesson is about those differences.

What a PWA is not

A PWA is not a framework or a special build. A plain multi-page site with server-rendered HTML can be a PWA, and a single-page React app can fail to be one. It is also not "offline mode" alone: offline comes from the service worker and install from the manifest, and the next chapter keeps the two apart.

This lesson is the overview. The service worker itself (lifecycle, caching strategies, updates), push notifications, and client storage each have their own lesson, which we link to rather than repeat.

Check your understanding · Exercise 1

Which of these are true of a progressive web app? Select all that apply.

The mental model: a website with three additions

Picture an ordinary website with three additions on top, each unlocking one part of the app experience. Take any of them away and you lose that part, but the website still works.

  1. HTTPS. Installing an app and running a service worker are powerful, so browsers only allow them on secure origins. http://localhost counts as secure for development.
  2. A web app manifest. A small JSON file that gives the app its identity: name, icons, colors, the URL to open, and how the window looks. It is what makes the site installable.
  3. A service worker. A script that sits between the page and the network and can answer requests from a cache. It is what makes the app reliable: it opens fast and works offline.
A layered stack. At the bottom, an ordinary website: HTML, CSS, and JavaScript at a URL. Above it, HTTPS, a secure origin. On top of HTTPS, side by side: the web app manifest, passive JSON data the browser reads, which makes the app installable with a name, icons, and its own window; and the service worker, active code that answers requests, which makes the app reliable with an offline, fast launch. At the top, the result: an installed app.What the user getsAn installed appWeb app manifestdata: name, icons, windowService workercode: cache, fetch, pushmakes it installablemakes it reliableHTTPS: a secure originAn ordinary websiteHTML, CSS, JavaScript at a URL
Each layer unlocks one part of the experience. The manifest is data the browser reads; the service worker is code that runs. Remove either one and the site still works, with less.

The manifest and the service worker are often confused, so separate them firmly. The manifest is passive data: the browser reads it, the page never runs it. The service worker is active code: it runs in the background, handles fetch events, and may receive push events. A site can have either one without the other.

What "install" actually creates

Installing does not copy the website onto the device the way an app store does. It creates an entry point: an icon that opens a URL inside a window with no browser chrome (chrome means the address bar, tabs, and buttons around a page). What exactly the browser creates depends on the platform:

PlatformWhat install createsWhere the user finds it
Chrome on AndroidA WebAPK: a small real Android package, generated and signed by a Google server from your manifestHome screen and app drawer, app settings, and the share sheet if it declares share_target
Chrome or Edge on desktopAn OS app entry that launches the browser in an app windowStart menu, Dock or Launchpad, taskbar, the browser's apps page
Safari on iOS and iPadOSA Home Screen web app that runs in its own web view, with its own storageHome Screen and App Library
Safari on macOS 14+A web app in the Dock, created with Add to DockDock, Applications folder, Spotlight

In every case the code still comes from your origin, and the service worker cache is what makes it available offline. Uninstalling removes the entry point and, on some platforms, offers to clear the site's data.

One origin, one app (mostly)

Browsers tie an installed app to an origin (scheme, host, and port, such as https://gym.example), and its storage, cookies, permissions, and service worker belong to that origin. Two apps on one origin would share all of that, so the usual advice is one app per origin, such as app.gym.example and admin.gym.example.

Check your understanding · Exercise 2

A site has a valid manifest and HTTPS, but no service worker. What happens in Chrome on Android? Select all that apply.

The web app manifest, field by field

The manifest is a JSON file, usually named manifest.webmanifest or manifest.json, linked from the head of every page with <link rel="manifest" href="/manifest.webmanifest">. Link it from every page, not only the home page: the user may decide to install from any page, and the browser only knows about the manifest of the page it is showing.

Here is a complete manifest for the gym app. Every field is explained below, grouped by what it controls.

json
{
  "id": "/app",
  "name": "Northside Gym: classes and bookings",
  "short_name": "Northside",
  "description": "Book classes, see your passes, check in at the door.",
  "start_url": "/app?source=pwa",
  "scope": "/app",
  "display": "standalone",
  "display_override": ["window-controls-overlay", "standalone"],
  "theme_color": "#1f4e79",
  "background_color": "#ffffff",
  "icons": [
    { "src": "/icons/192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icons/512.png", "sizes": "512x512", "type": "image/png" },
    { "src": "/icons/maskable-512.png", "sizes": "512x512",
      "type": "image/png", "purpose": "maskable" }
  ],
  "shortcuts": [
    { "name": "Book a class", "url": "/app/book",
      "icons": [{ "src": "/icons/book-96.png", "sizes": "96x96" }] }
  ],
  "screenshots": [
    { "src": "/shots/phone.png", "sizes": "1080x1920",
      "type": "image/png", "form_factor": "narrow" }
  ]
}
Serve it with the application/manifest+json content type. Browsers are lenient about the type, but some audit tools are not.
Seven lines of the gym app manifest, each with a note. "id": "/app" is the permanent identity. "short_name": "Northside" shows under the icon, about 12 characters. "start_url": "/app?source=pwa" opens on launch. "scope": "/app" lists the URLs inside the app. "display": "standalone" gives its own window with no URL bar. "background_color": "#ffffff" is the splash screen color. "purpose": "maskable" marks an icon safe for any launcher shape."id": "/app"permanent identity"short_name": "Northside"under the icon, 12 chars"start_url": "/app?source=pwa"opens on launch"scope": "/app"URLs inside the app"display": "standalone"own window, no URL bar"background_color": "#ffffff"splash screen color"purpose": "maskable"icon safe for any shape
Identity, launch, window, and look. Of these, only id must never change; the rest can be tuned later, but reach installed apps slowly.

Identity: name, short_name, description, id

  • name is the full name, shown in the install dialog, the splash screen, and the OS app list.
  • short_name is shown under the home screen icon, where about 12 characters fit before the OS cuts the text. If you only give name, a long name gets cut in an ugly place.
  • description appears in Chrome's richer install dialog and in store listings created from the manifest.
  • id is the app's permanent identity. If it is missing, browsers use start_url as the identity. That is a trap: change start_url later (say, to add a tracking parameter) and the browser sees a different app, so users can end up with two icons. Set id on day one and never change it.

Launch: start_url and scope

start_url is the URL the icon opens. A query such as ?source=pwa lets analytics count launches from the installed app. It must be inside scope, and it should be a page the service worker can serve offline, or the installed app shows an error every time it starts in a tunnel.

scope is the set of URLs that belong to the app: every URL whose path starts with the scope. If you leave it out, the scope is the folder that contains start_url. When the user follows a link outside the scope, the browser shows its own toolbar with the URL or opens a browser view, so the user can see they have left the app. Sign-in pages are the classic victim; the failure modes chapter shows the fix.

Window: display and display_override

display valueWhat the user seesGood for
fullscreenThe whole screen, no status bar, no browser UIGames, kiosks, video
standaloneIts own window with the OS status bar, no browser UIMost apps
minimal-uiIts own window with a small bar: back, reload, URLContent apps where back matters
browserA normal browser tab (this means: not an app window)Sites that should stay sites

display_override is a list the browser tries in order before falling back to display. It exists for newer modes that older browsers do not know. For example, window-controls-overlay lets a desktop app draw into the title bar area, and is only supported in Chromium browsers on desktop. Safari ignores display_override and does not support minimal-ui.

Look: icons, colors, screenshots, shortcuts

  • icons: a list of images with sizes, type, and an optional purpose (any, maskable, or monochrome). Chapter 6 covers them in depth.
  • theme_color: the color of the title bar or status bar. A <meta name="theme-color"> tag on the page overrides it, and can differ for light and dark mode.
  • background_color: the splash screen background while the app starts. Match the background of your first paint, or the user sees a flash of the wrong color.
  • screenshots: images that Chrome shows in a richer install dialog, like a store listing. Use form_factor: "narrow" for phones and "wide" for desktop.
  • shortcuts: up to a handful of deep links that appear on a long-press of the icon (Android) or a right-click of the dock or taskbar icon (desktop Chromium, and Safari 17.4 or later for Dock apps on macOS). Safari on iOS ignores them.

Check your understanding · Exercise 3

A team ships a manifest without id and start_url: "/". Six months later they change start_url to /?source=pwa. What happens? Select all that apply.

Installability and install prompts

Each browser has its own install criteria: a checklist a site must pass before an Install button or menu item appears. The lists differ, and Safari has none at all.

Chrome, Edge, and other Chromium browsers

Chrome is the strictest and the best documented, and Edge, Samsung Internet, and Opera follow it closely. Before Chrome shows its install promotion and fires beforeinstallprompt, all of these must be true:

  • The page is served over HTTPS (or from localhost).
  • The app is not already installed.
  • The manifest has name or short_name, a start_url, icons of at least 192 and 512 pixels, and a display of fullscreen, standalone, minimal-ui, or window-controls-overlay.
  • prefer_related_applications is missing or false. If it is true, Chrome points the user to your native app in the store instead.
  • The user has engaged: clicked or tapped the page at least once, and spent at least 30 seconds on it, at any time, even on an earlier visit.

Notice what is missing: a service worker. Chrome used to require one with a fetch handler, and many articles still say so, but it dropped that rule in 2023. You still want a service worker, because an installed app that shows the dinosaur offline page feels broken, but it is no longer the gate.

Safari on iOS, iPadOS, and macOS

Safari has no install criteria and no install prompt. Any site can be added, with or without a manifest; if there is one, Safari reads the name, start_url, display, and icons from it, and otherwise uses the page title and the apple-touch-icon link. Your job on Apple devices is to teach users the steps, because nothing in the browser will suggest it.

  • iPhone and iPad: the user opens the Share menu and picks Add to Home Screen. Since iOS 16.4, Chrome, Edge, and Firefox on iOS can do the same from their share menus.
  • What opens: before iOS 26, the icon opened a web app only if the manifest's display was standalone or fullscreen, and Safari otherwise. Since iOS 26, every site added this way opens as a web app by default, and the user can turn off an Open as Web App switch.
  • Mac: since Safari 17 on macOS Sonoma, the user picks File, then Add to Dock. That always creates a web app, and Safari copies the site's cookies into it, so the user stays signed in.

Firefox

Firefox on Android can install sites to the home screen from its menu, using the manifest. On desktop, Firefox had no install support for years; in 2025, Firefox 143 on Windows added a way to pin a site to the taskbar as a simple web app. It fires no beforeinstallprompt, so plan for most desktop Firefox users as tab users.

{
  "name": "Northside Gym",
  "short_name": "Northside",
  "display": "browser",
  "icons": [
    { "src": "/icons/192.png", "sizes": "192x192", "type": "image/png" }
  ]
}
What each browser does with this manifest. Chrome, Edge (Android and desktop): not installable. Safari on iPhone and iPad: app on iOS 26+. Safari on macOS: Add to Dock.Chromiumnot installableSafari iOSapp on iOS 26+Safari macOSAdd to DockChromium is missing 3 of 7 requirements
Chromium checks a list and offers to install only if every item passes. Safari never checks: the user adds any site by hand, and the manifest supplies the name, icons, and, before iOS 26, whether it opens as an app.

Chrome, Edge (Android and desktop): not installable.

  • Missing: A 512 by 512 icon
  • Missing: start_url
  • Missing: display is an app mode (now browser)
  • No service worker: it can install, but opens to an offline error without a network.
  • No id: the app's identity follows start_url, so changing it later can create a second app.
  • No maskable icon: Android shrinks the icon onto a white circle.
  • No screenshots: Chrome shows the basic install dialog, not the richer one.

Safari on iPhone and iPad: app on iOS 26+.

  • No prompt: the user picks Share, then Add to Home Screen. On iOS 26 and later it still opens as a web app unless the user turns off Open as Web App; older versions open it in Safari, like a bookmark.
  • App icon: the manifest icons.
  • Opens to an error offline, since nothing is cached.
  • Push works only after it is added to the Home Screen (iOS 16.4+).

Safari on macOS: Add to Dock.

  • No prompt: the user picks File, then Add to Dock. Any site can be added; the manifest is optional.
  • App icon: the manifest icons.
  • Opens to an error offline, since nothing is cached.

Showing your own install button

When a Chromium browser decides the site qualifies, it fires beforeinstallprompt on window. If you call preventDefault() and keep the event, the browser does not show its own mini-infobar on Android, and you can call prompt() later from your own button. Each saved event can be used for one prompt.

ts
let saved: BeforeInstallPromptEvent | null = null;

window.addEventListener("beforeinstallprompt", (event) => {
  event.preventDefault();
  saved = event as BeforeInstallPromptEvent;
  showInstallButton();
});

async function onInstallClick() {
  if (!saved) return;
  await saved.prompt();
  const { outcome } = await saved.userChoice; // "accepted" | "dismissed"
  analytics.track("install_prompt", { outcome });
  saved = null;
  hideInstallButton();
}

window.addEventListener("appinstalled", () => analytics.track("installed"));

const isInstalled =
  matchMedia("(display-mode: standalone)").matches ||
  (navigator as { standalone?: boolean }).standalone === true; // iOS
BeforeInstallPromptEvent is not in TypeScript's DOM types, so declare it yourself. navigator.standalone is Safari's own flag for Home Screen apps.
A sequence diagram between the page, the browser, and the user. The browser sees the install criteria are met and fires beforeinstallprompt at the page. The page calls preventDefault and saves the event, then shows its own Install button. Later the user taps Install in the page. The page calls prompt on the saved event. The browser shows its install dialog to the user, and the user picks Install. The browser resolves userChoice with accepted to the page, then fires appinstalled.PageBrowserUsercriteria metbeforeinstallpromptpreventDefault()taps Install buttonsaved.prompt()install dialogInstallaccepted, appinstalled
The page decides when to ask, the browser owns the dialog. Only Chromium browsers run this flow; Safari has no beforeinstallprompt.

Decision When and how should the app ask users to install?

Leave it to the browser

Do nothing with beforeinstallprompt

Chrome shows an install icon in the address bar and, on Android, a mini-infobar

Pros

  • No code
  • No risk of annoying users

Cons

  • Few users notice the address bar icon
  • Nothing at all on Safari or Firefox

Banner on first visit

Show an "Install our app" banner as soon as the page loads

Pros

  • Maximum exposure

Cons

  • Users have not seen any value yet, so most dismiss it
  • Takes space from the content they came for

Contextual button after a moment of value

Save the event

Offer install after a second visit, a booking, or a saved item

On iOS, show two-step Add to Home Screen instructions instead

Pros

  • Asks when the user already sees the benefit
  • Works on every platform with platform-specific UI

Cons

  • Needs a little UI and analytics work
  • Fewer people see the offer

What we pick: Use the contextual button. Pick a moment of value (a finished booking, a third visit), keep a quiet permanent entry in the settings or account menu, and hide all install UI when display-mode: standalone matches.

Check your understanding · Exercise 4

Your manifest is valid and the site is on HTTPS, but beforeinstallprompt never fires during your quick test in Chrome. What could explain it? Select all that apply.

The app shell: opening like an app

Native apps never show a blank white screen while they download their own menu bar. The frame of the app is on the device, so it appears at once, and only the content waits for the network. The app shell pattern gives a PWA the same behavior.

The shell is the smallest set of HTML, CSS, and JavaScript that draws the app's frame: the header, the navigation, the layout of the main area, and empty placeholders. The service worker stores it in a cache when it installs. On every launch, the service worker answers the navigation request with the cached shell, and the shell then fetches the content.

A launch, with and without a shell

Take the gym app on a weak mobile connection with a 600 ms round trip. Without a shell, a launch is a normal page load: DNS, a TLS connection, the HTML, then the CSS and JavaScript it names. The user stares at the splash screen, then a white page, for 2 to 3 seconds, and offline the launch fails.

With a shell, the service worker answers the HTML, CSS, and JavaScript from its cache in tens of milliseconds. The frame is on screen in about 300 ms, mostly the time to parse and run the scripts. The bookings area shows the last saved bookings from IndexedDB, marked as "updated 2 hours ago", while a request for fresh data runs. Offline, everything except the fresh data still works.

Two launch timelines to scale on a network with a 600 millisecond round trip, from 0 to 3 seconds. Without a shell: DNS and TLS until 1.2 seconds, the HTML until 1.8 seconds, CSS and JavaScript until 2.6 seconds, then running the code; the frame paints at about 3 seconds. With a cached shell: the shell is served from cache and the frame paints at about 300 milliseconds, cached data shows by 600 milliseconds, and fresh data arrives in the background by about 1.8 seconds.no shellDNS, TLSHTMLCSS, JSrunshellshelldatafresh datafirst paint about 3 sfirst paint about 300 ms01 s2 s3 s
The service worker removes the network from the critical path of the launch. The fresh data still takes the same time; it just no longer blocks the first paint.

Keeping the shell small

The shell is downloaded again after every deploy that changes it, and parsed on every launch. A useful budget for a mobile shell is about 100 to 150 KB of compressed JavaScript and CSS. Code for screens the user may never open belongs in lazy-loaded chunks, cached at runtime.

  • In the shell: the layout, the router, the design system's core styles, the icons in the frame, an offline page, and the code that reads cached data.
  • Not in the shell: the content itself, rarely used screens, large libraries such as a chart or rich-text editor, and user-specific HTML.
  • Skeletons: placeholders with the same size as the real content, so nothing jumps when the data arrives. A skeleton that is the wrong size trades a blank screen for layout shift.

App shell or server-rendered pages?

The shell fits apps with a stable frame and changing data: mail, chat, dashboards, booking tools. For a content site, it can make the first view slower, because the article now waits for JavaScript to fetch it.

Decision How should an installed app answer a navigation (a launch or a page load)?

App shell, cache first

Serve the same cached index.html for every in-scope URL

The client router renders the screen and fetches data

Pros

  • Fastest and most reliable launch
  • Works fully offline with cached data

Cons

  • Content waits for JavaScript
  • Shell changes ride on the service worker update flow

Server-rendered pages, network first

Fetch each page from the network

Fall back to a cached copy, then to an offline page

Pros

  • Content is in the first HTML, good for search and LCP
  • Always fresh when online

Cons

  • Slow launches on bad networks, since the timeout must pass before the fallback
  • Offline only for pages visited before

Cached shell plus streamed content

The service worker streams the cached header, the network body, and a cached footer as one response

Navigation preload starts the network request while the worker boots

Pros

  • Frame paints at once and the content is still server-rendered

Cons

  • The most complex to build and debug
  • Needs a server that can return content without the frame

What we pick: For an app people use daily (bookings, mail, dashboards), use the app shell with cached data. For a content site, cache pages network first with an offline page. Combine them only when you have measured that both the launch and the content paint matter.

Check your understanding · Exercise 5

Which of these belong in the precached app shell of a booking app? Select all that apply.

Icons and splash screens

The icon is the first thing users see of an installed app, and on many devices the browser has to reshape it. The splash screen is the second thing: what fills the screen while the app starts. Both are generated from a few manifest fields, and both look broken if those fields are careless.

Sizes and formats

Give at least a 192 by 192 and a 512 by 512 PNG. The browser scales them to every place the icon appears: the home screen, the app switcher, the task bar, the install dialog, the splash screen. Chrome requires both sizes for installability.

Safari on iOS reads manifest icons in recent versions, but an apple-touch-icon link still wins when present. Give it a 180 by 180 PNG with no transparency: iOS fills transparent pixels with black, and it rounds the corners itself, so do not round them in the image.

Maskable icons

Android launchers show every icon in one shape, chosen by the device maker: a circle, a squircle (a rounded square with soft corners), a rounded square, or a teardrop. A plain square icon does not fit any of them. Without help, Android shrinks it and puts it on a white circle, which looks like a sticker on a plate.

A maskable icon solves this. It is a full-bleed square, colored to the edges, with everything important inside the safe zone: a central circle whose diameter is 80% of the icon, about 410 pixels across on a 512-pixel icon. Every launcher mask keeps the whole safe zone, so the logo is never cut. Mark it with "purpose": "maskable".

A regular icon in a circle mask. The whole logo is visible. On the right, the same icon in all four shapes: Circle fits, Squircle fits, Rounded square fits, Teardrop fits.logocircleCircleon a plateSquircleon a plateRounded squareon a plateTeardropon a plate
The safe zone is the dashed circle, 80% of the icon's width. A logo inside it survives every launcher shape.

With no maskable icon, Android shrinks the square icon onto a white circle. It works, but looks like a sticker on a plate.

  • Ship a separate any icon and maskable icon. A single icon marked "any maskable" either has too much padding where it is shown unmasked, or gets clipped where it is masked.
  • Fill the whole square with a background color or pattern. Transparent corners in a maskable icon show up as black or white wedges after masking.
  • Check the result in a maskable preview tool or in Chrome DevTools (Application, then Manifest).
  • monochrome icons are single-color shapes a platform can tint to match a themed home screen. Support is limited; treat them as optional.

Splash screens

On Android, Chrome builds the splash screen from the manifest. It fills the screen with background_color, puts one of your icons in the center, and on some versions writes name under it. Nothing to design, but three things to check: a 512 icon so the center icon is sharp, a background_color that matches the first frame of your app, and a name that is not cut off.

A phone showing the Android splash screen for the gym app, with three notes. The whole screen is filled with background_color. One of the manifest icons sits in the center, and a 512 pixel icon keeps it sharp. The app's name appears under the icon on some Android versions.NNorthsidebackground_colorfills the whole screenone of your icons;512 px keeps it sharpname, under the iconon some versions
Android builds this from the manifest alone: background_color, an icon, and the name. Match the color to your first paint, or the user sees a flash when the app takes over.

Safari on iOS has not built splash screens from the manifest the way Android does. The reliable way is apple-touch-startup-image link tags: one full-screen image per device screen size and orientation, chosen with a media query. That is dozens of images to cover current iPhones and iPads, so most teams generate them with a build tool, or accept a plain screen in the background color.

html
<link rel="apple-touch-icon" href="/icons/apple-180.png">
<link rel="apple-touch-startup-image"
      media="(device-width: 393px) and (device-height: 852px)
             and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
      href="/splash/1179x2556.png">
<meta name="theme-color" content="#1f4e79" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#0b1f33" media="(prefers-color-scheme: dark)">
One startup image is shown, for one iPhone size in portrait. A real app repeats it for each size and orientation it supports.

Desktop apps have no splash screen: the window shows background_color until the first paint, so match it to your page background, in dark mode too.

Check your understanding · Exercise 6

Your Android icon shows as a small logo on a white circle. Which fixes help? Select all that apply.

Platform differences and app capabilities

The standards are shared, but what an installed web app can do depends on the browser engine and the operating system. Chromium on Android and on desktop is the most capable. Safari on iOS is the most limited, and it matters most, because on iOS every browser has to use Safari's engine, WebKit (the EU allows other engines since iOS 17.4, but in practice almost all iOS browsers still use WebKit).

A matrix of eight capabilities against four platforms: Chrome on Android, Chromium browsers on desktop, Safari on iOS, and Safari on macOS. install prompt: Chrome Android yes, Chromium desktop yes, Safari iOS no, Safari macOS no. offline launch: Chrome Android yes, Chromium desktop yes, Safari iOS yes, Safari macOS yes. web push: Chrome Android yes, Chromium desktop yes, Safari iOS installed, Safari macOS yes. Background Sync: Chrome Android yes, Chromium desktop yes, Safari iOS no, Safari macOS no. icon badge: Chrome Android no, Chromium desktop yes, Safari iOS installed, Safari macOS yes. share target: Chrome Android yes, Chromium desktop ChromeOS, Safari iOS no, Safari macOS no. file handling: Chrome Android no, Chromium desktop yes, Safari iOS no, Safari macOS no. app shortcuts: Chrome Android yes, Chromium desktop yes, Safari iOS no, Safari macOS yes.ChromeAndroidChromiumdesktopSafariiOSSafarimacOSinstall promptyesyesnonooffline launchyesyesyesyesweb pushyesyesinstalledyesBackground Syncyesyesnonoicon badgenoyesinstalledyesshare targetyesChromeOSnonofile handlingnoyesnonoapp shortcutsyesyesnoyes
Roughly, at the time of writing; check current support tables before you rely on a row. Installed means only for apps added to the Home Screen. Safari on iOS is the column that limits most designs.

What is different on iPhone and iPad

  • Separate storage. A Home Screen web app has its own cookies, localStorage, IndexedDB, and caches, apart from Safari. A user who signed in to Safari is signed out in the installed app and must sign in again. Plan for that moment: make sign-in quick, with passkeys or a magic link that opens inside the app.
  • Storage eviction. In the Safari browser, if the user has not interacted with a site during 7 days of Safari use, Safari deletes the storage its scripts wrote. A Home Screen web app keeps its own day counter, which resets each time the user opens it, so WebKit does not expect its data to be deleted. That is one more reason to encourage installs on iOS; still handle missing data, as the client storage lesson explains.
  • Push only after install. Web push works on iOS 16.4 and later, only for Home Screen apps, and the permission request must follow a tap inside the installed app. The push notifications lesson covers it in full.
  • No background work. There is no Background Sync, no Periodic Background Sync, and no Background Fetch. Writes queued offline are sent the next time the user opens the app.
  • No install event. No beforeinstallprompt and no appinstalled. Use navigator.standalone or the display-mode media query to tell whether the app is running installed.

Capabilities that make it feel native

Beyond install and offline, a set of APIs lets an installed app plug into the operating system. Most started in Chromium and support elsewhere is partial, so check current support tables before you promise one.

CapabilityWhat it doesWhere it works (at the time of writing)
App shortcuts (shortcuts)Deep links on long-press or right-click of the iconChromium on Android and desktop; Safari Dock apps on macOS
Badging (navigator.setAppBadge)A number or dot on the app iconChromium desktop; Safari Home Screen and Dock apps (needs notification permission on iOS)
Share target (share_target)The app appears in the OS share sheet and receives text, links, or filesChromium on Android and ChromeOS
File handling (file_handlers)The OS opens files of a type, such as .csv, in the appChromium desktop
Protocol handlers (protocol_handlers)Links like web+gym: open in the appChromium desktop
Window Controls OverlayThe app draws into the title bar areaChromium desktop
Bluetooth, USB, Serial, NFCTalk to hardware from the pageChromium only; NFC only on Android; none on iOS

Every one of these must be feature-detected, never assumed from the user agent: the badge appears where the platform can show it, and the app works the same everywhere else.

ts
async function showUnread(count: number) {
  if (!("setAppBadge" in navigator)) return; // no badge here, the in-app counter is enough
  try {
    if (count > 0) await navigator.setAppBadge(count);
    else await navigator.clearAppBadge();
  } catch {
    // Not installed, or permission missing: ignore
  }
}

if ("launchQueue" in window) {
  // File Handling: files the OS opened with this app
  window.launchQueue.setConsumer(async ({ files }) => {
    for (const handle of files) importCsv(await handle.getFile());
  });
}
launchQueue may also need a type declaration, since it is not in every TypeScript DOM version.

Check your understanding · Exercise 7

A user installs your PWA from Safari on an iPhone. Which of these should you expect? Select all that apply.

App stores and real products

A PWA does not need an app store, but many users look for apps there first, and some companies need a listing for search, reviews, or device management. The packaging is thin: the store app is a wrapper that opens your site, so one deploy still updates every copy.

A tree. At the top, your PWA: one URL and one deploy. Four branches below it. The browser, installed from a prompt or menu, with no review. Google Play, as a Trusted Web Activity built with Bubblewrap, after Play review. The Microsoft Store, installed through Edge, after Store review. The Apple App Store, only with a native shell, after Apple review, drawn dashed because a thin wrapper risks rejection. PWABuilder can generate the three store packages.Your PWAone URL, one deployBrowserprompt or menuno reviewGoogle PlayTWA, BubblewrapPlay reviewMicrosoftStore, via EdgeStore reviewApp Storenative shellApple reviewPWABuilder can generate the three store packages
Every branch opens the same site, so one deploy updates all of them. Only the App Store branch needs real native code, and it is the riskiest.

Google Play: Trusted Web Activity

A Trusted Web Activity (TWA) is an Android app whose only job is to open your PWA full-screen in the user's browser, usually Chrome, with no browser UI. Because it runs in the real browser, it shares the browser's engine, cookies, and service worker with the website, and gets every web feature Chrome has.

The "trusted" part is a two-way check called Digital Asset Links. The Android app declares the website, and the website declares the app in a file at /.well-known/assetlinks.json with the app's package name and the SHA-256 fingerprint of its signing key. If the check passes, the app opens with no address bar. If it fails, for example because Play App Signing changed the key and the file still lists the upload key, the app shows a URL bar at the top, and users notice at once.

json
[{
  "relation": ["delegate_permission/common.handle_all_urls"],
  "target": {
    "namespace": "android_app",
    "package_name": "com.northsidegym.app",
    "sha256_cert_fingerprints": ["14:6D:E9:83:...:A5"]
  }
}]
Served at https://gym.example/.well-known/assetlinks.json. Use the fingerprint of the key Google Play signs with, shown in the Play Console.

Google's Bubblewrap command-line tool reads your manifest and generates the Android project for a TWA. Store rules still apply to the wrapped app: for example, selling digital goods inside a Play Store app generally means using Play's billing.

Microsoft Store, App Store, and PWABuilder

  • Microsoft Store accepts PWAs as first-class apps. The listing installs the PWA through Edge, and it shows up in the Start menu like any Windows app.
  • Apple App Store has no PWA path. You need a native app, which can be a thin shell around a WKWebView. Apple's review guidelines reject apps that are only a repackaged website, so the wrapper must add real native value, such as native push, widgets, or deep OS integration.
  • PWABuilder, an open-source project started by Microsoft, takes a URL and generates packages for Google Play (a TWA), the Microsoft Store, and an iOS wrapper project. It also reports what the manifest is missing.

In real products

The well-known PWA case studies are from 2017 to 2019, when big brands rebuilt their mobile sites. The numbers below are as the companies reported them; treat them as the scale of the effect, not as a promise.

  • Starbucks built a PWA for ordering. It was reported at about 233 KB, roughly 99.8% smaller than its iOS app at the time, and it works offline for browsing the menu and building an order. Starbucks reported about twice as many daily web users after the launch.
  • Pinterest rebuilt its mobile website as a PWA and reported that time spent of 5 minutes or more grew by about 40%, and core engagements by about 60%.
  • Twitter Lite (now X) launched as a PWA in 2017, a fraction of the size of the native Android app, aimed at slow networks and cheap phones.
  • Uber rebuilt m.uber.com so that the core app is about 50 KB gzipped and loads in a few seconds even on 2G networks, to reach riders in new markets.

The common thread is reach. Each company already had native apps. The PWA served the users the native app did not reach: people on low-end phones, with little storage, on slow or expensive data, or who would not install anything to try the product once.

Check your understanding · Exercise 8

Your TWA in the Play Store suddenly shows a URL bar at the top. What is the most likely cause? Select all that apply.

Failure modes and edge cases

Most PWA bugs are not crashes. They are small breaks in the illusion of being an app: a URL bar that appears, a white flash, a sign-in loop, an icon that never updates. Users rarely report them; they just stop opening the app.

The installed app does not open offline

The team tests offline in a browser tab and it works, but the installed app fails. The cause is almost always start_url: the manifest says /app?source=pwa, the service worker precached /app, and the cache lookup matches the full URL including the query, so it misses. Fix it by precaching the exact start_url, by serving the shell for every navigation in scope (the usual app shell setup), or with ignoreSearch: true on that one lookup.

Leaving scope, and sign-in loops

Any navigation outside scope leaves the app experience: Chromium shows a toolbar with the URL, and iOS opens an in-app browser sheet. Sign-in is where this hurts most, because the app sends the user to auth.example.com, outside scope. On iOS the sign-in may complete in a browser context that does not share cookies with the Home Screen app, so the app still thinks the user is signed out and sends them back, in a loop.

  • Keep sign-in pages on the app's own origin and inside scope, for example by proxying the identity provider through /auth.
  • Prefer flows that finish in the same context: passkeys, one-time codes typed into the app, or a magic link that opens a URL inside scope.
  • Test sign-in in the installed app on a real iPhone, not only in a desktop browser tab.

Manifest changes that do not arrive

You change the theme color and the icon, and installed apps still show the old ones a week later. That is expected: each platform has its own update path, and none is instant.

A state machine for manifest updates in Chromium. From Installed, launching the app moves to Checking the manifest. If nothing changed, it returns to Installed. If colors or similar fields changed, it moves to Quiet update, which is applied and returns to Installed. If the name or icon changed, it moves to Ask the user; when the user accepts, it becomes a Quiet update. A note says that on iOS the name and icon are frozen; the user must remove the app and add it again.InstalledCheckingQuiet updateAsk the userlaunchcolors changedname or iconacceptedappliedno changeiOS: name and icon are frozen;remove the app and add it again
Updates happen on launch, not on deploy, and identity changes need the user's consent. On Android, rebuilding the WebAPK can add a day or more.

Chromium fetches the manifest again when the installed app is launched and applies most changes, such as colors, without asking. A change to the name or icon is security-sensitive, since a malicious update could pose as another app, so Chrome holds it until the user reviews and accepts it. Since Chrome 144 it compares icon URLs, not pixels, so give a new icon a new URL.

On Android the WebAPK is rebuilt, which can take a day or more, since Chrome waits for the app to close and the phone to be charging on Wi-Fi. On iOS, the name and icon never update: the user has to remove the app and add it again.

More traps, in one table

SymptomCauseFix
Two icons for the same appNo id, then start_url changedSet id on day one; keep it forever
Manifest fails to load for signed-in users onlyManifest is behind auth and fetched without cookiesServe it publicly, or add crossorigin="use-credentials" to the link
White flash before the first paintbackground_color does not match the page, often in dark modeMatch it, and set theme-color per color scheme
Users stuck on a screen in the iOS appstandalone hides the browser's back button and iOS has no system backShow an in-app back button when display-mode: standalone matches
Content under the iPhone notch or home barFull-screen layout without safe-area paddingviewport-fit=cover plus env(safe-area-inset-top) and friends
Old version for days after a deployThe new service worker waits for all windows to closeAn update prompt; see the service worker lesson

To debug, start with Chrome DevTools, Application, then Manifest. It lists every field it parsed, shows the icons with a maskable preview, and prints the exact reason the page is not installable. Lighthouse used to have a PWA category, but it was removed in Lighthouse 12 in 2024, so do not look for a PWA score there.

Check your understanding · Exercise 9

Your installed app shows an offline error on launch, but the same page works offline in a tab. Which are likely causes? Select all that apply.

When a PWA beats a native app, and when it does not

This is a product decision, not a technology contest. Start from who the users are, how they find the product, and which device features the core flow needs.

Where a PWA wins

  • Reach from a link. A search result or a shared link opens the full app with no install step. Every step of a store install (open the store, wait for the download, open, sign in) loses a share of users.
  • Size. A PWA downloads what it needs, often a few hundred KB, where native apps are commonly 50 MB or more. That matters on cheap phones with little free storage and on prepaid data.
  • Release speed. Deploy, and every user gets the new version on their next launch. No store review, and no users stuck on a version from two years ago.
  • One code base for the web, Android, desktop, and iOS, owned by one team.
  • Occasional use. Apps people open a few times a year, such as parking, event tickets, or a hotel's guest app, are rarely worth an install, but a home screen icon after the first use is welcome.

Where native wins

  • iOS-heavy features and users. Background location, background tasks, widgets, Bluetooth or NFC on iPhone, HealthKit, or Siri. And daily power users on iPhone, where push, storage, and background work are all weaker for web apps.
  • Heavy compute and media. Advanced camera pipelines, on-device video editing, or high-end 3D, where native APIs are faster and more complete.
  • Store-driven discovery. Markets where users find apps mostly by searching the App Store or Play Store.
A flowchart with three questions. First: does the core need iOS-only powers such as background GPS, Bluetooth, or widgets? If yes, build a native app or a wrapped web app. If no: do users arrive by link, from search, shared links, or QR codes? If yes, PWA first, with native later if needed. If no: are users found through app store search? If yes, a PWA packaged for stores, as a TWA and in the Microsoft Store. If no, a PWA on its own, which suits internal tools.Needs iOS-only powers?background GPS, Bluetooth, widgetsyesNative appor a wrapped web appnoUsers arrive by link?search, shared links, QR codesyesPWA firstnative later if needednoFound in app stores?store search drives installsyesPWA in storesTWA, Microsoft StorenoPWAinternal tools too
Ask the device question first: it is the only one that rules the web out. After that, the answer is a PWA, and the remaining questions only decide where you list it.

Decision How should a new product reach mobile users?

PWA only

One web app with a manifest and a service worker

Optionally a TWA in the Play Store and a Microsoft Store listing

Pros

  • One team, one release
  • Reach from links and search
  • Small and instant to try

Cons

  • Weakest on iOS: push only after install, no background work
  • No App Store listing

Native apps only

Separate iOS and Android apps, or one cross-platform framework such as React Native or Flutter

Pros

  • Every device feature
  • Best background behavior and store presence

Cons

  • Store reviews slow every release
  • Install friction loses first-time users
  • A separate web experience is still needed for links

PWA first, native later

Launch as a PWA

Add native apps for daily power users once the product settles, often sharing code or wrapping web screens

Pros

  • Fast start and wide reach
  • Native spend goes where usage proves it

Cons

  • Two experiences to keep consistent later
  • Users must be moved to the native app

Web app in a native shell

Wrap the web app with Capacitor or a WKWebView shell, and add native plugins for push or sensors

Pros

  • Store presence on iOS with mostly web code
  • Native APIs where needed

Cons

  • Still goes through store reviews
  • A thin wrapper risks App Store rejection

What we pick: For most new consumer products and almost all internal tools, start with a PWA. Add native apps when the data shows heavy daily users on iOS or a core feature the web cannot provide. Many large companies run both: the PWA for reach, the native app for depth.

Using it in a system design interview

Bring PWAs up when the prompt mentions mobile users, poor networks, emerging markets, offline use, or "should we build an app". Do not bring it up for a desktop-only admin console that is always online. Draw the three layers (HTTPS, manifest, service worker), put the app shell and cached data on the client side of your diagram, and say what installing adds.

  • Junior: know what the manifest and service worker each do, and that installing gives an icon, a window, and offline launch.
  • Mid-level: name the install criteria, the app shell, maskable icons, and the iOS limits (push only after install, separate storage, no Background Sync).
  • Senior: frame PWA versus native as a product trade-off with data, plan the install prompt and its metrics, handle id and scope so the app survives URL changes, and mention TWA and store options.

Check your understanding · Exercise 10

For which products is a PWA a strong first choice? Select all that apply.

Summary

  • A PWA is a website plus three things: HTTPS for trust, a manifest for installability, and a service worker for a fast, offline launch.
  • Set id on day one, keep start_url inside scope, and keep sign-in inside scope too, or installed users meet duplicate icons and URL bars.
  • Chrome installs a site with a valid manifest (name, 192 and 512 icons, start_url, an app display mode) on HTTPS after some engagement; a service worker is no longer required, but still needed to work offline.
  • beforeinstallprompt is Chromium-only. Safari installs through Add to Home Screen on iOS and Add to Dock on macOS, so give iOS users instructions instead of a button.
  • The app shell draws the frame from cache in a few hundred milliseconds; the splash screen only hides the launch.
  • Ship separate any and maskable icons, with the logo inside the central 80% circle.
  • iOS is the limiting platform: separate storage from Safari, push only after install, no background sync. Feature-detect every capability.
  • A PWA wins on reach, size, and release speed; native wins on deep device features and iOS background work. Many products run both.

In the interview

Questions interviewers ask about this. Answer each out loud first, then compare.

Question 1

What makes a website a progressive web app, and what does each part do?

Question 2

Your PM wants an "Install our app" banner on the first page view. What do you suggest?

Question 3

After installing the PWA on iPhone, users say they are signed out and some never get notifications. Why?

Question 4

We changed start_url to add a tracking parameter and now some users have two icons. What happened and how do we prevent it?

Question 5

A startup asks: PWA or native apps first?

Practice it

These design problems lean on progressive web apps.

References

Read next