Skip to Content
ExamplesOffline-resilient fetch

Offline-resilient fetch

Polling a flaky endpoint the way production apps need to: transient failures retry with exponential backoff, going offline pauses polling entirely, coming back online resumes immediately, and the last good value stays visible the whole time.

Doing this with useEffect means juggling interval handles, abort flags, retry counters, and online/offline listeners across several pieces of state. As a stream, each requirement is one operator:

  • Pause and resumeonline$.pipe(switchMap(...)) tears the polling down while offline and rebuilds it on reconnect; timer(0, …) makes the first fetch after reconnect immediate.
  • Retry with backoffretry({count: 3, delay}) on the request, with the backoff computed from the attempt number.
  • Errors don’t kill the pollcatchError sits on the inner request observable, so an exhausted retry becomes a status value while the outer timer keeps ticking.
  • Keep last-good data — a scan folds every status into a view that remembers the most recent successful snapshot.

Try it: hit “Simulate going offline”, wait a few polls, and come back online — the price refreshes immediately. The mock API fails about a third of the time, so you’ll also see the retry/backoff path fire on its own.

import {useObservable} from 'react-rx'
import {
  catchError,
  map,
  of,
  retry,
  scan,
  startWith,
  switchMap,
  timer,
} from 'rxjs'

import {
  fetchPrice,
  online$,
  type Snapshot,
} from './api'

const POLL_MS = 3000

type Status =
  | {phase: 'offline'}
  | {phase: 'fetching'}
  | {phase: 'live'; data: Snapshot}
  | {phase: 'error'; message: string}

const status$ = online$.pipe(
  // While offline, stop polling entirely. On reconnect, switchMap
  // resubscribes and timer(0, …) fires immediately — resume for free.
  switchMap((online) =>
    online
      ? timer(0, POLL_MS).pipe(
          switchMap(() =>
            fetchPrice().pipe(
              map(
                (data): Status => ({
                  phase: 'live',
                  data,
                }),
              ),
              // Transient failures retry with exponential backoff…
              retry({
                count: 3,
                delay: (_error, attempt) =>
                  timer(300 * 2 ** attempt),
              }),
              // …and only after that surface as an error (caught on the inner
              // observable, so the polling timer itself survives).
              catchError((error: Error) =>
                of<Status>({
                  phase: 'error',
                  message: error.message,
                }),
              ),
              startWith<Status>({
                phase: 'fetching',
              }),
            ),
          ),
        )
      : of<Status>({phase: 'offline'}),
  ),
)

// Keep the last good snapshot visible through fetching/offline/error phases.
const view$ = status$.pipe(
  scan(
    (view, status) => ({
      status,
      last:
        status.phase === 'live'
          ? status.data
          : view.last,
    }),
    {
      status: {phase: 'fetching'} as Status,
      last: undefined as Snapshot | undefined,
    },
  ),
)

export default function App() {
  const {status, last} = useObservable(view$, {
    status: {phase: 'fetching'},
    last: undefined,
  })
  const online = useObservable(online$, true)

  return (
    <>
      <h4>ACME stock</h4>
      {last ? (
        <p
          style={{
            opacity:
              status.phase === 'live' ? 1 : 0.6,
          }}
        >
          <strong>
            ${last.price.toFixed(2)}
          </strong>{' '}
          <small>
            as of{' '}
            {new Date(
              last.at,
            ).toLocaleTimeString()}
          </small>
        </p>
      ) : (
        <p aria-busy="true">
          Fetching first price…
        </p>
      )}
      <p>
        <StatusLine status={status} />
      </p>
      <button
        type="button"
        onClick={() => online$.next(!online)}
      >
        {online
          ? 'Simulate going offline'
          : 'Go back online'}
      </button>
    </>
  )
}

function StatusLine({status}: {status: Status}) {
  switch (status.phase) {
    case 'live':
      return (
        <small>
          live — polling every {POLL_MS / 1000}s
        </small>
      )
    case 'fetching':
      return (
        <small aria-busy="true">fetching…</small>
      )
    case 'error':
      return (
        <small>
          <mark>{status.message}</mark> — retrying
          on the next poll
        </small>
      )
    case 'offline':
      return (
        <small>
          offline — polling paused, last price
          kept
        </small>
      )
  }
}

Open on CodeSandboxOpen Sandbox

api.ts is the only mock — a stand-in for fromFetch(...) plus, in a real app, an online$ derived from fromEvent(window, 'online') / 'offline' (the wiring is in the comment). The component itself is two useObservable reads.

Last updated on