Skip to content

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.

Markdown
On this page

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.

Browsers: offline: true#

src/instrument.ts
import { init } from "@belvador/browser"; // or @belvador/react, @belvador/tanstack-start/clientinit({  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.

RuleValue
Kept at most50 envelopes per DSN — beyond that the oldest is dropped and counted as a queue overflow
Kept for72 hours — older ones are dropped instead of sent
Orderoldest first
Answers that are not network failuresa 4xx from your node is never stored: sending it again would change nothing
Costabout 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 once.

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.

Terminal
npm install @belvador/spool@0.6.1

Node.js (@sentry/node):

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):

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):

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:

Terminal
deno run --allow-net=ingest.belvador.dev \  --allow-env=BELVADOR_DSN,BELVADOR_SPOOL_DIR \  --allow-read=./.belvador-spool --allow-write=./.belvador-spool \  main.ts
OptionDefaultWhat it does
dir— (required)One directory per app, created with mode 0700; files are 0600. Several processes of the same app may share it.
maxBytes64 MiBTotal size on disk. The oldest envelopes go first, counted as a queue overflow.
ttlHours72Older envelopes are dropped instead of sent.
keynot set32 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.

  • 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.

Spotted something wrong or missing? Tell us: hello@belvador.dev