---
title: 'Progressive Web Apps'
source: 'https://academia.sh/en/courses/browser-platform/progressive-web-apps'
course: 'The Browser and the Web Platform'
language: en
updated: '2026-08-17T18:09:12+00:00'
license: 'CC BY-SA 4.0'
---

# Progressive Web Apps

The pieces that turn a page into an installable app; the web app manifest's fields and internal consistency, installability criteria, the responsibilities display mode brings, the permission model, and the offline outbox.

The previous lesson made the page independent of the network: resources are local, decisions
are defined. What results is now more than a page, and it needs to look that way to the user
too. The person using the North Slope page in the field does not want to open it by typing an
address every time; they expect it to open in its own window, with its own icon.

This lesson takes up three pieces: the manifest that introduces the page to itself, the
conditions for installability, and the permission model for notifications arriving from the
background. This approach is called a **progressive web app**; the "progressive" in its name
describes the page continuing to work as an ordinary page in an unsupported environment.

## The Web App Manifest

The page links to a JSON file that introduces itself as an app. The file carries the
information the operating system needs to list the app and open its window: a display name, a
short name, the address to go to on launch, the path scope the app is counted within, display
mode, icons, and colors.

Most fields are formally independent, but there are silent consistency conditions between
them. These are checkable.

```js
// manifest-check.mjs — tests the internal consistency of a web app manifest
const REQUIRED = ["name", "start_url", "icons", "display"];
const REQUIRED_SIZES = [192, 512];

function check(manifest) {
  const findings = [];

  for (const field of REQUIRED)
    if (!(field in manifest)) findings.push(`missing field: ${field}`);

  const scope = manifest.scope ?? "/";
  if (manifest.start_url && !manifest.start_url.startsWith(scope))
    findings.push(`start_url (${manifest.start_url}) outside the scope (${scope})`);

  const icons = manifest.icons ?? [];
  const sizes = new Set(
    icons.flatMap((i) => (i.sizes ?? "").split(" ")).map((s) => Number(s.split("x")[0])));
  for (const needed of REQUIRED_SIZES)
    if (![...sizes].some((s) => s >= needed))
      findings.push(`no icon at ${needed}x${needed} or larger`);

  if (!icons.some((i) => (i.purpose ?? "").split(" ").includes("maskable")))
    findings.push("no icon with maskable purpose: edges get clipped in cropped slots");

  if (manifest.display && !["fullscreen", "standalone", "minimal-ui", "browser"].includes(manifest.display))
    findings.push(`unrecognized display value: ${manifest.display}`);

  return findings;
}

const manifests = {
  "north-slope.webmanifest": {
    name: "North Slope Measurement Station",
    short_name: "North Slope",
    start_url: "/station/north-slope/",
    scope: "/station/north-slope/",
    display: "standalone",
    background_color: "#0f1115",
    theme_color: "#0f1115",
    icons: [
      { src: "/icon-192.png", sizes: "192x192", type: "image/png" },
      { src: "/icon-512.png", sizes: "512x512", type: "image/png" },
      { src: "/icon-maskable.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
    ],
  },
  "incomplete.webmanifest": {
    short_name: "North Slope",
    start_url: "/panel/",
    scope: "/station/",
    display: "app",
    icons: [{ src: "/icon-64.png", sizes: "64x64", type: "image/png" }],
  },
};

for (const [name, manifest] of Object.entries(manifests)) {
  const findings = check(manifest);
  console.log(`${name}: ${findings.length === 0 ? "consistent" : findings.length + " finding(s)"}`);
  for (const f of findings) console.log("  - " + f);
}
```

```
north-slope.webmanifest: consistent
incomplete.webmanifest: 6 finding(s)
  - missing field: name
  - start_url (/panel/) outside the scope (/station/)
  - no icon at 192x192 or larger
  - no icon at 512x512 or larger
  - no icon with maskable purpose: edges get clipped in cropped slots
  - unrecognized display value: app
```

The second finding is the sneakiest one. The scope in the manifest draws the app's boundary: a
url outside the scope opens in the browser, not the app window. If the launch url is outside
the scope, the app installs and its icon appears, but every launch exits its own window. This
must also line up with the service worker's scope; when the two scopes diverge, a url opened in
the app window can end up lacking offline support.

The maskable-icon finding looks like a visual detail, and it is not. The system crops the icon
to its own shape; an icon that does not account for cropping gets its edges cut off. An icon
marked with a separate purpose tag is one with safe space left around it.

## Criteria for Installability

Installability does not come automatically with the manifest's presence. The common core is:
the page served from a secure origin, a valid manifest linked, an icon of sufficient size
provided, and usually a service worker registered that answers requests. Criteria beyond this
vary from browser to browser and can change over time; the only correct approach is to build
the install flow with **feature detection**.

When the install prompt is shown is not the page's decision. The browser emits an event
whenever it sees fit; the page can catch this event, defer it, and bind it to a button in its
own interface. Opening the install window without user interaction is not possible. The event
may never arrive at all: if the app is already installed, or the environment does not support
installation. The install button is therefore shown conditionally.

## Display Mode and the Responsibility It Brings

In standalone display mode, the browser interface is not visible: no address bar, no back
button, no reload button. Every browser capability the page implicitly relied on is now the
page's own responsibility.

Three things are directly affected. **Back navigation** must be offered explicitly in the
interface; the user gets stuck on a screen they cannot leave. **A reload path** must be left in
place; when the offline fallback page is shown, the user needs a button to try again. **Sharing
the address** needs a way of its own; without an address bar, there is no other way to copy the
current screen's url.

Which mode the page opened in can be read with a media query; this lets the same document show
different navigation elements in a browser tab versus an app window. The mode declaration also
works like a fallback chain: if the requested mode is not supported, it falls back to a less
privileged mode, ending eventually at an ordinary tab.

## Notifications and the Permission Model

The authority to send notifications depends on the user's explicit consent, and permission
sits in one of three states: not asked, granted, denied. The denied state is permanent; the
page cannot ask for the same permission again unless the user reverses it in settings.

This permanence produces a single rule: **permission is asked for at the moment the user
starts an action that needs it.** Permission asked for the instant the page opens is usually
denied, and that denial is permanent; the right to ask again when it is genuinely needed later
gets burned. The right order is to first show a step in the interface explaining what it will
be used for, then open the browser's permission window.

Remote notification also needs a server side: the subscription the page creates is registered
with the server, the server sends the message to a broker, and the broker wakes the service
worker. The subscription is not permanent; the browser can refresh it and the server's record
can go stale. For this reason, notification is never counted as the only channel of
communication; the same information should also be available in the interface when the app is
opened.

## Offline Submission and Resilience to Repetition

Offline reading was the previous lesson's topic; offline **writing** is a separate problem. A
measurement taken in the field cannot be sent while there is no connection; to keep it from
being lost, it is written to a local queue and drained once a connection arrives. In some
environments, the browser can do this draining even while the page is closed; support is
conditional and used through detection.

The queue itself produces a new class of error. If the response gets lost on the way after the
server has written the record, the client counts the submission as failed and retries it.

```js
// queue.mjs — offline submission queue and resilience against retries
// Scenario: the record is written to the server, but the response is lost before it
// reaches the client. The client counts the submission as failed and retries it.

function server() {
  const records = [];
  const seenKeys = new Set();
  return {
    records,
    write(body, key) {
      if (key && seenKeys.has(key)) return "duplicate request, ignored";
      if (key) seenKeys.add(key);
      records.push(body);
      return "written";
    },
  };
}

// Drains the queue. If responseLoss[i] is true, the response does not reach the client on attempt i.
function drain(queue, s, responseLoss) {
  let attempt = 0;
  while (queue.length > 0 && attempt < 5) {
    const job = queue[0];
    const result = s.write(job.body, job.key);
    const lost = responseLoss[attempt] === true;
    attempt += 1;
    console.log(`  attempt ${attempt}: server "${result}"` +
      (lost ? " -> response lost, will retry" : " -> response arrived"));
    if (!lost) queue.shift();
  }
}

const measurement = { station: "north-slope", temperature: -4.2 };

for (const key of [null, "submission-9f3c"]) {
  console.log(key ? `retry-resilient key: ${key}` : "submission without a key:");
  const s = server();
  const queue = [{ body: measurement, key }];
  drain(queue, s, [true, false]); // response lost on the first attempt
  console.log(`  records on the server: ${s.records.length}\n`);
}
```

```
submission without a key:
  attempt 1: server "written" -> response lost, will retry
  attempt 2: server "written" -> response arrived
  records on the server: 2

retry-resilient key: submission-9f3c
  attempt 1: server "written" -> response lost, will retry
  attempt 2: server "duplicate request, ignored" -> response arrived
  records on the server: 1
```

The same measurement was recorded twice, and no error appeared on the client side. The
distinction is a key produced together with the submission that stays **unchanged** across
retries. The key is produced on the client when the submission is queued; if it is produced
fresh on every attempt, it serves no purpose. The server-side counterpart is ignoring a request
that sees the same key a second time and reporting success anyway.

The queue's visibility to the user is also part of the design. The count of unsent
measurements should stand in the interface and disappear once submission completes; data the
user thinks is saved silently waiting in the queue is the most costly failure of offline
support.

## Summary

- The web app manifest is the JSON file that introduces the page to the operating system; its
  fields carry checkable consistency conditions between them.
- The launch url must be within the manifest's scope; outside it, the app exits its own window
  on every launch.
- Installability criteria include a secure origin, a valid manifest, and usually a registered
  service worker; the rest varies by environment and is handled with feature detection.
- In standalone display mode, back navigation, reload, and address sharing become the page's
  own responsibility.
- Permission denial is permanent; permission is asked for at the moment the user starts an
  action that needs that capability.
- The offline submission queue produces retries; without a key that stays unchanged per
  submission, the same record gets written more than once.

## Next Step

The layers built up to here — the document tree, the event path, encapsulated components, the
intervening worker, caching decisions — become invisible when they work together. When
something goes wrong, which layer the problem came from cannot be understood by reading text:
the badge's color may be wrong because a style rule won unexpectedly, or a measurement may be
stale because the request was served from the cache. These two diagnoses need different
questions and different measurements. The next topic takes up the diagnostic tools the browser
offers through these questions, and its first lesson starts from the nearest one: reading which
values an element is actually painted with.
