Offline-first PWA
SkillAI & modelsRules for building offline-first Progressive Web Apps that launch instantly from the home screen. Use when building, reviewing or debugging a PWA, or anything touching a service worker, a web app manifest, precaching, an in-app update prompt, iOS home screen behaviour, or safe-area/notch layout.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Offline-first PWA skill
What this skill tells your AI
The instructions your AI receives, as published by mattiasgeniar/offline-first-pwa in SKILL.md and read by ahel’s review.
An offline-first PWA is an app that launches from cache in under a second with no network at all, and updates itself in the background afterwards. Every rule below follows from that sentence, and every one of them is here because it was gotten wrong first.
Stack-neutral. Where a rule needs a build step it says "your bundler's manifest", not a specific tool.
If you only apply three
- Serve navigations cache-first, never network-first. A slow network does not reject a fetch, it hangs.
- Everything the app launches must be precached, including assets only fetched at runtime. Missing one only breaks a cold or slow launch, which nobody tests by hand.
- Test slow, not offline. They are different failure modes and only one of them is handled by accident.
Service worker
- Navigations are cache-first. Never network-first. This is the big one and it is
counter-intuitive enough to get written the wrong way round on purpose. The reasoning for
network-first is usually "the page contains app logic, so a cached page is a stale app".
True, and irrelevant next to how it fails: a
fetch().catch(() => cache)fallback only fires when the device is properly offline. On a network that is technically up and crawling, the worker just waits. Measured with the page already in cache: 8s of added latency produced 8.1s to first paint, 20s produced 20s. Cache-first made the same launches 64ms and 112ms. - Freshness is a separate mechanism from the fetch strategy. Do not pay latency on every
launch to solve a problem that happens on deploy day. Serve from cache, refetch the page in
the background, compare the response body to what was served, and only if it actually
differs
postMessagethe client so it can offer a refresh. - Read a cached response's body before you hand it to
respondWith(). Once returned, the body is locked andclone()throws. Getting this wrong is near-invisible: the throw happens inside the promise passed towaitUntil(), so it rejects in the background with nothing in the page console, the cache silently stops refreshing, and the app serves the same stale HTML forever. Take the.clone().text()synchronously, at the point where you still own the response. - Register the worker during parse, not on
window.load. Waiting for load means waiting for every subresource, so the cache gets warmed last on exactly the slow connection where it matters most. Registration does not block rendering. - Version the worker on a hash of the precache list plus the templates that compose the page. Asset hashes alone miss an HTML-only edit, and then a copy change ships to nobody.
start_urlandscopecarry no trailing slash (/app, not/app/), so the worker controls the app's own entry page. If the script is served from inside that path, sendService-Worker-Allowed: /appso its scope can widen one level up.- One app, one worker, one scope. A nested page that registers a second worker puts a second cache of the same shell inside the first one's scope, neither aware of the other's version, and whichever claims the page last decides what you see. Nested apps share the parent's worker and scope, and get their own manifest, name and icon, which is what tells them apart on a home screen.
Cache-Control: no-cacheon the worker script, immutable on hashed build assets. A cached worker script is an app that can never update.
Precaching
- Whatever the app launches has to be in the precache list.
start_urland the offline page are the two entries it cannot open without. Drift there still works online and only breaks a cold or slow launch, so assert the list against your build manifest in CI rather than trusting a manual check. - Walk the whole bundler graph, not just the entry files. Shared chunks, the entry's CSS, imported assets, and the chunks only fetched at runtime. A self-contained single-file entry hides the need for this, right up until one app is not.
- So reference runtime assets by importing them, never by URL. A literal
new Audio('/sounds/x.mp3')is invisible to the build manifest, works online, and goes quiet exactly when someone is offline.import url from './x.mp3'lands in the manifest and gets precached. - Nothing goes on the critical path that the installed app cannot use. The classic is the
install prompt: a couple of hundred KB that can only ever render for someone who has not
installed the app, downloaded and parsed on every launch of the installed app to render
nothing. Dynamically import it behind a
display-mode: standalonecheck. Apply the same test to anything new. - No webfonts. A render-blocking request on the critical path of an app whose entire selling point is opening instantly, and every platform ships a good sans.
Updates
skipWaiting()+clients.claim(), then show a small "new version, refresh" banner oncontrollerchangeand reload on tap. Pollregistration.update()onvisibilitychangeand roughly hourly, which covers an installed app left open for days.- "Already controlled" is not the same test as "there is an update". A worker with a
broader scope controls a nested page from the moment you arrive, so
controllerchangefires a second later with nothing new anywhere and the banner shows on first launch. Comparenavigator.serviceWorker.controller.scriptURLbefore and after instead: the same URL means a new version of that worker, a different URL is just a handover. Reproduce it by loading the parent scope first and then navigating in; opening the nested page cold does not show it. - The first launch after a deploy is still slow, by design. The old worker serves that navigation, updates itself in the background, and launch two onward is fast. Do not chase it as a bug.
iOS
- The home screen icon and title come from
apple-touch-iconandapple-mobile-web-app-title, not from the manifest. Set both per app. - iOS ignores
orientationin the manifest. An app that needs one orientation still has to say so itself, with a CSS-only@media (orientation: portrait)cover that is there before any script has loaded. - iOS has no programmatic install.
beforeinstallpromptdoes not exist there, so the install affordance is Share-sheet instructions, shown only on mobile, hidden once installed, and remembered as dismissed. - Detect standalone with an inline script in
<head>, during parse, and hide the chrome that only makes sense in a browser tab (a link back to a hub, a site header). Run it any later and that chrome flashes up before it disappears. - In anything you tap repeatedly, iOS pops its own "Copy / Look Up" bar over the UI. It
reads a fast second tap as a double tap, selects whatever is underneath, and shows the
selection bar. It takes both halves to stop, and the second one is the one that gets missed:
- CSS on the whole layout, not just the element you tap:
-webkit-user-select: none,-webkit-touch-callout: none,-webkit-tap-highlight-color: transparent,touch-action: manipulation. The bar belongs to whichever element takes the tap, so scoping this to the canvas alone leaves the surrounding UI exposed. preventDefault()onpointerdowndoes not suppress it, ontouchstartit does. iOS hangs selection and the callout off the touch event, so add atouchstartlistener with{ passive: false }whose only job ispreventDefault().pointerdownfires first, so app logic can stay there.- Chrome cannot verify the callout half: it does not implement
-webkit-touch-callout, so it drops the declaration fromcssRulesand from computed styles even though the property is in the built CSS. Grep the built stylesheet, then confirm on a real iPhone.
- CSS on the whole layout, not just the element you tap:
Layout
- Assume the top of the screen is not yours. Installed PWAs run full-bleed
(
viewport-fit=cover, translucent status bar), so the top strip is clock, battery, notch or dynamic island, and the bottom is the home indicator. Never put the first row of UI flush against the top. - Pad with
max(env(safe-area-inset-top), <minimum>), never a bareenv().env(safe-area-inset-top)is0in a normal browser tab and on devices that expose no inset, so on its own it reserves nothing and the UI slides under the status bar. Themax()keeps a usable minimum everywhere and grows to the real inset when there is one. Anythingfixedto an edge adds the same value to its offset. - The page itself never scrolls.
100dvhwithoverflow: hiddenas a flex column, and the content pane is theflex: 1; min-height: 0slot that carriesoverflow-y: auto. An app that scrolls as a page does not read as an app. - To fit a fixed-ratio box into the leftover height, use container-query units, not a
dvhguess.container-type: sizeon the slot, then size the child offcqw/cqh. A guess likemax(17rem, 30dvh)is wrong at some viewport by construction, andheight: 100%together withaspect-ratiodoes not work either: the explicit height wins, so as soon asmax-widthbinds you get rectangles instead of squares. - A phone layout stretched to a tablet looks like a mistake. Cap the content column, centre it, and let controls grow taller rather than wider: a finger on a tablet is the same size as on a phone.
Testing
-
Test slow, not offline. Offline is the failure mode that works by accident; a network that is up and crawling is the one that hangs. Put real latency in front of the app (a small reverse proxy that sleeps before piping the upstream response works, and lets you change the delay at runtime) and read
performance.getEntriesByType('navigation')plusfirst-contentful-paint. -
DevTools network throttling does not apply to fetches made inside a service worker. It will show you a fast load and tell you nothing.
-
When testing the update path, change something the version hash does not cover. Editing a template bumps the worker version, so
controllerchangefires and a completely broken background-content-update path still looks like it works. Change a string that is rendered but not hashed, confirm the worker version is unchanged, and then the banner can only have come from the mechanism under test. Check the negative too: relaunch with nothing changed and assert the banner stays hidden. -
The worker will hand you yesterday's HTML while you debug. Navigations are cache-first by design, so after a build a plain reload can still run the previous page and the previous script, and you will be looking at a bug you already fixed. Unregister and drop the caches before concluding anything:
(await navigator.serviceWorker.getRegistrations()).forEach(r => r.unregister()) (await caches.keys()).forEach(k => caches.delete(k))
Moving an app to another origin
- An installed PWA cannot follow its origin. A home screen icon is welded to the origin it was installed from and there is no API to move it. The best available outcome is: get the window across, then tell the user the icon is stale.
- Never redirect a service worker script. The browser fetches it to check for an update; a redirect makes that fetch a network error, a failed update leaves the old worker registered, and that worker serves navigations from its cache by design. The app then keeps opening its cached copy of the old site and never learns anything changed.
- Serve a real worker at the old URLs instead, whose whole job is to delete every cache,
unregister itself, and
client.navigate()its windows to the new origin. It still needs itsService-Worker-Allowedheader, because the old registration's scope may sit above the script's own directory, and without it the update is rejected on scope, which fails in exactly the same invisible way as the redirect. - The first launch after the move still shows the old app, once. The old worker serves that navigation before it has any idea a new script exists, then moves the window a second later.
Signals
- GitHub stars
- 27
- Forks
- 2
- Last commit
- Aug 2026
Advanced
- Catalog kind
- skill
- Gateway key
offline-first-pwa- Source
- github.com/mattiasgeniar/offline-first-pwa