Skip to content

๐Ÿ“ด Offline & sync

Every own project is a local-first PWA built on the same platform packages (@platform/events, @platform/sync, @platform/offline): the UI reads and writes the device's store, the network is a background concern. RefApp's notes exercise all of it.

The first sign-in downloads every row of the app's collections plus their eager images; other images and files are stored the first time they are shown, and online-only features are never kept. A relaunch opens from the device โ€” the app shell from the service worker, the account from localStorage, the rows from IndexedDB โ€” then syncs in the background. A new version of the app downloads in the background when it opens, then the page reloads itself once; new data never reloads it. Only a 401 signs the device out: a server error (the maintenance page during a deploy), a 429 or a Wi-Fi portal page keep it signed in with its data.

DEVICE the shell from the service worker app pages: read and write the store store: a mirror over IndexedDB outbox: unsent edits, grouped file queue: files waiting to upload SERVER the app's container change bus ยท /api/events/sync POST /api/sync one transaction per collection applies the edits, returns the rows changed since the last sync file slots ยท data/files SSE pull push PUT
The UI never waits for the network: the store answers at once, the outbox and the file queue catch up.

๐Ÿ“ถ Online / offline detection

ONLINE SSE ping every 15 s no polling at all CHECKING /api/health ร— 3, 2 s apart 8 s timeout each OFFLINE /api/health every 15 s edits wait in the outbox first answered check: within 15 s, at once when the OS reports the network or the tab shows suspicion none any answer a suspicion: the stream errors or never opens ยท the OS says offline ยท a round gets no answer ยท 35 s without a ping. Any HTTP answer counts as online, even a 429.
Three states per tab, driven by events; the only polling is while offline.

@platform/events keeps one online/offline state per tab, driven by events, not polling:

  • Liveness stream โ€” /api/events (public SSE) sends a ping every 15 s; the server ends each stream after 30 min and the browser reconnects.
  • No polling while online โ€” the only polling is one /api/health call every 15 s while the app believes it is offline.
  • Going offline โ€” a suspicion (the stream errors or never opens, the OS reports no network, a sync round gets no answer, or 35 s pass without a ping) triggers 3 /api/health checks, 2 s apart; only if none answers does the app switch to offline, about 6 s later. A check unanswered after 8 s counts as no answer, so a network that accepts requests but never replies still ends offline (within about 40 s). Any HTTP answer counts as online, even a 429.
  • Coming back โ€” the first answered check switches back: within 15 s, or at once when the OS reports the network or the tab becomes visible again. The streams reopen and a sync round runs.
  • On screen โ€” a green or red dot at the bottom-left of the account's avatar in the top bar (OnlineDot); no dot until the first answer. An app shows connectivity through this dot or not at all.

๐Ÿ”„ Reconciliation

@platform/sync pushes and pulls every collection of the app in one POST /api/sync round.

โฑ๏ธ When

Never on a timer โ€” only on these triggers:

Trigger Delay
App launch, once the account is known immediate
Network comes back (detection) โ‰ค 15 s
A sync event on /api/events/sync: another device or tab, or the server, changed one of my collections immediate โ€” the tab that made the change is not notified
A local edit while online 300 ms (edits are grouped)
The change stream reconnects, including its forced 30 min renewal immediate โ€” an open tab catches up at least every 30 min
The tab becomes visible, or is restored with back/forward immediate
"Sync now" in the sync popover immediate

Edits made offline do not try to sync; they wait for the network to come back.

๐ŸŸข What the user sees

The sync state is a dot at the bottom-left of the account's avatar inside the app's user panel, or on the page the avatar opens (SyncDot):

Dot When
๐ŸŸข green everything on this device is on the server
๐ŸŸ  orange a round runs, a file uploads, or changes wait while online
๐Ÿ”ด red offline, with or without changes waiting, or a refused, failing or full-device notice

A click on it โ€” a tap on a phone, Enter from the keyboard โ€” opens the sync popover: what waits, when the device last synced, Sync now and the notices; a second click, Escape or a click elsewhere closes it. Hovering opens nothing. While an account is signed in, the page's <html> also carries data-sync-state and data-sync-label: what the test suites read. An app shows sync state through this dot or not at all.

๐Ÿ” What a sync round does

THIS DEVICE SERVER OTHER DEVICES POST /api/sync ยท per collection: โ‰ค 500 unsent edits + last sync time one transaction per collection: apply the edits, read the rows changed since (minus 60 s) a sync event accepted ยท rejected ยท the changed rows drop the accepted edits merge: a newer unsent edit stays save each collection's time download the new eager images backlog over 500 โ†’ another round, up to 10 ยท one tab at a time files: PUT <slot url>, one at a time, then one more round Never on a timer: a launch, the network back, a sync event, a local edit (300 ms), a reconnect, the tab shown.
One round: push, apply, pull, merge; again while a backlog or files are waiting.

When the network comes back, the streams reopen and one round runs:

  1. Push โ€” one POST /api/sync carries, per collection, the unsent local edits (โ‰ค 500) and the time of its last sync.
  2. Server โ€” per collection, in one transaction: applies the edits, returns every row changed since the last sync (minus a 60 s safety overlap), notifies the user's other devices.
  3. Device โ€” drops the accepted edits from its queue, removes rejected rows with a notice, merges the returned rows (a newer unsent local edit stays), saves each collection's sync time.
  4. Images โ€” downloads the eager images of new or changed rows.
  5. Backlog โ€” more than 500 edits waiting โ†’ another round, up to 10. A Web Lock lets only one tab sync at a time.
  6. Files โ€” files waiting on this device (files & images) upload once their row is on the server, one at a time, then one more round pulls their new state. The sync dot counts them: "Synced" means rows and files reached the server.
Conflicts
  • Latest edit wins, per whole row, by the device's clock: if two devices edit different fields of the same row offline, the later edit replaces the whole row and the other change is lost. On a tie the server keeps its row.
  • A delete is final and beats any later edit.
  • Clocks โ€” an edit time in the future is capped at server time + 5 min; a phone whose clock runs behind loses conflicts.
  • Stale device โ€” not synced for over 90 days โ†’ that collection is reloaded from the server (unsent edits are kept and re-sent).
Failures
  • No network โ€” nothing changes; the next trigger retries.
  • Server error (5xx / 429) โ€” everything is kept; after 20 failures in a row the sync dot turns red and its popover shows a notice.
  • Refused batch (400) โ€” dropped with a notice: a client bug no retry can fix.
  • 401 (session expired or revoked) โ€” the user is signed out and the device wiped, unsent edits included. Intended: the server would refuse them anyway.
What is reconciled

Only the rows of the app's declared collections. Images and files are not reconciled:

  • Eager images (downloaded after each sync) are skipped when their URL is already cached โ€” a changed image shows only once its URL changes.
  • On-view images and files (stored the first time they are shown) are never refreshed.
  • Uploads โ€” files attached to rows go through the platform's file queue below: RefApp's note images, CardBox's logos, Kouizine's recipe photos, and TripWise's trip and item attachments and its trip pictures. Every other upload still goes through a regular API call.
  • Deletes โ€” a deleted row (here or on another device) takes its images and files off the device.

Rows, queue and image caches are wiped on sign-out and before another account's data loads.

๐Ÿ–ผ๏ธ Files & images

A file always belongs to a synced row under a named slot, and the slot's URL is both where its bytes upload and where the UI reads them. RefApp's note images carry three:

Slot What Kept on the device
thumb โ‰ค 320 px copy, made by the browser every device, after a sync (eager)
display โ‰ค 1920 px copy, made by the browser once opened (on-view)
original the dropped file, untouched โ€” JPEG XL included only while waiting to upload (a download otherwise)
  • Dropping โ€” the browser decodes the image (JPEG XL with a WASM decoder, precached, so it works offline) and makes the two copies; the row and the three files are saved on the device in one step and shown at once, offline included.
  • Uploading โ€” once the row is on the server, each file goes with a PUT of its raw bytes, one at a time. The server streams it to data/files/tmp, checks its size and its real type (from its first bytes, never the name), computes its SHA-256, moves it into place and marks the row. Limits: 1 MB thumbnail, 8 MB display, 32 MB original; a refused file is dropped with a notice in the sync popover, a network or server failure retries later.
  • On disk โ€” data/files/images/thumbnails/<uuid>.webp, โ€ฆ/displays/<uuid>.webp (.jpg from a browser without a WebP encoder) and โ€ฆ/originals/<uuid>.<ext>; the paths, the original's size and its SHA-256 are on the row.
  • Deleting โ€” an image, or the note it belongs to, removes its files from the server and from every device. A daily task sweeps what a crash could leave behind (abandoned uploads in tmp/, files no row references).