Skip to main content
Pylon keeps synced rows in IndexedDB and queues writes made with no network. With offline set in the manifest, the app also opens with no network: Pylon serves a web app manifest, a service worker, and the pages to keep on the device.

Turn it on

Add offline to buildManifest:
Pylon then does these things:
  • It serves /manifest.webmanifest from this config. A public/manifest.webmanifest or app/manifest.webmanifest file replaces it.
  • It adds the manifest link, the theme color, and the home-screen icon tags to every page.
  • It writes a service worker for each build and serves it at /pylon-sw.js.
  • It registers the worker in production. pylon dev does not register it, and removes one that an earlier build left.

What the worker keeps

When the worker installs, it downloads these files:
  • The build files that the pages of routes load: scripts, chunks, stylesheets, and fonts.
  • The web app manifest and the icons.
  • The HTML of each route in routes that has no dynamic segment.
Build files are content-hashed, so the worker serves them from its cache first. It never caches /api/*, /auth/*, the sync connection, or the dev endpoints. Data comes from the sync engine, not from the HTTP cache. When a page load fails because there is no network, the worker answers with the kept HTML for that route. A route with a dynamic segment, such as /leads/[id], uses the start URL’s HTML. The client then renders the real URL from the local replica. A load of a route that is not in routes opens the start URL. On an offline route, a slow network gets 3 seconds. Then the worker shows the kept page.

Render rule for offline routes

An offline route renders with no server. Follow these rules on the page and its layouts:
  1. Read data with db.useQuery or db.useQueryOne. These read the local replica.
  2. Do not read serverData. There is no server to answer it.
  3. Do not read props.auth, props.headers, or props.cookies during the render. Use props.session.exists for a signed-in check, and useSession() for the user.
The worker keeps a page only when its HTML is the same for every signed-in user. When it asks for a route’s HTML, the runtime checks the render. A page that breaks rule 2 or 3 is refused, and the browser console shows the reason:
pylon lint warns about an offline route that reads serverData (PYL008), and about an offline route that names no page (PYL009). The kept HTML carries one bit of the session: signed in or not. When that bit changes, the page tells the worker, and the worker gets the HTML again.

Unsynced and failed writes

Writes made with no network wait in a queue on the device and are sent when the network returns. Show the user what is waiting and what the server refused with useOutbox():
To fix a value the server rejected, pass the corrected fields: retry(w.id, { door: "14B" }). A failed write is sent again only as the user who made it. Rows written offline stay on screen after the app restarts. The sync engine applies the queued writes to the local replica again when it starts. When two people edit the same field of one row offline, the server keeps the edit that arrives last. Both writes succeed, so neither person is told. Edits to different fields of the row are both kept.

Photos taken offline

uploadFile needs the network. queueUpload keeps the file on the device and returns at once. The leader tab uploads queued files one at a time when there is a network, then sets the file on its record.
  1. queueUpload stores the file in IndexedDB.
  2. It sets field and urlField on the record to local:<localId> with a queued write. useFileUrl shows a local: value from the copy on the device.
  3. When the upload finishes, a second queued write sets field to the file id and urlField to the file URL.
Both changes to the record are ordinary writes, so the entity’s policies apply. Insert one row per photo to keep several photos on one record. The rules:
  • A file is uploaded only as the user who queued it. When another user signs in, the queued file is discarded.
  • A file the server refuses (type, size, policy) stays in the outbox as a failed write with kind: "upload". It is not sent again until you retry it. Retry and dismiss it like any failed write.
  • A dropped connection leaves the file queued. It is sent again on the next reconnect.
  • The queue holds at most 200 MB (maxQueuedUploadBytes on the sync engine). Past that, queueUpload throws UploadQueueFullError.
  • Another device sees local:<localId> until the upload finishes. useFileUrl returns null for it.
<FileUpload offline attach={...} /> in @pylonsync/client does the same for a file picker. Give attach as a function to insert a row per file:

Instant navigation

A navigation to an offline route renders from the device, with no page fetch, also when the network is good. A tap on a tab bar paints in the same frame. To get the same for a route that is not in routes, export navigation = "client" from its page. See Instant navigation.

New deploys

Each build gets a new worker with its own caches. The new worker installs in the background and waits. It takes over when every tab of the app is closed, or when the page tells it to. Then it deletes the caches of the old build. A deploy never changes the code of a page that is open by itself. An app on the iOS home screen can stay open in the background for days. Show the user that a new version is available with useAppUpdate():
The hook checks /_pylon/version when the page becomes visible, and every five minutes while it is visible. It also learns of a new worker when the worker finishes installing. apply() does these things in sequence:
  1. It waits until the outbox has no pending writes. A write made offline waits for the network, so apply() waits too. Failed writes do not block it.
  2. It waits for the new worker to finish installing, for up to 30 seconds.
  3. It tells the new worker to take over, and reloads the page once when it does.
When a reload does not change the build (for example, a CDN serves old HTML), a second apply() in the next 60 seconds does not reload. Thus a page that calls apply() automatically cannot reload in a loop. /_pylon/version returns the latest build and is never cached:
<AppUpdateBar /> shows a minimal notice with an Update button, and renders nothing when there is no update. It has no styles. Give it a className, or style [data-pylon-app-update]. To use your own components, use the hook. Under pylon dev, in an app without offline, and during the server render, updateReady is false and apply() does nothing.

Changes to the name or the icon

iOS reads the app’s name, icons, display mode, and start URL only when the user adds the app to the home screen. A later deploy does not change them. When one of these changes, reinstallRecommended becomes true on an iOS home-screen app. Tell the user to remove the app from the home screen and add it again. Android and desktop browsers update an installed app from the manifest, so reinstallRecommended stays false there. The install field of /_pylon/version is a hash of these values, and of the icon files in public/.

Install the app

  • iOS Safari: tap Share, then Add to Home Screen.
  • Android Chrome: tap the menu, then Install app.
The installed app opens on startUrl, with no browser UI when display is "standalone".