An offline-first browser error tracking pipeline that runs inside the failure path, redacts before it stores anything, and slows one repeating failure instead of a whole page.

You are on a call with support. A customer says checkout failed twice this morning. Then it worked. You ask them for the browser console, they send a screenshot, and the console is empty, because the page has been reloaded twice since then. The network tab is gone with it. What you have is one sentence and a timestamp.

Somewhere in your dashboard, that customer has no error. Not a wrong error, not a truncated stack, just nothing. Which means the dashboard is telling you a story about happy users, and the users who are quietly giving up look exactly the same as them.

So here is the question that decides whether your browser error tracking is useful or decorative: what happens to the errors that never reach your tracker at all?

Between here and there you will build a capture pipeline that stores locally before it ever touches the network, redacts before it writes anything, slows one repeating failure without silencing the new ones, and ends in a single HTML file that a support engineer can open and read.

Keep one question in mind while you read, because I answer it near the end. Where does an error go when it is thrown before your tracker starts, and the tab closes a second later?

If debugging is the part of the job you actually like, follow me here on Medium. I publish one practical frontend piece a week and I reply to comments. If someone on your team owns the error dashboard, send them this.

Why browser error tracking fails at the worst possible moment

Quick version. Your reporter is a handful of lines that run after something already went wrong. One call at startup, one call per failure you handle yourself, and a way to get the results out. In the version I use below, that startup call captures uncaught errors, unhandled promise rejections and everything logged at warn or above into IndexedDB, and the results come out when someone presses Ctrl+Shift+Alt+D. It is the smallest piece of your error handling, which is exactly why it gets the least attention.

Real-world version. The failure modes are not exotic. They are the ordinary consequences of running code in a hostile moment.

A convenience reporter that touches error.message on a thrown value that is not an Error throws a second exception inside your catch block. A fetch call that is not keepalive gets cancelled when the tab closes, so the error you captured during pagehide never leaves the browser. A URL pasted into a payload carries whatever token a user's password reset link had in the query string. One broken polling endpoint produces the same error every five seconds until it owns your entire rate limit, and the genuinely new failure that happened at minute three is the one that gets dropped. Any script loaded from a third-party origin can be blocked by an extension or a strict Content Security Policy, and when that happens capture stops silently.

Under the hood. These are not separate bugs to fix one at a time. They come from one design decision: whether the reporting path is allowed to fail. The library I use in the examples below makes five promises to itself, and each one kills a class of the failures above.

It cannot throw, so a hostile value cannot turn reporting into a second crash. It cannot recurse, because a flag drops anything raised while the pipeline is already running. It cannot double count, because a WeakMap remembers the error objects it has already seen. It cannot flood, because a fixed-window limiter drops the excess. It cannot lose pre-init errors, because failures thrown before initTelemetry runs are normalised immediately and replayed once the application metadata exists.

Teaser. Which brings us to the smallest and most important function in the whole system, the one that people write in thirty seconds and regret for a year.

Before we get into the examples, a quick note: The code snippets provided here are meant purely for understanding the concept. Some syntax shown may reflect patterns from earlier Angular/React versions. Always refer to the official documentation for the most current API and syntax.

The reporter that throws while it reports

Quick version. Here is a helper that a lot of codebases have somewhere, and it looks fine.

// shared/report-error.ts export function reportError(error: unknown): void { const payload = JSON.stringify({ message: (error as Error).message, stack: (error as Error).stack, url: window.location.href, }); void fetch('/telemetry/errors', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: payload, }); } 

It compiles, it runs, and in development it even works. Then production hands it a value that is not an Error, and the same helper fails in four different ways.

(error as Error).message on a thrown string returns undefined and you log an empty record. On null it throws a TypeError, and because this ran inside your own catch block, the original failure is replaced by a new one. window.location.href includes the query string, so a reset link with a token in it is now in your telemetry. And fetch without keepalive is dropped by the browser when the page is closing, which is when the errors you most want are captured.

Real-world version. The fix is not to write a longer version of the same function. It is to route every capture through one ingestion point that owns normalisation, redaction and persistence.

// shared/telemetry.ts import { captureError, initTelemetry, logger } from '@codewithrajat/rm-logvault'; initTelemetry({ appName: 'checkout', appVersion: '2.4.1', environment: 'production', }); export function reportCheckoutFailure(error: unknown, orderId: string): void { captureError(error, { tags: { flow: 'checkout', route: '/checkout/:orderId' }, extra: { orderId }, severity: 'error', }); logger.warn('[Checkout] payment attempt failed', { orderId }); } 

Note what is missing. No window.location.href, because the record picks up page context itself. No .message access, because captureError accepts unknown and normalises whatever it gets. No await, because capturing is fire-and-forget and the write is queued locally before any network is involved.

The library is @codewithrajat/rm-logvault, published on npm with the source on GitHub.

The thrown value is the first argument, which makes the helper safe to call from any catch block you already have, including the one that rethrows.

// checkout-api.ts import { reportCheckoutFailure } from './telemetry'; export async function payForOrder(orderId: string): Promise<void> { try { await postPayment(orderId); } catch (error) { reportCheckoutFailure(error, orderId); throw error; } } 

The record that lands in IndexedDB after payForOrder('A-1024') fails looks like this, trimmed to the fields that matter.

{ "schemaVersion": 1, "source": "manual", "severity": "error", "handled": true, "thrownType": "error", "name": "Error", "message": "Payment provider returned 502", "fingerprint": "3f2c81a9c4b7d0e5", "occurrenceCount": 1, "tags": { "flow": "checkout", "route": "/checkout/:orderId" }, "extra": { "orderId": "A-1024" }, "page": { "url": "https://shop.example.com/checkout", "route": "/checkout", "pageLoadId": "pl_8f13" }, "uploadStatus": "pending" } 

Under the hood. Normalisation is where most of the work hides. A thrown string, a thrown number, a rejected Promise with a plain object, a Proxy whose getter raises, an AggregateError, a cause chain four levels deep: all of them have to become the same shape without ever throwing. The pipeline records thrownType so you can tell a real Error from an object someone rejected with, keeps up to three links of the cause chain, and reads at most five sub-errors from an AggregateError.

When the pipeline itself fails, it does not surface a new error to the user. It reports through an internal channel that fires once per stage, which is why a broken storage layer produces one warning instead of one warning per captured error.

If you have a value that must become a real Error before it reaches code that expects one, toError does that without stringifying anything hostile that came in with it.

Teaser. Capturing locally is cheap. Deciding when those records travel, and what happens when they cannot, is the part people get wrong next.

Where do browser errors go when the network is down?

Quick version. Nowhere, and that is the point. The default configuration stores everything in IndexedDB and makes no network request at all.

// main.ts import { initTelemetry } from '@codewithrajat/rm-logvault'; initTelemetry({ appName: 'checkout', appVersion: '2.4.1' }); 

Two databases are created, rm-logvault-errors and rm-logvault-logs. Errors are capped at 500 rows kept for 7 days, logs at 2000 rows kept for 3 days. Nothing is sent anywhere, which is why this works with no backend, in an air-gapped build, and in a page with a locked-down CSP.

Real-world version. The turning point here is a design decision rather than a feature: nothing travels straight from the failure to the network. A queue that only lives in memory is not a queue, and the interesting moments are the ones where the page is going away.

The library flushes the write buffer on pagehide and on visibilitychange when the page becomes hidden, and it asks the uploader for a keepalive drain at the same time. That combination is what stops the last thirty seconds of a session from disappearing with the tab. It is also why a burst of logs can still be lost, since the buffer holds entries for up to a second before writing unless the batch size is reached first.

Storage is not guaranteed either. Private browsing modes, a full origin quota, and a corrupted database all happen, and the quota rules are set by the browser rather than by you, as covered in Storage for the web. When opening the database fails, storage moves to unavailable and the library keeps working with the console only, because losing diagnostics is much less bad than breaking the application.

// support-tools.ts import { getTelemetryStatus } from '@codewithrajat/rm-logvault'; export function describeTelemetry(): string { const status = getTelemetryStatus(); return `mode=${status.mode} storage=${status.storage} pending=${status.pending.errors}/${status.pending.logs} dropped=${status.droppedByRateLimit}`; } 

Under the hood. What sits on top of IndexedDB is an outbox. A write is a pending row. A sync run claims rows atomically, sends them, and marks them uploaded. Claiming matters for correctness, not performance: with two tabs open, two sync loops would otherwise upload the same record twice.

 captureError(error, ctx) | v normalize -> fingerprint -> already seen this Error object? | | | merge tags/extra onto the pending row v sanitize (redact + bound) -> IndexedDB row (pending, occurrenceCount) | v outbox: claim -> send -> mark uploaded | retry with backoff | mark failed | v HTML report (Ctrl+Shift+Alt+D) / json / jsonl / csv 

Aggregation only ever touches pending rows. A row that is being uploaded is immutable, because rewriting a record after it has left the browser would make the server copy and the local copy disagree with each other and with their own fingerprint.

Teaser. None of that queue matters if the thing you are queueing is a customer's session token.

Redaction should happen before storage, not before upload

Quick version. Records are scrubbed on the way in. A key named token, email, password or authorization becomes [REDACTED] regardless of nesting, and the same happens inside free text.

// shared/telemetry.ts import { captureError, initTelemetry } from '@codewithrajat/rm-logvault'; initTelemetry({ appName: 'checkout', redaction: { extraSensitiveKeys: ['orderReference'], allowedQueryParams: ['page', 'locale'], }, }); captureError(new Error('card declined for user@example.com'), { extra: { accessToken: 'sk_live_51H8x', locale: 'en-GB' }, }); 

The stored record keeps the shape of your object and none of the secrets.

{ "message": "card declined for [REDACTED]", "extra": { "accessToken": "[REDACTED]", "locale": "en-GB" } } 

Real-world version. Redacting on the server is the common mistake, and it is a mistake even when the server is yours. The data has already crossed the network, sat in a request log, and been handled by whatever sits in front of your API. Redacting in the browser before the write means the plaintext value never exists in any store you have to audit later.

The second common mistake is assuming the redactor only looks at keys. It also scans text, because phone=+14150000000 in a log message leaks the same value as { phone: '+14150000000' } would. The sensitive-key match is done against a lowercased key with separators stripped, so access_token, accessToken and ACCESS TOKEN all resolve to the same decision. Free text gets JWT-shaped strings, Bearer and Basic schemes, e-mail addresses, and token=... style pairs scrubbed, along with long opaque runs of 32 to 200 word characters, which is the shape of a leaked API key or a dashless UUID.

Query strings follow a different rule, because dropping every value would make a URL useless. Values survive only for parameter names on an allow-list, which by default is limit, offset, page, pageSize, size, sort, order, scope, lng, lang and type. A URL that is really an identifier gets replaced with a placeholder, and the fingerprint normalises those placeholders so /orders/8842 and /orders/9917 group as one bug.

Under the hood. The sanitizer is one instance reached through getDefaultSanitizer(), and it is wired before anything can capture, not after.

The stronger half of the privacy claim is what is never read at all, since there is no rule to get wrong when there is no read. Request and response bodies are never touched, including axios data and params. Cookies and document.cookie are never read. localStorage and sessionStorage are never read, because the vault is separate storage rather than a view over yours. Auth header values are never read, and only four named correlation headers are probed by name for a request id. Form values are never read. The only DOM element the library inspects is the shortcut's own event target, and only to decide whether to stop.

Bounding is as important as removing. Text is truncated before any pattern runs, so the regular expressions cannot be driven into a pathological backtrack. Values are walked to a depth of four, thirty keys, twenty array items, two thousand characters per string. Records are then reduced against a byte budget, 16 KB for errors and 4 KB for logs, so one enormous stack trace cannot block the write queue.

Which of your own query parameters would survive that allow-list? Open one real production URL from your application and look at what the key names are. If an identifier you did not expect is in the list, you just found a leak before an auditor did.

Teaser. A redacted record is still capable of drowning out the record that matters.

One failing poll should not bury every other error

Quick version. Identical failures aggregate into one row with an occurrenceCount, so a thousand repeats cost one row instead of a thousand.

// shared/telemetry.ts import { captureError, flushTelemetry, initTelemetry } from '@codewithrajat/rm-logvault'; import { createMemoryRepository } from '@codewithrajat/rm-logvault/testing'; const repository = createMemoryRepository(); initTelemetry({ appName: 'checkout', repository, shortcut: false }); for (let attempt = 0; attempt < 5; attempt += 1) { captureError(new Error('orders refresh failed'), { tags: { poll: 'true' } }); } await flushTelemetry(); const rows = repository.errors.all(); // rows.length === 1, rows[0].occurrenceCount === 5 

Real-world version. Aggregation alone does not save you, and this is the bug I see most often in hand-rolled trackers. A global limit answers "how many errors may this page produce per minute". It does not answer "may this one error be produced again". A poll that refetches every five seconds and fails every time spends a tenth of a 120-per-minute budget on a failure that is already sitting in the report. Ten endpoints failing on that cadence exhaust the budget completely, and the new failure at minute three is the one that gets dropped, which is the exact opposite of what you want.

The fix is a second limiter, per fingerprint, consulted before the global one. Twelve repeats per minute by default. A repeat that is refused does not consume the global budget, so the budget goes to failures nobody has seen yet.

// shared/telemetry.ts import { initTelemetry } from '@codewithrajat/rm-logvault'; initTelemetry({ appName: 'checkout', errors: { maxEventsPerMinute: 120, maxRepeatsPerMinute: 12, }, }); 

A wrong version that still runs, and still looks reasonable, is deduplicating on the full stack including line numbers.

// shared/dedupe-error.ts const seen = new Set<string>(); export function isDuplicate(error: Error): boolean { const key = `${error.message}:${error.stack ?? ''}`; if (seen.has(key)) return true; seen.add(key); return false; } 

Every deploy shifts line numbers, so every deploy creates a brand new group for a bug you already knew about. The set also grows without bound for the lifetime of the page. Dropping the stack and deduping on the message alone fails in the other direction, collapsing two unrelated failures that happen to share a message.

Have you ever lost an evening to a bug like one of these, where the tooling lied to you about what happened? Tell me what it was in the comments, because these are usually more interesting than the fix.

Under the hood. A fingerprint is built from the source, the normalised message, the top stack frames, and a path with identifiers replaced. It is hashed with cyrb53 rather than a cryptographic hash, because this value is a grouping key, not a security boundary, and the whole thing has to stay small and fast on the error path.

Two layers of dedupe run before any of that. Capturing the same Error object twice, once from a React boundary and once from window.onerror, does not create two records, because the object identity is remembered in a WeakMap. The second capture is not wasted either. It can merge severity, tags and extra onto the row that already exists, which is how a global handler's generic capture gets upgraded by the caller that actually knew where the failure came from.

message, stack, category, page and environment are deliberately not patchable. They describe the occurrence, and the first capture recorded them. Changing them afterwards would leave a stored record disagreeing with its own fingerprint.

The aggregate itself is mutable by design: a repeat widens lastSeen and increments occurrenceCount on the pending row. Repeat throttling is the same mechanism that keeps that update path from becoming a write storm.

Teaser. Everything so far assumes your code threw the error. A lot of real browser failures never reach a try block at all.

The errors a window.onerror handler cannot see

Quick version. Two of these are off by default, because capturing them changes behaviour for the host page. The third is on by default, and the snippet below sets all three so you can see where they live.

// shared/telemetry.ts import { initTelemetry } from '@codewithrajat/rm-logvault'; initTelemetry({ appName: 'checkout', errors: { captureResources: true, captureCsp: true, captureChunkErrors: true, }, }); 

Resource failures cover a <script> or <link> that failed to load. CSP violations arrive as securitypolicyviolation events. Chunk errors are dynamic import failures, and they are on by default because a stale chunk after a deploy is one of the most common production-only failures there is.

Real-world version. Each of these has a reason to be invisible by default.

A resource error does not bubble, so a listener on the document in the bubble phase never sees it, which is why the error event has to be caught during capture. A CSP violation is not an error at all, it is an event with a blockedURI and a directive. And a cross-origin script that throws produces the message Script error. with no stack and no details, which is a browser security decision rather than a bug in your tracker. The same rule applies to rejected promises from cross-origin scripts, which do not fire unhandledrejection at all, as documented on MDN.

Dynamic imports need one more thing, because the failure happens before any component or route handler is running. withChunkCapture wraps the loader and attaches the route pattern.

// orders.routes.ts import { withChunkCapture } from '@codewithrajat/rm-logvault'; export const loadOrderDetail = withChunkCapture( '/orders/:id', () => import('./pages/order-detail'), { tags: { flow: 'order-detail' } }, ); export async function openOrder(orderId: string): Promise<void> { try { await loadOrderDetail(); } catch { offerReload(); } } 

The record from that failure carries source: 'chunk' and fatal severity, which is what makes "the deploy broke lazy loading" a query rather than a hunch.

Under the hood. Angular and React both funnel framework failures into the same ingestion point, which is the only reason the grouping works across boundaries. Angular's ErrorHandler is an injectable class, so the adapter subclasses it, reports, and then delegates to console.error so the framework's own diagnostics are unchanged. React 19 added root level error callbacks to createRoot, and the adapter exposes them as a spreadable bundle.

Workers are their own case, because a worker has its own global scope. The worker adapter listens for error and messageerror inside the worker and forwards the failure, which is the only way an uncaught exception in a worker stops being silent.

If your application knows something the library cannot, a context builder is the hook for it. Registration is by name, a second registration under the same name replaces the first, and a throwing builder falls through to the next one instead of swallowing the capture.

// shared/telemetry.ts import { registerErrorContextBuilder } from '@codewithrajat/rm-logvault'; const unregister = registerErrorContextBuilder({ name: 'feature-flag-failures', canHandle: (error) => error instanceof Error && error.message.includes('flag'), build: (error) => ({ tags: { subsystem: 'feature-flags' }, extra: { flagMessage: error instanceof Error ? error.message : 'unknown' }, }), }); // Later, on teardown. Safe to call twice. unregister(); 

Teaser. Two of those boundaries are in frameworks you are already using, and wiring them the wrong way is how people end up with a capture from a component that React never actually failed on.

Wiring React and Angular error boundaries without replacing them

Quick version. In React, an error boundary that reports is a class component, because that is still how boundaries work.

// app-root.tsx import { TelemetryErrorBoundary } from '@codewithrajat/rm-logvault/react'; import { App } from './app'; export function AppRoot() { return ( <TelemetryErrorBoundary fallback={(error, reset) => ( <div> <p role="alert">Something went wrong: {error.message}</p> <button type="button" onClick=Source:https://dev.to/codewithrajat/browser-error-tracking-the-errors-your-sdk-never-sends-5ek1

Related posts