systemd unit file handles auto-start on boot and automatic restart on crash, replacing the previous pm2 recommendation.
The Problem v3.31 Created
Moving Family Dashboard to a self-hosted Express server on Raspberry Pi was the right call — credentials off the client, no Vercel cold starts, full control over the runtime. But it introduced a new failure mode that Vercel never had: the server can be genuinely unreachable.
With Vercel, a serverless function either responds or returns a 5xx. The static files were always served from Vercel's CDN regardless of what happened to the function. Move to a Pi, and a sudo apt upgrade that triggers a reboot takes the whole thing offline for 90 seconds. The iPad on the kitchen wall goes blank. Someone asks "why is the dashboard broken?" The answer — "the Pi is rebooting" — is not the experience a wall display should deliver.
v3.32 addresses this in two layers. The service worker handles the client-side resilience: cache what you can, serve stale data when the server is gone. The systemd unit handles the server-side resilience: come back up automatically, without intervention, as fast as possible.
Registering the Service Worker
The service worker file lives at sw.js in the project root, which puts it in the highest-level scope the server allows. Registration is straightforward — added to the bottom of app-client.js after the main initialization:
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then(reg => {
console.log('[SW] registered, scope:', reg.scope);
})
.catch(err => {
console.warn('[SW] registration failed:', err);
});
});
}
The load event timing matters here. Registering during DOMContentLoaded can compete with page resources and slow down initial paint on the older iPads the dashboard targets. Waiting for load gives the main thread a chance to finish first.
The App Shell Cache
The first thing the service worker does on install is pre-cache the app shell — the HTML, CSS, and JavaScript files that make up the dashboard skeleton. These files don't change between server restarts, so they're safe to cache aggressively:
const CACHE_NAME = 'family-dash-v1';
const APP_SHELL = [
'/',
'/index.html',
'/style.css',
'/app-client.js',
'/caldav-client.js',
'/weather-client.js',
'/manifest.json'
];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME).then(cache => {
return cache.addAll(APP_SHELL);
})
);
self.skipWaiting();
});
skipWaiting() means the new service worker activates immediately rather than waiting for all existing clients to close. For a wall display that might stay open for days, waiting for the old worker to be "naturally" replaced would mean the new cache never takes effect until someone manually refreshes the iPad — which defeats the purpose.
Stale-While-Revalidate for API Responses
The more interesting part is how the service worker handles the API routes — /api/config, /api/weather, and /api/calendar. For these, the right strategy is stale-while-revalidate: return what's in cache immediately (fast, always works), then fetch fresh data in the background and update the cache for next time.
const API_ROUTES = ['/api/config', '/api/weather', '/api/calendar'];
self.addEventListener('fetch', event => {
const url = new URL(event.request.url);
const isApiRoute = API_ROUTES.some(r => url.pathname.startsWith(r));
if (isApiRoute) {
event.respondWith(staleWhileRevalidate(event.request));
return;
}
// App shell: cache-first
event.respondWith(
caches.match(event.request).then(cached => {
return cached || fetch(event.request);
})
);
});
async function staleWhileRevalidate(request) {
const cache = await caches.open(CACHE_NAME);
const cached = await cache.match(request);
const fetchPromise = fetch(request)
.then(response => {
if (response.ok) {
cache.put(request, response.clone());
}
return response;
})
.catch(() => null);
return cached || await fetchPromise;
}
The key line is the last one: return cached || await fetchPromise. If there's a cached response, it's returned immediately. The background fetch still happens — the cache gets updated for next time — but the client doesn't wait for it. If there's no cached response yet (first load, or cache was cleared), we wait for the network.
What happens when both cached is null and the fetchPromise rejects (network unreachable, server down)? fetchPromise catches the error and returns null. The service worker returns null as the response. The main app handles a null API response the same way it handles a 500 — it leaves the last rendered data in place and schedules a retry. The widget doesn't clear; it just stops refreshing until the server comes back.