Offline & sync health
Your app already works offline. This page is about showing the user what's going on, and tuning what gets cached.
There’s no offline mode to switch on. Reads are served from the local store, writes queue locally and drain when the network returns. What you do have to build is the UI that tells the user which state they’re in.
Sync health
A single dropped request never trips the banner. useSyncStatus reports degraded only after a
run of consecutive failed sync rounds (three by default) and flips back on the first
successful round.
| Accessor | Meaning |
|---|---|
status() | 'healthy' or 'degraded'. |
isDegraded() | Sustained failures. Something is wrong. |
everConnected() | At least one sync round has succeeded this session. |
isOffline() | Degraded after having connected. This is the one to drive a banner off. It stays false during initial connect, so a cold start doesn’t flash “offline”. |
connection() | Transport state: 'connecting', 'connected', 'reconnecting', 'disconnected'. |
isReconnecting() | The socket is being re-established. |
health() | The full snapshot: consecutiveFailures, kind (network or application), error, connection. |
Tune the threshold in your client config:
Pass syncHealth: false to never report degraded at all.
Connection vs. health
These answer different questions and it’s worth keeping them apart:
connection()is about the socket. It flips the moment the WebSocket drops.status()is about whether sync rounds are succeeding. It only degrades after a sustained run of failures.
A short reconnect is usually reconnecting and still healthy — writes queue locally and push
once the socket is back, so there’s nothing to alarm the user about. Use isReconnecting() for a
quiet spinner and isOffline() for the actual “we can’t reach the server” banner.
Reconnect
The client keeps the connection alive for as long as the page lives. Three things cooperate:
- The SurrealDB SDK retries after a socket close, on exponential backoff. Sp00ky configures this to retry indefinitely rather than the SDK’s default of five attempts (which gives up after roughly a minute of downtime and never tries again).
- A supervisor re-opens the connection from scratch if the SDK stops trying at all.
- A heartbeat probes the server on an interval. This is what catches a half-open socket: the peer is gone (laptop slept, wifi switched, NAT timed out) but no close event ever arrives, so nothing else would notice. If a probe goes unanswered the socket is torn down and rebuilt.
Coming back online or un-hiding the tab also triggers an immediate probe instead of waiting out a backoff.
After any reconnect, active queries re-register and live subscriptions are re-issued — a live subscription is scoped to its WebSocket session, so it does not survive the drop.
The defaults need no configuration, but they’re tunable:
queryTimeoutMs matters more than it looks. Remote queries are serialized, so a single request
that never settles would otherwise block every later one — including the health probe, leaving the
client wedged while still reporting healthy. The deadline turns that into an ordinary network
failure that gets retried.
Individual failures are always swallowed and retried. They never throw into your app. Sync health is purely a reporting channel.
Pending writes
Mutations made while offline sit in a queue. pendingMutationCount is how many are waiting, and
subscribeToPendingMutations fires whenever it changes.
Useful for a “3 changes not yet saved” indicator, and for warning before the user closes the tab with unsynced work.
Where data lives locally
Set database.store in your client config:
| Store | Behaviour |
|---|---|
indexeddb | Survives reloads. What you want in production. |
memory | Cleared on refresh. Good for tests and ephemeral views. |
Reads hit this store first, so a returning user sees their data on the first frame, before any network round trip completes.
Preloading
db.preload() warms a query’s cache without rendering it. Run it on a route you’re about to
navigate to and the next screen paints instantly.
refresh | Behaviour when a cached copy already exists |
|---|---|
onUse (default) | Don’t refetch now; refresh when something actually subscribes. |
background | Refetch immediately in the background. |
stale | Refetch in the background only if the cached copy is older than staleTime (default 1h). |
Telling clients a new version shipped
Deployed frontends write an announcement row that running clients can see. useAppRelease surfaces
it so you can offer a reload, or force one when you’ve shipped a fix for a broken build.
| Accessor | Meaning |
|---|---|
latestVersion() | Announced version, or undefined if nothing has been announced. |
updateAvailable() | The announcement is semver-newer than currentVersion. |
mandatory() | Deployed with --mandatory: reload without asking. |
cacheBust() | Deployed with --cache-bust: clear CacheStorage during the reload. |
reload() | Performs the reload, handling cache-busting and the service worker correctly. |
Announce a release without a full deploy with spky release.