In the previous article, I told the story of why I built Mes mots, a pictogram communication app for a kid who doesn't talk yet. One of the project's constraints is that it has to work in airplane mode, on a tablet that might not see wifi for days. This article is only about that: how you build something truly offline, and above all how it still gets updated without a permanent connection.
"Offline-first", in most projects that carry the label, means something different from what it means here. Worth clarifying before getting into the code.
The misunderstanding around "offline-first"
Most apps labeled offline-first actually have a server behind them. A Trello, a Google Docs, a mail client: they cache, let you write offline, then sync and reconcile conflicts once the network comes back. That's a real engineering problem, conflict resolution, vector clocks, three-way merges, and a good chunk of the writing on the topic is about exactly that.
Mes mots doesn't have that problem, because it doesn't have a server at all. There's nothing to sync, because there's no one on the other end. The only thing that ever "arrives" from the network, occasionally, is a new version of the app's own code, not data. That's a much rarer category: not offline-first with a network fallback, but offline, period, with an occasional update.
That removes a whole class of problems (no conflicts, no auth token to refresh, no partial sync state to display). It brings in another one, narrower and stranger: getting a code update to arrive without ever disturbing whatever the user is doing right now, while having literally no way to talk to them other than through the screen they're already using.
What needs caching, and the file everyone forgets
The project uses the vite-plugin-pwa plugin, which generates a Workbox-powered service worker from a simple declarative config. The starting point is the list of files to precache:
workbox: {
// phrase MP3s must be cached: without them the app is mute offline
globPatterns: ['**/*.{js,css,html,png,svg,woff2,mp3}'],
// a previous version's cache has already served a bundle gone from disk, which
// silently hid a shipped feature: the family would stay on an old version
// without knowing it, with no way to notice
cleanupOutdatedCaches: true,
},
The trap lives in a forgotten extension. The {js,css,html,png,svg,woff2} pattern
catches everything a typical frontend build produces. Except this app talks: every tile speaks
its word by playing an .mp3 file. Without mp3 in the list, the app
installs perfectly fine, opens offline with no error, tiles render, everything looks normal.
And nothing comes out of the speaker. No exception in the console, no error screen: just a
silence a parent would discover in airplane mode, at the worst possible moment.
That's the nature of a caching bug in a PWA: it doesn't break anything, it quietly removes a feature. The only safety net is listing, explicitly, everything the app needs to function without a network, not just the code.
A root domain, a subfolder, and a blank screen with no error
The app runs in two places: on the family's tablet, served at the root of a domain
(anime-sanctuary.net), and as a public demo on GitHub Pages, under
/mes-mots/. A service worker and its precached assets are scoped to a specific
base URL, and a PWA built for the root looks for its files one level too high when served from
a subfolder.
/**
* The tablet serves the app at its domain root. The public demo lives under
* `/mes-mots/` on GitHub Pages, and without this prefix it would look for its files
* one level too high: blank page, no readable error. `BASE_PUBLIQUE` exists only for it.
*/
const BASE = process.env.BASE_PUBLIQUE ?? '/'
One environment variable, read once at build time, decides between the two cases. The lesson here isn't the variable itself, it's the failure mode: a wrong base path doesn't throw a clean exception you can read in devtools, it produces a blank page. The kind of bug you can't debug remotely, on the tablet of someone with no terminal.
An update that arrives without being asked for
registerType: 'autoUpdate' in the plugin config means the service worker downloads
the new version as soon as it sees network, installs it in the background, and takes over on
its own, no "an update is available" banner to click. What that mode doesn't tell you is that
the page already open keeps running the old JavaScript until a full reload.
The browser exposes an event to find out: controllerchange, fired when a new
service worker has just taken control of the page.
/**
* The new service worker takes over on its own, but the displayed page keeps running
* the old one until a reload: it took two loads to see a fix, and the listener only
* lived in the parents screen, absent from the child's screen. Here it starts at
* boot. `controller` doesn't exist on the very first install, which would otherwise
* look like an update.
*/
const updateReady = ref(false)
if (navigator.serviceWorker?.controller) {
navigator.serviceWorker.addEventListener('controllerchange', () => {
updateReady.value = true
})
}
The comment tells a real fixed regression: this listener originally lived in the parents
screen, never visible from the child's screen, the one that actually runs all day. As a result,
an update taken by the service worker was only detected if a parent opened their space, which
could take days. The guard on controller avoids, in turn, confusing the very first
install (where no controller exists yet) with an actual update.
Never reload a tile mid-speech
Detecting that an update is ready doesn't say when to apply it. Reloading the page immediately would cut a word mid-speech, erase a phrase being composed on the sentence bar, or lose a parent's half-filled form in the settings screen. For a kid who doesn't understand why the screen changed, each of those is a bad surprise.
The fix is defining an idle state and only applying the update then:
/**
* The reload waits until the tablet isn't serving anyone: otherwise it would cut off
* a word mid-speech, erase a phrase being composed, or lose a parent's half-filled
* form. At rest, it goes unnoticed.
*/
const tabletIdle = computed(
() =>
mode.value === 'child' &&
speakingTileId.value === null &&
!isReadingPhrase.value &&
phrase.value.length === 0,
)
watch([updateReady, tabletIdle], () => {
if (updateReady.value && tabletIdle.value) location.reload()
})
Four conditions have to be true at once: the child screen is shown (not the parents space), no tile is currently speaking, the phrase bar isn't being read back, and the composed phrase is empty. The reload waits patiently for all four to turn green, and only fires then. On an app that runs all day and never gets closed, that moment always ends up arriving, usually within minutes.
It's a rule that generalizes well beyond this project: a silent background update is harmless for a blog or a dashboard you can refresh without thinking. It turns dangerous the moment the page's state has value, a form in progress, media mid-playback, unsaved input. At that point you need an explicit safe point, not just a timer.
Knowing which version is running, without opening devtools
Last detail, tiny in the code, decisive in practice: every build prints its own build date.
/** Build date, shown in the parents screen: "which version is running?" should be
* answered by looking at the screen, not by digging through a service worker cache. */
const BUILD_VERSION = new Date().toISOString().slice(0, 16).replace('T', ' ')
A developer diagnoses a caching problem by opening the Application tab in devtools. A parent will never open that tab, and shouldn't have to. Showing the version in plain sight in the parents screen turns a debugging question ("did the service worker actually pick up the latest version?") into one anyone can answer just by looking at the screen.
Conclusion
The network, in web development, we've learned to be wary of it: timeouts, retries, loading states, a whole vocabulary built around its absence. The silence of an update arriving unseen, much less so. Building an app that never asks for the network doesn't remove complexity, it moves it to a place you rarely think about: the exact moment it becomes safe to swap the ground under the feet of someone who's currently standing on it.