Web · How-to

How to build an offline-first notes app with a service worker

I wanted a notes app that opened instantly on my phone, worked with no signal, and did not need an app store. A progressive web app is the right tool for that: plain HTML, CSS and JavaScript, a service worker to keep the shell available offline, a manifest so Android offers to install it, and localStorage for the notes themselves. The repo is pwa_notes. This post walks through building it from an empty folder.

There is no framework and no build step. Everything here runs by copying the files to any static host that serves over HTTPS, which is a requirement for service workers. GitHub Pages works.

What you will end up with

Step 1: the app shell

Start with the smallest page that does the job. The structure is a text area for the current note, a list of saved notes, and a script tag. The two important lines in the head are the manifest link and the theme colour, which Android uses for the status bar once the app is installed.

index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta name="theme-color" content="#303055">
  <link rel="manifest" href="manifest.json">
  <link rel="stylesheet" href="style.css">
  <title>Notes</title>
</head>
<body>
  <textarea id="note" placeholder="Write something"></textarea>
  <button id="save">Save</button>
  <ul id="list"></ul>
  <script src="app.js"></script>
</body>
</html>

Step 2: store notes in localStorage

For a personal notes app, localStorage is enough. It is synchronous, available offline by definition, and survives app restarts. The storage is a single JSON array under one key. Every save rewrites the array; at the scale of personal notes that is fine. If you later need thousands of notes or attachments, IndexedDB is the upgrade path, but it is not needed to get started.

The last block of app.js registers the service worker. Registration is asynchronous and must be guarded, because older browsers and some privacy modes do not expose navigator.serviceWorker.

app.js
const KEY = "notes";
const $ = (id) => document.getElementById(id);

function load() {
  try { return JSON.parse(localStorage.getItem(KEY)) || []; }
  catch { return []; }
}
function persist(notes) {
  localStorage.setItem(KEY, JSON.stringify(notes));
}
function render() {
  $("list").innerHTML = load()
    .map((n) => `<li><time>${new Date(n.ts).toLocaleString()}</time> ${escapeHtml(n.text)}</li>`)
    .join("");
}
function escapeHtml(s) {
  return s.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c]);
}

$("save").addEventListener("click", () => {
  const text = $("note").value.trim();
  if (!text) return;
  persist([{ ts: Date.now(), text }, ...load()]);
  $("note").value = "";
  render();
});
render();

// register the service worker once the page has loaded
if ("serviceWorker" in navigator) {
  window.addEventListener("load", () => {
    navigator.serviceWorker.register("sw.js").catch(console.error);
  });
}

Step 3: the service worker

The service worker is what makes the app open offline. It has two jobs. On install it opens a named cache and adds every file in the app shell. On fetch it answers requests from the cache first and only goes to the network for anything it does not have.

The cache name carries a version. When you change any shell file, bump the version: the new worker installs with a fresh cache, and the activate handler deletes the old one. Without this you will ship a change, reload, and wonder why nothing happened, because the old cache is still answering.

sw.js
const CACHE = "notes-v1";
const SHELL = [
  "./",
  "./index.html",
  "./style.css",
  "./app.js",
  "./manifest.json",
  "./icons/icon-192.png",
  "./icons/icon-512.png"
];

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

self.addEventListener("activate", (event) => {
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
    )
  );
  self.clients.claim();
});

self.addEventListener("fetch", (event) => {
  if (event.request.method !== "GET") return;
  event.respondWith(
    caches.match(event.request).then((hit) =>
      hit || fetch(event.request).then((res) => {
        // cache anything else we fetch successfully, for next time
        const copy = res.clone();
        caches.open(CACHE).then((c) => c.put(event.request, copy));
        return res;
      })
    ).catch(() => caches.match("./index.html"))
  );
});

Cache-first is the right strategy for an app shell that changes rarely. For data that must be fresh you would use network-first with a cache fallback instead. This app has no server data at all, so cache-first everywhere is correct.

Step 4: the manifest

The manifest is a JSON file that tells the browser the app's name, icons, start URL and how to display it. With a valid manifest, a registered service worker and HTTPS, Chrome on Android treats the page as installable and shows an install prompt. The app then appears in the launcher with its icon and opens without browser chrome.

manifest.json
{
  "name": "Notes",
  "short_name": "Notes",
  "start_url": "./index.html",
  "display": "standalone",
  "background_color": "#e8e8f2",
  "theme_color": "#303055",
  "icons": [
    { "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
  ]
}

You need at least a 192 and a 512 pixel icon for the install prompt. Any PNG will do; I used a flat single-colour square with a glyph.

Step 5: test it

  1. Serve the folder over HTTPS, or over http://localhost, which browsers treat as secure for development.
  2. Open DevTools, Application tab. Confirm the service worker is activated and the cache contains the shell files.
  3. Tick "Offline" in the Service Workers panel and reload. The app should still open and your notes should still be there.
  4. On an Android phone, open the URL in Chrome and accept the install prompt. Turn on aeroplane mode and launch it from the home screen.

Shipping an update

The part of PWAs that trips people up is how updates reach installed users. When you deploy new files, the browser re-fetches sw.js on the next visit and compares it byte for byte with the installed worker. If it differs, the new worker installs in the background, caches the new shell under the new cache name, and takes over on the next load. If sw.js is byte-identical, nothing happens, even if app.js changed. That is why the cache name must change with every release: it changes the worker file, which triggers the update, and it gives the new shell a fresh cache.

Calling skipWaiting and clients.claim, as the worker above does, makes the new version active immediately instead of waiting for all tabs to close. For a single-user notes app that is the behaviour you want. For a multi-tab app with in-memory state you might prefer to show a "new version available, reload" message instead and let the user choose the moment.

Notes in localStorage are untouched by any of this. The cache holds the application shell; the storage holds the user's data. Keeping those two concerns separate is what makes it safe to wipe and rebuild the cache on every release.

What I learned

Source on GitHub >