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.
Traffic indicator
useSyncActivity answers a different question again: is data moving right now? It is the hook
behind an app-chrome mark that switches to “downloading” or “uploading”.
| Accessor | Meaning |
|---|---|
fetchingQueries() | Queries inside a fetch cycle right now. One subscription on the engine’s aggregate count, not one per query. |
pendingMutations() | Locally committed writes the server has not acknowledged yet. |
isDownloading() | Fetching for longer than downloadDelayMs (200 ms). Off the instant fetching stops. The delay filters the sub-frame fetches a warm cache produces. |
isUploading() | More than uploadThreshold (1) writes queued. |
While isOffline() is true, pendingMutations() is the number of changes that are saved locally
and not yet on the server. Show it: a count is more reassuring than a sentence.
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.
The Dart client also reports which records are still waiting. unsyncedRecordIds is the set of
record ids with a write the server has not acknowledged yet, and subscribeToUnsyncedRecords fires
whenever that set changes, even when the count stays the same because one write was acknowledged as
another was queued. Use it for a per-row indicator, like one tick while a message is only on the
device and two once it is stored.
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() registers a query without rendering it. Run it on a route you’re about to
navigate to and the next screen paints instantly.
| State | Behaviour |
|---|---|
| Resolved before on this device | Resolves at once; the rows are already in the local store. |
| Never resolved | Resolves once the server’s membership and every record are local. Pass signal to abort the wait. |
A preloaded query stays registered (and live) until a ttl after nothing has mounted it; a view that mounts the same query simply attaches to it.
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.