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.
  • 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 resume: online$.pipe(switchMap(...)) tears the polling down while offline and rebuilds it on reconnect. timer(0, …) makes the first fetch after reconnect immediate.
  • Retry with backoff: retry({count: 3, delay}) on the request, with the backoff computed from the attempt number.
  • Errors don’t kill the poll: catchError sits on the inner request observable. An exhausted retry becomes a status value, and 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 also fails about a third of the time, so you’ll see the retry 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, so resume is 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. It stands in for fromFetch(...) plus, in a real app, an online$ derived from fromEvent(window, 'online') and 'offline' (the wiring is in the comment). The component itself is two useObservable reads.

Last updated on