Astro
Applied12 min read

Building Offline-First Experiences for Families

A practical guide to making a family website work without a connection, drawn from the service worker behind littlebluepilot.com: what to keep, what never to keep, how to version the cache, and how to test more than a clean install.

Learned building The Little Blue Pilot

Written and engineered by

A family website is read in the back of a car, on a shared phone with a monthly data allowance, and in places where the signal comes and goes. Making it work offline is worth doing, but the useful version is narrower than “cache everything”. It opens what a child is most likely to want, it never surprises anyone with a large download, and it says plainly what still needs the internet.

This guide is drawn from littlebluepilot.com, a static Astro site for children aged 3 to 8 with a handful of interactive Svelte islands and one service worker for the whole site. How the site came to be built that way is in The Website Changed. The Story Didn’t. The code below is short and simplified, and each example was run against a test double of the browser’s cache before it was published here.

Note added 30 October 2026: since 28 October, littlebluepilot.com registers this worker only when a reader chooses “Save for offline” in its reading preferences, so nothing is kept from a first visit unless a family asks. What the worker keeps, and how, is as this guide describes; the section on slow connections says what changed.

Choose the journey before the files

Start with a list of pages, not a list of files. Ask which pages a child is most likely to want with no connection, and keep that list short. On littlebluepilot.com it is the homepage, the music, both records’ pages and every song’s page, the storybook, Ask Little Blue and the page that says the reader is offline. Everything else falls into one of two other groups.

GroupWhat is in itWhen it is stored
Kept from the first visitThe core pages above and the files they need, one size of each pictureWhen the worker installs
Kept as readAny other page a reader opens, up to the last 80, and up to 400 of their files, none larger than 5 MBWhen the page or file is fetched
Never keptSound, film, anything streamed in ranges, and anything from another siteNever

The third group matters as much as the first. A song that plays in the background or a film on a video service is the heaviest thing on the site, and it is the thing a family least expects to have downloaded.

Static pages and islands

In Astro every page is HTML when the site is built, so an offline copy of a page is already complete: no script is needed to read it. Islands are the exception. A page with an interactive island needs that island’s script, and the script needs the chunks it imports.

So compute the list from what the build produced, never by hand. The site’s build reads each core page’s built HTML, collects the stylesheets, scripts, pictures and island chunks it names, and then follows each script’s imports until nothing new appears. Fonts get the same treatment: only the font files whose character ranges cover characters the core pages actually show. A list written by hand drifts the first time someone adds a component; a list computed from the output cannot.

Precache and runtime cache

The precache is what the worker stores when it installs. It should be the smallest set that makes the core pages open. The runtime caches fill as a reader uses the site, and the rules for them differ by kind of request.

For a page, the network comes first, so a reader who is online gets today’s page. A slow network should not keep a child waiting, though, so the kept copy is used if the network has not answered within a few seconds. The fresh copy is still written to the cache when it arrives.

const NETWORK_WAIT = 4000; // ms before a kept copy is good enough
const OWN = ['core-v1', 'pages']; // never search every cache

async function kept(url) {
  for (const cacheName of OWN) {
    const hit = await caches.match(url, { cacheName, ignoreSearch: true });
    if (hit) return hit;
  }
}

async function page(event) {
  const url = event.request.url;
  const network = fetch(event.request).then(async (response) => {
    if (response.ok) await (await caches.open('pages')).put(url, response.clone());
    return response;
  });
  event.waitUntil(network.catch(() => {})); // write the copy even if it loses

  const slow = new Promise((resolve) => setTimeout(resolve, NETWORK_WAIT, null));
  try {
    return (await Promise.race([network, slow])) ?? (await kept(url)) ?? (await network);
  } catch {
    return (await kept(url)) ?? (await kept('/offline/')) ?? Response.error();
  }
}

Two details in that code are there because they went wrong first. The lookup names the caches it reads instead of searching all of them: an earlier worker on the same site looked a page up across every cache, found the previous build’s copy, and served a stale page offline. And the time limit returns a kept copy only if there is one; with nothing kept, the page still waits for the network rather than failing early.

For files, the order flips. A script chunk with a hash in its name never changes, so a kept copy is always right: look in the caches first, and fetch only what is missing, keeping the response if it came from this site, arrived whole and is under the size limit. The site enables navigation preload as well, so the browser starts fetching a page while the worker is still waking.

Client-side routers fetch pages too

A site with a client-side router, such as Astro’s view-transitions router, does not always ask for its next page as a navigation. It fetches the page with fetch(), and to a service worker that request looks like any other: its mode is not navigate, and unless the router sets one, its Accept header is the browser’s default, which does not mention HTML.

A worker that recognises a page only by its navigation mode, or by an Accept header that names HTML, will treat those requests as files. If files are served from the cache first, a reader moving around the site can then be shown a copy of a page kept on an earlier visit, even after a new version of the site has gone live, until the page is reloaded in full.

Recognise a page by the shape of its address as well:

// A browser navigation says so. A router's fetch() may not: its mode is
// "cors", and its Accept header is often the default, */*.
function isPage(request, url) {
  if (request.mode === 'navigate') return true;
  if ((request.headers.get('accept') ?? '').includes('text/html')) return true;
  return url.pathname.endsWith('/') || url.pathname.endsWith('.html');
}

On a site whose page addresses all end in a slash, as these do, the address alone is a reliable test. On a site whose addresses do not, a request with no file extension is the usual signal.

One size of each picture

A responsive picture comes in a ladder of sizes, and the browser chooses one for the screen. Precaching every size of every picture on the core pages came to 16 MB on the site’s first build. Keeping one size of each instead, the one nearest 640 pixels wide, and answering a request for any other size of the same picture with it offline, is what brought the precache down to about 7 MB. Offline, a picture may be a little softer than it would be online. It is never missing.

// "/art/cover-480.webp" and "/art/cover-960.avif" are the same picture.
const family = (path) =>
  path.replace(/-\d+(?=\.[a-z0-9]+$)/i, '').replace(/\.[a-z0-9]+$/i, '');

// Built once, from the files the worker precached.
const keptSize = new Map(); // family -> the one path kept
for (const path of PRECACHED) {
  if (/\.(webp|avif|png|jpe?g)$/i.test(path) && !keptSize.has(family(path))) {
    keptSize.set(family(path), path);
  }
}

async function standIn(url) {
  const path = keptSize.get(family(url.pathname));
  return path ? caches.match(path, { cacheName: 'core-v1' }) : undefined;
}

The size a reader’s own browser chose is kept too, as it is fetched, so a picture seen online comes back at full sharpness offline.

Versions and upgrades

A worker that cannot be upgraded cleanly is worse than no worker. Four rules keep upgrades boring.

  1. Name the precache after its contents. The site hashes the list of precached files and their bytes, and uses the hash in the cache’s name. A build that changes nothing produces the same name; a build that changes anything produces a new one.
  2. Install from one build. Files whose names carry a hash may come from the browser’s HTTP cache; everything else is fetched past it, so one install never mixes two builds.
  3. Clear only what you own. When the new worker activates, it deletes the earlier precaches and the retired worker’s caches, and nothing else. The pages and pictures a reader kept stay.
  4. Let the browser find the new worker. The worker’s own file is served with Cache-Control: no-cache, so a new build’s worker is noticed on the next visit.

The site’s worker does not skip the waiting phase. A new version installs in the background and takes over only when every tab of the site has closed, which keeps an open page and the worker serving it from the same build. The cost is that an update arrives one visit late, which suits a site that changes by the week rather than by the minute.

Retiring a worker needs the same care. When the site moved to one worker for every page, the old worker that kept only Ask Little Blue was still installed in families’ browsers. Its address now serves a replacement whose only job is to clear its own caches, unregister itself and reload the page. Deleting the file instead would have left the old worker answering, for as long as the browser kept it.

Slow connections and Save-Data

Most of a slow connection is handled by the rules above: the time limit on pages, files from the cache first, nothing streamed, nothing from another site. The part still open on littlebluepilot.com is the first visit.

About 7 MB is precached in the background after a family’s first page, which is more than a phone should spend without being asked. The usual remedy is to precache only the shell (the offline page, the stylesheets, the fonts and the core scripts), keep pages and pictures as they are visited, and skip precaching altogether when the browser reports that the reader has asked to save data. Chromium-based browsers expose that preference as navigator.connection.saveData, and send a Save-Data header; other browsers do not, so treat it as a signal to honour when present, not a switch to depend on.

The balance to strike is the one this guide began with: a smaller first download, without losing the pages and pictures a family expects to open offline.

Note added 30 October 2026: littlebluepilot.com has since settled its first visit another way. It downloads none of the offline copy until a reader presses “Save for offline”, and the copy it then keeps is 132 files, about 6.2 MB, measured on 30 October. A shell-only precache and Save-Data remain the remedies for a site that installs its worker on every visit. The change is described in Offline, only when asked.

When the network fails

  • A page that was never kept gets the offline page, which says what is available and does not pretend.
  • A link out of a page that works offline, such as a chapter linked from Ask Little Blue, checks first with one small request. If nothing answers, the page says that this one needs the internet, instead of letting the tap fail on a browser error.
  • Data the page needs is fetched fresh when there is a connection and read from the cache when there is not. Ask Little Blue’s knowledge file works this way.
  • The promise is shown only when it is true. Ask Little Blue’s “Works offline now” appears only once the worker is active, in a space laid out from the first paint so its arrival moves nothing.

A family should be able to read, in words, what is stored. The site’s privacy page states the size of the offline copy, and the build fails if the real figure drifts more than a quarter of a megabyte from what the page says. A number in prose goes stale without anyone noticing; a number held to the build cannot.

Video and audio from elsewhere

The films live on the artist’s video channel, and the players load only when a child presses play. They need a connection, and the worker leaves them alone: it handles only requests to its own site. The theme song is the site’s own file, but it is streamed in ranges, so it is left to the browser as well. Say this where a family will read it. The site’s privacy page puts it in one sentence: the films, the theme song and the music services are never kept and still need the internet.

Without JavaScript

A service worker is registered by script, so without JavaScript there is no offline copy, and that is acceptable because nothing depends on one. Every page is readable as HTML. Ask Little Blue’s question shelf is made of native disclosure elements, so its answers open without script. The Sky Map is also written out as a plain flight plan. Treat the worker as an improvement to a site that already works, not as the thing that makes it work.

Test more than a clean install

A clean install on a fast connection is the case that almost always works. The checks worth writing are the others.

  • The upgrade. Install the previous worker first, the real one from the last build, then load the new site and confirm the old worker gives way and the core pages still open offline. The site’s audit does exactly this with the old site’s Ask-only worker.
  • The journey offline. Visit once with a connection, then cut it and open every core page at a phone width and a desktop width; open a page read earlier, from the kept copy; open one never read, and get the offline page.
  • A stale cache. Leave a previous build’s caches on the device and check that nothing reads them.
  • A move made by the router. Move between pages inside the site, publish a change, move again, and check which copy arrives. This is the test that catches the problem described above.
  • A slow and a failing data file. Make the data a page needs slow, then unavailable, and check the page still answers.
  • The stated size. Hold the number on the privacy page to the build.

Two things the site has not tested yet, and should: a real device losing its connection mid-visit, and a new worker arriving while the site is open in another tab.

A short checklist

  • The core journey is a short list of pages, chosen before any files.
  • The precache list is computed from the built output.
  • One size of each picture is precached, and any size can stand in for it.
  • Pages are network first with a time limit; hashed files are cache first.
  • A client-side router’s requests for pages are recognised as pages.
  • Sound, film, ranges and other sites are never cached.
  • Precache names carry a content hash; activation deletes only old precaches.
  • The worker file is served with no-cache.
  • A retired worker is replaced by one that unregisters itself.
  • The offline copy’s size is written where families read, and held to the build.
  • The first visit stays small, and Save-Data is honoured where a browser reports it.