Lesson 19 / 24
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.
Contents
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.
// 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.
// 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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.