Github|...

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.

ConnectionBanner.tsx
import { useSyncStatus } from '@spooky-sync/client-solid';
import { Show } from 'solid-js';

function ConnectionBanner() {
  const sync = useSyncStatus();

  return (
    <Show when={sync.isOffline()}>
      <div class="banner">Can't reach the server, changes are saved locally.</div>
    </Show>
  );
}

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.

AccessorMeaning
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:

const dbConfig: SyncedDbConfig<typeof schema> = {
  // …
  syncHealth: { degradeAfterConsecutiveFailures: 5 },
};

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:

  1. 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).
  2. A supervisor re-opens the connection from scratch if the SDK stops trying at all.
  3. 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:

const dbConfig: SyncedDbConfig<typeof schema> = {
  database: {
    // …
    reconnect: {
      attempts: -1,                 // -1 = retry forever (the default)
      retryDelayMax: 15_000,        // cap on the backoff between attempts
      heartbeatIntervalMs: 20_000,  // liveness probe cadence; 0 disables
      heartbeatTimeoutMs: 10_000,   // unanswered for this long = socket is dead
    },
    queryTimeoutMs: 60_000,         // deadline on every remote query; 0 disables
  },
  pushTimeoutMs: 30_000,            // deadline on a single outgoing mutation
};
Note

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.

Note

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.

const [pending, setPending] = createSignal(db.pendingMutationCount);
onCleanup(db.subscribeToPendingMutations(setPending));

<Show when={pending() > 0}>
  {pending()} change{pending() === 1 ? '' : 's'} not yet synced
</Show>

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:

StoreBehaviour
indexeddbSurvives reloads. What you want in production.
memoryCleared 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.

// warm the thread list before the user navigates to it
await db.preload(
  db.query('thread').orderBy('created_at', 'desc').limit(20).build(),
  { refresh: 'stale', staleTime: '5m' }
);
refreshBehaviour when a cached copy already exists
onUse (default)Don’t refetch now; refresh when something actually subscribes.
backgroundRefetch immediately in the background.
staleRefetch 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.

UpdatePrompt.tsx
import { useAppRelease } from '@spooky-sync/client-solid';
import { Show, createEffect } from 'solid-js';

function UpdatePrompt() {
  const release = useAppRelease({
    app: 'web',
    currentVersion: __APP_VERSION__,   // baked in at build time
  });

  // a mandatory release reloads without asking
  createEffect(() => {
    if (release.updateAvailable() && release.mandatory()) release.reload();
  });

  return (
    <Show when={release.updateAvailable()}>
      <button onClick={() => release.reload()}>
        Update to {release.latestVersion()}
      </button>
    </Show>
  );
}
AccessorMeaning
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.

Next