Offline and reconnection

What resources do when the network drops and comes back: stop reads, stop burning retries, refetch on return, and queue writes so they fire when you are online again.

@mmstack/resourcenpm

Handling offline by hand is a lot of small, easy-to-miss work. You have to detect the drop, stop retrying into a dead network, refetch once you are back, and decide what happens to a write the user triggered while disconnected. Getting one of those wrong tends to show up as a wasted request storm or a lost save.

The library does most of this by default. This page explains what happens so you can rely on it and tune the parts that are yours to decide, mainly whether writes should queue and whether they should survive an app close.

#Reads stop when the network drops

When the browser goes offline, a query stops firing. disabled() becomes true and disabledReason() reports 'offline'. While in this state nothing burns retry attempts, so a flaky connection does not eat through a retry budget it was never going to spend well. The last value stays readable the whole time.

disabledReason() is the signal to branch your UI on. It is one of 'offline', 'circuit-open' (the circuit breaker tripped after repeated failures), 'no-request' (the request function returned undefined), or null when the resource is enabled. Reading that directly is clearer than trying to infer the cause from combined status.

@switch (posts.disabledReason()) {
  @case ('offline') {
    <p>You are offline. Showing the last loaded posts.</p>
  }
  @case ('circuit-open') {
    <p>The posts service is having trouble. Retrying soon.</p>
  }
  @default {
    <ul>
      @for (p of posts.value(); track p.id) {
        <li>{{ '{{' }} p.title {{ '}}' }}</li>
      }
    </ul>
  }
}

#Refetch when you come back

Data you looked at before going offline is probably stale by the time you return. The refresh option can refetch on the events that mark a return: onReconnect fires when the browser comes back online, and onFocus fires when the tab becomes visible again. Both respect disabled and paused state, so they do not fire into a still-dead network.

queryResource(() => ({ url: '/api/notifications' }), {
  refresh: { onFocus: true, onReconnect: true },
});

Compose the object form with interval for "poll while the tab is visible, and refresh the moment I come back to it". The interval keeps the data warm while you are looking at it, and the focus trigger catches the gap after you have been away.

queryResource(() => ({ url: '/api/notifications' }), {
  refresh: { interval: 60_000, onFocus: true }, // poll while visible, refresh on return
});

#Writes that survive going offline

A read can just wait. A write the user already committed to, a posted comment, a saved note, should not be dropped because the network happened to be down at that instant. Pass queue: true to a mutationResource and mutations serialize into a FIFO that runs one at a time, and that queue persists across disabled states.

const saveNote = mutationResource(
  (note: Note) => ({ url: '/api/notes', method: 'POST', body: note }),
  { queue: true }, // serialized, and survives the offline window
);

// triggered while offline -> waits in the queue -> fires when back online
saveNote.mutate(note);

So a POST triggered while offline sits in the queue and fires when the resource recovers. That resilience comes with a UX caveat worth stating plainly: a queued mutation can fire long after the user triggered it. If that timing would be surprising in your interface (a "send" that lands minutes later), do not reach for queue without designing for the delay.

#Writes that survive an app close

The queue lives in memory, so a queued mutation is lost if the tab closes before it fires. Add persist: { key: '...' } alongside queue: true and accepted-but-unsettled mutations are stored in IndexedDB. They replay when a mutationResource with the same persist.key is next instantiated while online, or when the network is regained.

const saveNote = mutationResource(
  (note: Note) => ({ url: '/api/notes', method: 'POST', body: note }),
  {
    queue: true,
    persist: {
      key: 'save-note', // stable identity of this mutation KIND across sessions
      // ttl (default 7 days); serialize/deserialize for non-cloneable payloads;
      // keepOnError to retry transient failures on the next replay
    },
    invalidates: ['/api/notes'], // fires for replayed successes too
  },
);

The key is the stable identity of this kind of mutation across sessions, which is how a fresh instance knows which stored rows are its to replay. Replay runs the normal lifecycle, so onMutate, onSuccess, and onError all fire with their closures intact (which is why replay activates at resource instantiation, not from a serialized callback). onError receives { replayed: true } so your reconciliation policy can tell a replayed failure apart, and invalidates fires for replayed successes too, so server truth wins after a replay. Ordering is per-key FIFO for queued resources.

Because replay runs your optimistic hooks, this pairs directly with optimistic updates: the same rollback and reconcile logic applies whether the mutation ran this session or replayed from disk after a restart. For a "3 changes waiting to sync" surface and a manual "sync now" button, read the pending mutations with injectPendingMutations().

#Streams reconnect on their own

A streamResource handles the outage itself. Connection failures retry with exponential backoff, persistent by default, and it is offline-aware in the same way as queries: while offline nothing burns attempts, and regaining the network reconnects immediately on a fresh ladder. The last value stays readable through the whole drop, and only genuinely exhausted retries surface as an error.

const chat = streamResource<ChatMessage>(() => '/api/chat/stream', {
  transport: sse(),
  // reconnect: persistent by default (1s base, 30s cap)
  // reconnect: 0,                     // single-shot
  // reconnect: { max: 5, backoff: 2_000 },
});

Tune it with reconnect: 0 for a single-shot connection, or { max, backoff } to cap the attempts and set the base delay.

#Testing offline behavior

You do not want a test to depend on the real navigator.onLine. Provide controllable sensors with provideMockResourceSensors() and pass a writable signal for networkStatus. Flip it in the test to simulate a drop and a return.

import { signal } from '@angular/core';
import { provideMockResourceSensors } from '@mmstack/resource';

const online = signal(true);

TestBed.configureTestingModule({
  providers: [provideMockResourceSensors({ networkStatus: online })],
});

online.set(false); // the resource sees the drop and disables
// ...assert disabledReason() === 'offline', queued mutations wait...
online.set(true); // the reconnect: refetch + queue flush

Setting the signal to false makes the resource see the network drop, so disabledReason() becomes 'offline'; setting it back to true is the reconnect, which triggers the same refetch and queue-flush behavior as a real return.