# Getting started

> Build and ship your first installable PWA end to end: add a manifest, register a service worker, and meet the installability bar.

At the end of this guide an ordinary website is recognised as installable by Chrome,
Edge, and Samsung Internet, opens in its own window from the home screen or dock, and
serves its shell from cache. The two files that make the difference are a web app
manifest and a service worker; the rest of the site is unchanged.

You need a site served over HTTPS (or from `localhost` during development), two square
PNG icons at 192 × 192 and 512 × 512 pixels, and a text editor. No framework or build
step is required. In this guide we add the files to the root of the site so that the
manifest `scope` and the service worker scope both cover every page.

## Confirm the origin is secure

Service workers only register on a secure context. MDN's service worker guide states
that `localhost` counts as a secure origin, so a local dev server on
`http://localhost:8080` works; a LAN address such as `http://192.168.1.5` does not.
Open the site, and in Chrome check that the address bar shows no "Not secure" label
before continuing. Chrome's installability checker reports the exact failure
`Page isn't served from a secure origin` when this step is skipped (string from the
DevTools front-end source, `AppManifestView.ts`).

## Write the manifest

Create `/manifest.webmanifest` at the site root. The members below are the ones
Chromium browsers require for install, per MDN's installability guide: `name` or
`short_name`, `icons` with a 192 px and a 512 px entry, `start_url`, and `display`.
`id` and `scope` are optional for install but fixing them now avoids a second identity
later: MDN documents that `id` defaults to `start_url` when absent, so a later change
to `start_url` would otherwise register a second app.

```json
{
  "id": "/",
  "name": "Field Notes",
  "short_name": "Notes",
  "description": "Offline-first notes that sync when you are back online.",
  "start_url": "/?source=pwa",
  "scope": "/",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#1f4e79",
  "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/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
  ]
}
```

The `maskable` icon is a separate file with the artwork inside a central circle whose
radius is 40 % of the width; web.dev's maskable-icon article advises against reusing
one image for both `any` and `maskable` because the padding a maskable icon needs
makes the `any` rendering smaller. Link the manifest from every HTML page:

```html
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#1f4e79">
```

## Write and register the service worker

Create `/sw.js` at the site root. The worker below precaches the application shell on
`install`, deletes caches from earlier versions on `activate`, and answers navigations
and shell requests from the cache before touching the network. Bump `VERSION` whenever
a precached file changes; the browser treats a byte-different worker as an update.

```js
const VERSION = 'v1';
const SHELL_CACHE = `shell-${VERSION}`;
const SHELL = ['/', '/index.html', '/app.css', '/app.js', '/offline.html', '/icons/icon-192.png'];

self.addEventListener('install', (event) => {
  event.waitUntil(caches.open(SHELL_CACHE).then((cache) => cache.addAll(SHELL)));
});

self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(keys.filter((key) => key !== SHELL_CACHE).map((key) => caches.delete(key)))
    )
  );
});

self.addEventListener('fetch', (event) => {
  const { request } = event;
  if (request.method !== 'GET') return;
  event.respondWith(
    caches.match(request).then((cached) => {
      if (cached) return cached;
      return fetch(request).catch(() => {
        if (request.mode === 'navigate') return caches.match('/offline.html');
        return Response.error();
      });
    })
  );
});
```

Register it from the page script. The `in` check keeps the page working in a browser
without the API; the `scope` option is shown explicitly although `/` is the default for
a worker served from the root:

```js
if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () => {
    try {
      const registration = await navigator.serviceWorker.register('/sw.js', { scope: '/' });
      console.log('Service worker registered with scope', registration.scope);
    } catch (error) {
      console.error('Service worker registration failed:', error);
    }
  });
}
```

A service worker is no longer a condition for installing from Chrome's menu: the Chrome
team removed the fetch-handler requirement in Chrome 108 on Android and Chrome 112 on
desktop, and Chrome 112 ignores empty fetch handlers. The automatic install prompt
(`beforeinstallprompt`) still checks for a fetch handler, which the worker above has.

## Check the result in Chrome DevTools

Open DevTools (Command+Shift+P or Control+Shift+P, type `application`, choose **Show
Application**) and select **Manifest**. The pane lists the parsed **Identity**,
**Presentation**, and **Icons** sections; an **Installability** section appears only
when something is wrong. Under **Service workers** the **Status** line for `/sw.js` should read
`#N activated and is running` (N is the worker version number), and **Cache Storage** should list
`shell-v1` with the precached files. Reload once if the cache does not appear:
Chrome's DevTools documentation notes that the first write to a new cache may not be
detected until the page is reloaded.

:::observed
Chrome's **Installability** section uses fixed strings from the DevTools front end
(`front_end/panels/application/AppManifestView.ts`, main branch, read 2026-10-03). The
ones this guide's steps prevent are `Page has no manifest <link> URL`, `Manifest
couldn't be fetched, is empty, or couldn't be parsed`, `Manifest doesn't contain a
'name' or 'short_name' field`, `Manifest 'start_url' isn't valid`, and `Manifest
'display' property must be one of 'standalone', 'fullscreen', or 'minimal-ui'`. If one
of them is listed, fix the named member and reload the pane.
:::

## Install and launch it

On desktop Chrome the address bar shows an install icon on an installable page;
otherwise use **More** > **Cast, save, and share** > **Install page as app…** (English
UI). On Android the path is **More** > **Install and create shortcut** > **Install**.
On iOS and iPadOS there is no prompt: in Safari tap **Share** and then **Add to Home
Screen** (iOS 16.4 added the same entry to Chrome, Edge, Firefox, and Orion). Launch
the installed app: it opens without browser tabs, its first navigation carries
`?source=pwa`, and the page loads with the network disabled because the shell is
served from `shell-v1`.

## See also

- [Make it installable](/guides/installable/)
- [Offline strategies](/guides/offline/)
- [Manifest reference](/reference/manifest/)
- [Service worker lifecycle](/reference/service-worker/lifecycle/)
- [Installation criteria](https://web.dev/articles/install-criteria) (web.dev)
- [Making PWAs installable](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Guides/Making_PWAs_installable) (developer.mozilla.org)

← Back to the [Guides](/guides/) overview.