This is part 2 of FieldKit, a series where I build one real Progressive Web App and use it to dig into what modern PWAs can actually do. FieldKit is a field-notes app — geotagged notes, photos, and audio that work with no signal. It's open source (on GitHub). In part 1 we made it work offline. Now let's make it installable, so it lives on the home screen and launches like a native app.
Why bother making a web app installable?
In part 1 we did the hard part: FieldKit already loads and works with zero network. So why install it at all?
Because installation changes the relationship the user has with your app. An installed PWA:
- launches from the home screen or app drawer with its own icon,
- opens in its own window with no browser chrome — no address bar, no tabs,
- shows up in the OS app switcher like any other app,
- and gets a few capabilities that only unlock once installed (we'll need this in a later part — push notifications on iOS require an installed PWA).
For a field-notes tool this matters: you're standing on a trail, you want to tap an icon and start writing, not hunt for a bookmark. Installability is what turns "a website I saved" into "an app I use."
Two ingredients make it happen: a web app manifest (so the browser knows the app can be installed and how it should look), and a bit of JavaScript (so we control when and how we ask).
The web app manifest: your install contract
The manifest is a JSON file that tells the browser everything it needs to present your app as installable. FieldKit links it from the <head>:
<link rel="manifest" href="/manifest.webmanifest" />
Here's the manifest, trimmed to the fields that actually drive installation:
{
"name": "FieldKit — Offline Field Notes",
"short_name": "FieldKit",
"start_url": "/?source=pwa",
"scope": "/",
"display": "standalone",
"background_color": "#0b1120",
"theme_color": "#0f766e",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
The fields that matter most:
-
display: "standalone"— this is the one that makes it feel like an app: no browser UI, just your content in its own window. (fullscreenandminimal-uiare the other useful values.) -
start_url— where the app opens from the icon. I add?source=pwaso analytics can tell launches-from-icon apart from browser visits. -
icons— you need at least a 192px and a 512px icon. Ship amaskableone too: Android crops icons into circles/squircles, and a maskable icon keeps its important content inside the "safe zone" so it doesn't get clipped. Test it with maskable.app. -
theme_color/background_color— the toolbar tint and the splash-screen background while the app boots.
The install criteria browsers actually check
A manifest alone isn't enough. Chromium won't offer installation until your app clears a bar:
- served over HTTPS (or
localhostin dev), - a manifest with
name/short_name, astart_url, adisplayofstandalone/fullscreen/minimal-ui, and the required icons, - a registered service worker (which we built in part 1).
Miss any one and the install prompt simply never appears — and the browser won't tell you why. Chrome DevTools → Application → Manifest lists exactly what's missing; that panel is your best friend here.
Checking this by hand on every project got repetitive enough that I built a tool for it: PWA & Installability Inspector, a small open-source Chrome extension (MIT — source). Click it on any site and it gives a one-click installability verdict — will beforeinstallprompt fire, and if not, which requirement is missing — plus a field-by-field manifest audit, a check that your icons actually load and include a maskable variant, and service-worker/offline readiness. Because fixing is the tedious part, it also generates a corrected manifest and a full icon set (maskable included) from a single image. Everything runs locally — no servers, no telemetry.
Full disclosure: it's mine, and I built it while working through this exact series, so it encodes the same checklist as this article. DevTools does the same job by hand; the extension just makes the loop faster.
Taking control of the install prompt
You could let the browser nag users with its default install UI. Don't. A better pattern: catch the event, suppress the default, and surface your own install button at a moment that makes sense.
When Chromium decides the app is installable, it fires beforeinstallprompt:
let deferredPrompt = null;
window.addEventListener("beforeinstallprompt", (event) => {
event.preventDefault(); // stop the default mini-infobar
deferredPrompt = event; // stash the event for later
if (!isStandalone()) els.installBtn.hidden = false; // reveal our button
});
Then, when the user clicks our button, we replay the stashed event:
els.installBtn.addEventListener("click", async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt(); // show the OS dialog
const { outcome } = await deferredPrompt.userChoice; // "accepted" | "dismissed"
deferredPrompt = null; // the event can only be used once
els.installBtn.hidden = true;
if (outcome === "dismissed") toast("No worries — install anytime from the menu");
});
Two things people trip on here. First, the event is single-use — once you call prompt(), that captured event is spent; you can't stash and reuse it. Second, you can't call prompt() on a timer or page load — it must be triggered by a user gesture, or the browser ignores it.
When the install succeeds — whether through our button or the browser's own menu — the appinstalled event fires, and we clean up:
window.addEventListener("appinstalled", () => {
els.installBtn.hidden = true;
els.iosHint.hidden = true;
deferredPrompt = null;
toast("FieldKit installed");
});
Don't show "Install" to someone who already installed
If the user opens the installed app, showing an install button is silly. Detect standalone mode and hide the UI:
function isStandalone() {
return (
window.matchMedia("(display-mode: standalone)").matches ||
window.navigator.standalone === true // legacy iOS Safari
);
}
The display-mode media query is the modern, cross-browser check. The navigator.standalone fallback exists because older iOS only understands that. Belt and suspenders.
The iOS catch
Here's the part the happy-path tutorials skip. iOS and iPadOS Safari do not support beforeinstallprompt at all. There is no event, no programmatic prompt, no install button you can wire up. The only way to install on iOS is the user manually tapping Share → Add to Home Screen.
So on iOS you can't automate — you can only guide. FieldKit detects iOS and shows a small hint instead of a button:
function isIOS() {
return /iphone|ipad|ipod/i.test(navigator.userAgent) && !window.MSStream;
}
function maybeShowIosHint() {
els.iosHint.hidden = !(isIOS() && !isStandalone());
}
<div id="ios-hint" class="ios-hint" hidden>
<span>Install FieldKit: tap <strong>Share</strong>, then <strong>Add to Home Screen</strong>.</span>
<button id="ios-hint-close" aria-label="Dismiss">×</button>
</div>
A few iOS gotchas worth knowing while you're here: iOS uses apple-touch-icon for the home-screen icon (we link one in the <head>), it reads display for standalone launch, and historically it has been stingy about storage for non-installed sites. Installing is genuinely the better experience on iOS — you just can't force the door.
Honest support picture
-
Manifest +
display: standalone+ installation: works across Chromium (Chrome, Edge, Samsung Internet, Android WebView) and, in the manual form, on iOS/iPadOS Safari. Firefox on desktop doesn't install PWAs; Firefox on Android does add-to-home-screen. -
beforeinstallprompt+appinstalled: Chromium only. Safari and Firefox never fire them. That's exactly why the code above treats the custom button as a Chromium enhancement and falls back to the manual iOS hint. Design for "no prompt event" as the baseline. -
getInstalledRelatedApps()(checking whether your app is already installed) is Chromium-only too — handy, not reliable.
As always: confirm current behavior on caniuse and MDN before you lean on any of it.
How this compares to Electron
Installation is the sharpest contrast between the two worlds:
-
Electron is the install. You ship an
.exe/.dmg/.AppImage, the user runs an installer, and you own the window, the icon, the auto-updater, the OS integration — all of it, unconditionally, on desktop only. It's heavy (you're shipping a whole Chromium), but there's zero ambiguity about whether the app is "installed." - A PWA flips the trade-off. There's no download, no installer, no app-store review — the user taps "Install" and it's there in a second, on desktop and mobile, and it updates itself silently via the service worker. The cost is that you don't fully control the experience: the browser decides the install criteria, Safari makes it manual, and you get less OS integration than a native shell.
Rule of thumb from having shipped both: if you need deep desktop-OS integration, Electron still wins. If you want the lowest-friction path onto a user's phone and desktop without app stores, an installable PWA is hard to beat — and it costs you a manifest and about 30 lines of JavaScript.
Try it
Serve FieldKit over localhost, open Chrome DevTools → Application → Manifest, and check it reports installable. You'll see an install icon in the address bar, or FieldKit's own Install button. Install it, reopen from the icon, and notice the browser chrome is gone — and the Install button no longer shows. On an iPhone, you'll get the Add-to-Home-Screen hint instead.
git clone https://github.com/JohnJunior/FieldKit.git
cd FieldKit
npx serve .
Next up (FieldKit #3): capturing and playing back media — using the camera and microphone with getUserMedia and MediaRecorder, and all the iOS permission quirks that come with them.


Top comments (0)