Get started

When the network is down

The report that matters most is the one written while the application was broken — and that is exactly the one a failed fetch throws away. bugbottle/queue keeps it instead:

ts
import { createQueue } from "bugbottle/queue";
import { useBugReport } from "bugbottle/react";

const queue = createQueue({ endpoint: "/api/feedback" });
const form = useBugReport({ endpoint: "/api/feedback", queue });

A send that fails is written to localStorage, and the reporter is told the truth in their own language: "Saved — it will be sent when you are back online". form.status.kind is "queued" rather than "error", and statusMessage is the queued string of the locale. mountBugbottle takes the same queue option and shows its ordinary thank-you panel with that line.

The queue drains when it is created, when the browser fires online, and when the tab becomes visible again. A failed attempt backs off exponentially, from one second to five minutes. A 5xx or a network error keeps the report; a 4xx drops it, because the server has already said this report is not acceptable and retrying it would only fail again more quietly — nothing is ever queued on a 4xx in the first place.

ts
const queue = createQueue({
  endpoint: "/api/feedback",
  storageKey: "bugbottle:queue", // where in localStorage
  maxEntries: 5,                 // the oldest is evicted first
  maxAgeMs: 7 * 24 * 60 * 60 * 1000,
  headers: { Authorization: `Bearer ${token}` },
  sign: createSigner({ key: SIGN_KEY }), // for a signed endpoint; see below
});

queue.size();          // how many are waiting
await queue.flush();   // resolves with how many are still waiting
queue.clear();         // throw them away
queue.destroy();       // remove the listeners; the reports stay in storage

When the quota runs out #

localStorage is a few megabytes for the whole origin, shared with whatever else the application keeps there, and a screenshot as a data URL is a megabyte or two on its own. A write is refused sooner than anyone expects — and until 0.13 a refused write meant the report reached storage nowhere and was gone on the next reload, which is exactly what an outage ends in.

It costs the picture instead. When the storage refuses a write, the queue writes the same reports again without their screenshots and leaves a line on each one it took a picture from:

jsonc
"notes": ["Screenshot dropped: it did not fit in the offline queue."]

notes is part of the payload. The server validates it like every other field — at most five notes, 200 characters each, normaliseNotes — and toMarkdown prints them above the evidence, so whoever reads the report can tell "no screenshot was taken" from "a screenshot was taken and would not fit". It is written by the library about the report, never by the reporter.

Nothing is dropped on a guess: a picture that fits is kept whole. Only when the second write is refused as well does the queue go memory-only for the life of the page. If localStorage is unavailable from the start, as in Safari's private mode, it starts there rather than refusing to work — and either way, what is already stored is still read and still delivered.

Somewhere else to keep them #

storage replaces localStorage with anything that can read the queue and change it. One other implementation ships, in its own entry point:

ts
import { createQueue } from "bugbottle/queue";
import { createIdbStorage } from "bugbottle/queue-idb";

const queue = createQueue({
  endpoint: "/api/feedback",
  storage: createIdbStorage(), // databaseName, storeName, storageKey
});

IndexedDB has room for the pictures — a share of the free disk rather than five megabytes for everything on the origin — so a 2 MB report is queued whole. Its read-write transactions are ordered per database and across tabs, so the claim below is decided by the database rather than by whichever tab wrote last. What it costs is timing: every step is asynchronous, so a report queued in the last milliseconds before the tab is closed may not reach the disk, where localStorage always does. Which of the two matters more depends on whether your reports carry pictures.

When another tab loads a page that wants a newer version of the database, the browser asks this connection to stand aside. It does: the connection is closed and the next write opens a fresh one. A page that holds on instead blocks the other tab's upgrade for as long as it stays open, and a page that closes without reopening throws on every transaction afterwards and is memory-only for good.

It is a separate entry point because the default must not pay for it: bugbottle/queue is about 1.5 kB and this is another 650 bytes, only for those who ask for it. A browser with no IndexedDB at all makes the queue memory-only, and the reports are still sent.

Your own storage is one function:

ts
import type { QueueStorage } from "bugbottle/queue";

const storage: QueueStorage = {
  update: (change) => writeBack(change(readTheArray())), // throws when refused
};

update reads, applies change and writes the result back as one step, so a storage that can be atomic gets to be, and it answers with what is now stored. It may return a promise. A refused write throws, or rejects, and that is what starts the fallback above.

There is deliberately no plain read beside it: anything read outside a read-modify-write is stale the moment another tab commits, so every path through the queue — the flush on load included — goes through update, even the ones that only want to look.

Two tabs, one queue #

localStorage belongs to the origin, not the tab, and it cannot be changed atomically. The queue takes that seriously: each report is given a random id when it is queued, and every write re-reads the stored array and merges by id rather than replacing it. Before a report is delivered it is claimed — a timestamp written into storage that asks the other tabs to leave it alone for 30 seconds — and a successful delivery removes it by id from a freshly read array. So a second tab does not lose your reports, deliver them again, or put back one you have just sent.

The honest limit: this is a lease, not a lock. Two tabs that read, decide and write within the same few milliseconds can both claim one report and post it twice. The window is the length of one read-modify-write, the failure is a duplicate rather than a loss, and fingerprint(report) is there if duplicates matter to your storage. A tab closed mid-delivery leaves its claim behind, and the next tab picks the report up 30 seconds later. createIdbStorage() closes that window: reading the queue and writing the claim happen inside one IndexedDB transaction, and the browser orders those across tabs.

Without a queue, a send can still survive the page closing under it:

ts
await sendReport("/api/feedback", report, { keepalive: true });

keepalive is passed to fetch only when the serialised body is under 60 kB. The browser caps every keepalive body a page has in flight at 64 KiB together, and a larger one makes fetch reject rather than send — so this is for a report going out during unload, not for one with a screenshot attached. For anything larger, the queue is the answer.

Below the two integrations, SendOptions has the seam they are built on: onError(report, error) runs after a failed send, before the error reaches you, and is awaited.

ts
await sendReport("/api/feedback", report, {
  onError: (failed) => queue.enqueue(failed),
});

Edit this page on GitHub