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:
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.
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 storageWhen 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:
"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:
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:
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:
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.
await sendReport("/api/feedback", report, {
onError: (failed) => queue.enqueue(failed),
});