Skip to content
← All articles

Building a finance app as a PWA: what we never cache and why

How Spectre's service worker is scoped, why it stores build files but never pages or account data, how push notifications are opted into, and how idle sessions end.

Spectre Editorial

· 6 min read

Contents

A progressive web app can install to a home screen, open in its own window and receive notifications. The usual advice is to cache aggressively so the app feels instant and works offline. A financial app has a different priority: what stays on the device after someone closes the app, lends their phone or signs out. This post walks through the choices behind Spectre's PWA and the web standards that shaped them.

What a service worker is, and how far it reaches

MDN describes a service worker as a script that acts as a proxy between a web app, the browser and the network. It can intercept requests, manage cached responses and receive push messages. It runs on its own thread with no access to the page, and, as both MDN and the W3C specification note, it is only available in secure contexts: pages served over HTTPS, with an exception for localhost during development.

Its reach is defined by its scope. The web.dev guide on service workers explains that, by default, the folder a worker's file sits in determines which pages it can control, and that a worker cannot control pages above that folder unless the server allows a wider scope with an HTTP header. It also recommends setting the scope as close to the root of the app as possible.

Spectre's worker is bundled by the open-source Serwist library and served from /serwist/sw.js. That route sends a Service-Worker-Allowed: / header, and the app registers the worker with a scope of /, so one worker covers the whole app. The web app manifest uses the same root scope, launches in standalone mode, and opens on the welcome screen.

What we cache: build files only

The Cache API is the storage a service worker uses for saved responses. MDN describes three properties that matter here:

  • Cached items are not updated unless your code asks for it, and they do not expire unless your code deletes them.
  • The Cache API ignores HTTP caching headers, so a server's instruction not to store a response does not apply to it.
  • Cache storage belongs to the origin and lives independently of any page or tab.

web.dev's caching guide adds that the Cache Storage API neither updates nor deletes assets when they change on the server; your code manages both. Those properties make the Cache API good for files that never change once published, and a poor fit for anything personal. Spectre's worker therefore has exactly two rules:

  1. Requests for the app's own versioned build files, under /_next/static/, are served cache-first from a cache called spectre-static, limited to 300 entries and 30 days.
  2. Every other request goes to the network. That includes every page, every server-rendered data payload and every API call.

Build files are safe to keep: their names are versioned, so a release produces new files instead of changing old ones, and they are identical for every user.

We also precache nothing at install time. Downloading the whole build up front would include large bundles, such as the 3D card viewer, that many visits never use; files are cached when first requested instead.

If a response would mean something different for a different person, it does not belong in the cache.

What we never cache, and why

Balances, transactions, card details, profile data and the pages that display them are never written to the Cache API. There are three reasons.

It would outlive the session. Because cached items persist until code deletes them, account data cached during a session would stay on the device after sign-out, available to anyone who could open the browser's storage.

Cache-control headers would not protect it. A server can mark a response as not to be stored, but MDN notes that the Cache API ignores HTTP caching headers. Once a worker decides to store something, those instructions no longer apply.

Stale money is wrong money. A cached balance can be out of date, and in a financial app that is a correctness problem.

The Push API lets a server send a message to a web app even when it is not open. According to MDN, receiving push requires an active service worker and a subscription created with PushManager.subscribe(). The W3C Push API specification requires the browser to ask the user's permission before a subscription is created.

Spectre follows that model closely:

  • Push is switched on per device from Settings. The permission prompt appears only when someone presses the button to turn it on. MDN recommends exactly this: request notification permission in response to a user action, not on page load.
  • The subscription uses userVisibleOnly: true. The W3C specification defines this as a promise that every push message will be made visible to the user, for example as a notification, rather than processed silently in the background.
  • The server identifies itself with VAPID keys. RFC 8292 describes VAPID as a way for an application server to voluntarily identify itself to a push service, using a signed token. The browser receives our public key when it subscribes.
  • Message contents are encrypted end to end to the browser. The W3C specification points to RFC 8291 for how push payloads are encrypted, and the web-push library we use on the server applies that encryption.

MDN also warns that a push endpoint is a capability: anyone who knows it can send messages to that browser, so it must be kept secret. Spectre stores each device's endpoint and keys on the server, only accepts HTTPS endpoints, and lists devices in Settings by a readable name such as the browser and operating system, never by their keys. Removing a device there, or turning push off, deletes the subscription.

Push messages use the same short, display-safe text as in-app notifications, and each carries a tag so a repeated delivery replaces the earlier one. Tapping a notification only ever opens a page inside Spectre; any link that is not a path on our own site falls back to the dashboard. People choose which categories reach them by push, except security notices, which are always on. Subscriptions the push service reports as gone are deleted, and ones that keep failing are dropped.

Sessions that end on their own

Every Spectre session has a 15-minute idle timeout, enforced by the server. Activity extends it, and the server refreshes that expiry at most once a minute. On top of that, there is an absolute limit: 12 hours for customers and 30 minutes for staff accounts, however active the session has been. A session past its limit is revoked the next time it is checked. Session lookups go to the database on every request rather than relying on a cached cookie, so a revoked session stops working on the very next request.

Because moving between pages inside the app does not always reach the server, the authenticated app also runs a small client-side companion:

  • Genuine activity, such as a tap, click, key press or mouse-wheel scroll, triggers a session check at most once a minute, which keeps the server's idle timer in step.
  • If that check finds the session has already ended, the app returns to the sign-in screen.
  • If there has been no activity for 15 minutes, the app signs out and returns to sign-in.

The server remains the authority; the client timer just stops the screen showing account data after the session has gone.

Signing out yourself ends the session on the server and returns the app to the sign-in screen. Since account data was never cached, nothing personal is left behind in the service worker's storage.

The pattern is simple to state: cache the versioned files that are the same for everyone, send everything personal to the network, ask before notifying, and let idle sessions end on their own.