# Transport and offline

[Docs index](/web/) - [package README](/web/)

Events do not get lost when the network is hostile or the page is
closing. Three layers cooperate to make that true: a durable queue, a
retrying transport, and two terminal flush paths for unload.

## What the transport sends

- **Endpoint.** `POST ${host}/v1/behavior/events`. One endpoint, one
  path; a single `connect-src` entry covers every request the SDK
  makes. `host` defaults to `https://api.revu.ai`; point it at your own
  domain for [first-party ingest](/web/first-party-ingest/).
- **Body.** JSON with `{ api_key, batch }`, content type
  `application/json`.
- **Method.** `fetch` with `keepalive: true` while the page is live;
  `sendBeacon` (with an explicit `application/json` Blob) on
  `pagehide` and `visibilitychange -> hidden`.

## When the transport flushes

The transport flushes on any of:

- **Size threshold.** The queue size reaches `flushAt` (default 20).
- **Time interval.** Every `flushIntervalMs` (default 5000 ms) a
  background timer drains whatever is in the queue.
- **Connectivity returns.** On the `online` event, the failure counter
  and backoff reset and a flush attempts immediately.
- **Terminal lifecycle.** `pagehide` and `visibilitychange -> hidden`
  both trigger a `sendBeacon` flush so the final batch survives unload.
- **Manual.** [`revu.flush()`](/web/api/#revuflush) drains the buffer
  immediately and resolves to `true` on success.

Each flush sends at most `maxBatch` events per request (default 50). If
the queue holds more, it drains across several requests rather than one
oversized body.

## The durable queue

Events are appended to a localStorage-backed FIFO queue on every
`record()` call, before any send is attempted. That means:

- An offline period buffers locally and ships on the next `online`.
- A crash, a hard reload, or a closed laptop lid does not lose events;
  the next page load drains what was left.
- The queue is bounded by `maxQueue` (default 1000). When the cap is
  hit, the oldest events are pruned first (recent behavior is more
  valuable than stale backlog).
- A successful send only commits the batch (removes it from the queue)
  after the server acknowledges. A failed send leaves events queued
  for the next attempt.

When localStorage is unavailable (private mode in some engines, strict
quotas, storage disabled), the queue transparently falls back to an
in-memory array. The SDK keeps capturing; it just loses durability
across reloads. With `debug: true` the fallback is logged.

## Retries and backoff

Transient failures (network error, 429, 503, any non-2xx) keep the
batch in the queue and schedule a retry with capped exponential
backoff:

- Base delay: 1 s.
- Doubles per consecutive failure.
- Capped at 60 s.

The first success resets the counter, so a flapping network does not
ratchet the delay indefinitely.

The ingest endpoint dedupes on `event_id` (every event carries a
client-generated UUID and that UUID is the idempotency key), so a retry
that overlaps a send that actually landed is safe.

## Terminal flush ordering

The terminal flush is installed last in the SDK boot sequence, on
purpose. `pagehide` and `visibilitychange` listeners on the same
target fire in registration order during the bubble phase, so the
transport's terminal handler runs after every emit-on-terminal module
(autocapture's `$page_leave`, vitals' LCP / INP / CLS report, any
plugin doing the same). That ordering guarantees the final batch
already contains those last events when `sendBeacon` ships it.

iOS Safari is the reason two terminal events are wired instead of one.
On desktop, `pagehide` covers tab close, navigation, and bfcache
eviction. On mobile (especially iOS Safari), `pagehide` is often
skipped when the user backgrounds the app or closes the tab; the only
reliable terminal signal there is `visibilitychange -> hidden`. The
transport listens on both and dedupes so each real terminal close
emits one batch.
