# Offline and durable delivery

> Keep errors that cannot be sent yet: jittered retries, an IndexedDB queue with offline: true in browsers, and @belvador/spool on disk for Node.js, Bun and Deno.

Errors happen when the network is worst: a train tunnel, a restart of your node, a server whose upstream is down. This page shows what Belvador's SDKs do when an error cannot be sent, and how to keep errors until it can. Everything here is available from SDK 0.6.0.

## What every SDK does already

Each send is tried up to 5 times. The wait before each retry is random, between zero and 1, 2, 4, then 8 seconds (at most 30 seconds) — so a thousand apps that lost the connection together do not all come back in the same second. A `503` with `Retry-After` waits as asked instead. Every retry sends the same envelope with the same event id, so your node counts the error once.

Without the options below, an error that still cannot be sent after the last retry is lost, and a page that closes takes its waiting errors with it. The full rules are in [How sending works](https://belvador.dev/en/docs/sdk/configuration#how-sending-works).

## Browsers: `offline: true`

```ts title="src/instrument.ts"
import { init } from "@belvador/browser"; // or @belvador/react, @belvador/tanstack-start/client

init({
  dsn: "https://<public-key>@ingest.belvador.dev/<project-id>",
  // Errors that cannot be sent wait in this browser, then go out when it is back online.
  offline: true,
});
```

With `offline: true`, an error that fails on the network is kept in this browser (IndexedDB) and sent again:

- as soon as the browser reports it is back online,
- or on the next page load, even after the tab was closed.

| Rule | Value |
|---|---|
| Kept at most | 50 envelopes per DSN — beyond that the oldest is dropped and counted as a queue overflow |
| Kept for | 72 hours — older ones are dropped instead of sent |
| Order | oldest first |
| Answers that are not network failures | a `4xx` from your node is never stored: sending it again would change nothing |
| Cost | about 1.8 KB gzip, loaded as its own file only when `offline: true` is set |

Works the same with `@belvador/react` and `@belvador/tanstack-start/client`. In preview mode (`preview: true`) nothing is stored, because nothing is sent.

When this browser has no usable IndexedDB (some private windows, a storage policy that blocks it) or the offline file cannot load, the SDK sends as before, without a queue, and reports [`BLV_OFFLINE_UNAVAILABLE`](https://belvador.dev/en/docs/sdk/verify#blv-offline-unavailable) once.

> [!NOTE]
> What is stored is what would have been sent: the event after your `beforeSend`, the project's rules and Belvador's scrubbing. It stays in this browser's storage for this site until it is sent or expires, and is readable by other scripts of the same site — keep the same care as with anything you put in `localStorage`.

## Node.js, Bun and Deno: `@belvador/spool`

Servers that use the official Sentry SDK with a Belvador DSN can keep errors on disk while your node is unreachable. When it answers again they are sent oldest first — also by the next process, if this one was restarted or killed.

```bash
npm install @belvador/spool@0.6.1
```

**Node.js** (`@sentry/node`):

```js title="instrument.mjs"
import * as Sentry from "@sentry/node";
import { scrubBreadcrumb, scrubEvent } from "@belvador/core/sentry";
import { withSpool } from "@belvador/spool";

Sentry.init({
  dsn: process.env.BELVADOR_DSN ?? "https://<public-key>@ingest.belvador.dev/<project-id>",
  release: process.env.APP_VERSION,
  environment: process.env.NODE_ENV,
  sendDefaultPii: false,
  tracesSampleRate: 0,
  maxBreadcrumbs: 50,
  maxValueLength: 8192,
  // Belvador's scrubbing: request bodies, cookies and credentials never leave the app.
  beforeSend: scrubEvent,
  beforeBreadcrumb: scrubBreadcrumb,
  // Errors that cannot be sent wait on disk and go out after a restart.
  transport: withSpool(Sentry.makeNodeTransport, {
    dir: process.env.BELVADOR_SPOOL_DIR ?? "/var/lib/my-app/belvador",
  }),
});
```

**Bun** (`@sentry/bun`):

```ts title="instrument.ts"
import * as Sentry from "@sentry/bun";
import { scrubBreadcrumb, scrubEvent } from "@belvador/core/sentry";
import { withSpool } from "@belvador/spool";

Sentry.init({
  dsn: process.env.BELVADOR_DSN ?? "https://<public-key>@ingest.belvador.dev/<project-id>",
  release: process.env.APP_VERSION,
  environment: process.env.NODE_ENV,
  sendDefaultPii: false,
  tracesSampleRate: 0,
  maxBreadcrumbs: 50,
  maxValueLength: 8192,
  // Belvador's scrubbing: request bodies, cookies and credentials never leave the app.
  beforeSend: scrubEvent,
  beforeBreadcrumb: scrubBreadcrumb,
  // Errors that cannot be sent wait on disk and go out after a restart.
  transport: withSpool(Sentry.makeFetchTransport, {
    dir: process.env.BELVADOR_SPOOL_DIR ?? "./.belvador-spool",
  }),
});
```

**Deno** (`npm:@sentry/deno`):

```ts title="instrument.ts"
import * as Sentry from "npm:@sentry/deno@11.9.0";
import { scrubBreadcrumb, scrubEvent } from "npm:@belvador/core@0.6.1/sentry";
import { makeFetchTransport, withSpool } from "npm:@belvador/spool@0.6.1";

Sentry.init({
  dsn: Deno.env.get("BELVADOR_DSN") ?? "https://<public-key>@ingest.belvador.dev/<project-id>",
  release: Deno.env.get("APP_VERSION"),
  environment: Deno.env.get("APP_ENV") ?? "production",
  sendDefaultPii: false,
  tracesSampleRate: 0,
  maxBreadcrumbs: 50,
  maxValueLength: 8192,
  // Belvador's scrubbing: request bodies, cookies and credentials never leave the app.
  beforeSend: scrubEvent,
  beforeBreadcrumb: scrubBreadcrumb,
  // Errors that cannot be sent wait on disk and go out after a restart.
  transport: withSpool(makeFetchTransport, {
    dir: Deno.env.get("BELVADOR_SPOOL_DIR") ?? "./.belvador-spool",
  }),
});
```

The spool needs network access to your DSN's host, the two environment variables and read/write access to its directory:

```bash
deno run --allow-net=ingest.belvador.dev \
  --allow-env=BELVADOR_DSN,BELVADOR_SPOOL_DIR \
  --allow-read=./.belvador-spool --allow-write=./.belvador-spool \
  main.ts
```

| Option | Default | What it does |
|---|---|---|
| `dir` | — (required) | One directory per app, created with mode `0700`; files are `0600`. Several processes of the same app may share it. |
| `maxBytes` | 64 MiB | Total size on disk. The oldest envelopes go first, counted as a queue overflow. |
| `ttlHours` | 72 | Older envelopes are dropped instead of sent. |
| `key` | not set | 32 random bytes of your own: envelopes on disk are encrypted with AES-256-GCM. |

What we tested: 200 errors while the intake was down, the process killed with `SIGKILL`, a new process started once the intake was back — all 200 arrived, none twice, on Node.js, Bun and Deno. A process killed in the middle of writing leaves at most a half-written file, which the next start drops and counts; every complete envelope still arrives once. A burst larger than the Sentry SDK's in-memory buffer (64 sends) goes to disk instead of being dropped. The recipes above, as published, also ran against a real node with `@sentry/node` and `@sentry/bun` 11.9.0: errors made while it was unreachable arrived after a `SIGKILL` and, on Node.js, after an out-of-memory crash.

Already using the Sentry SDK's `makeOfflineTransport`? Pass `createStore: makeFileStore({ dir })` from `@belvador/spool` instead of its default store. `withSpool` is still the better choice: it also keeps bursts beyond the 64-send buffer and does not count a stored error as lost.

## Runtimes without a disk

- **Cloudflare Workers** have no disk and an isolate can stop at any time. `@belvador/cloudflare` retries as above inside `waitUntil`; an error that still fails is lost.
- **Supabase Edge Functions** and other short-lived functions: a spool on their temporary disk disappears with the instance, so it gains nothing there. Send before answering instead — see [Supabase Edge Functions](https://belvador.dev/en/docs/sdk/supabase-edge).
- **Python** (`sentry-sdk`) keeps a memory queue only; no disk spool is offered.

## What this does not promise

Neither the browser queue nor the spool promises that every error arrives. A full disk or storage quota, a damaged file or an envelope older than the time limit is dropped and counted in the SDK's delivery reports. Your app never waits for storage and never sees an error from it.
