Skip to content

Retry

The retry subpath is a lazy-loaded retry orchestrator. It is intentionally separate from the core entry so customers who never hit a transient failure path do not pay for it. Bundle target: ≤ 1.5 kB gzip.

import { runWithRetry } from '@syntarie/tracking/retry';
await runWithRetry(events, async (batch) => {
const res = await fetch(host + '/events', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(batch[0]),
});
return res.ok || res.status === 202;
});

The attempt callback returns true to signal “stop retrying”. Returning false (or throwing) triggers the next backoff step.

AttemptDelay before
11 s
22 s
34 s
48 s
560 s (cap)

Each delay carries a multiplicative ±25 % jitter to spread retries across clients (avoids the thundering-herd reconnect after a network blip). After 5 retries the orchestrator surrenders.

await runWithRetry(events, attempt, {
onExhausted: (events) => persistToBackup(events),
});

onExhausted lets you hand the unsent events to a fallback (e.g. the offline queue) instead of dropping them.

On the first attempt, the orchestrator stamps each event with a stable dedup_key derived from (id, type, anon_id, ts). Subsequent retries reuse the same key. The collector dedup window is 5 minutes — replays inside that window are silently absorbed and the response is still 202. This is what lets the retry module behave optimistically: a 202 returned to a retry of a previously-accepted event is the right answer, not a double-write.

In v1.0 the SDK transport already wraps runWithRetry internally for the default send() pipeline. Direct use of runWithRetry is for advanced cases:

  • You have built a custom transport (e.g. WebSocket, Service Worker) and want the same backoff semantics.
  • You are sending events outside the send() flow (e.g. flushing a custom buffer on pagehide).

For 99 % of customers the default transport’s retry is enough.