What no SDK can capture
Error capture has hard edges, and they are mostly not ours to move: the browser's security model, the operating system's process model, and the simple fact that a handler cannot observe an error that fired before the handler existed. This page is the complete list of those edges as they apply to Crossdeck — what the SDK cannot see, why each limit exists, and the mitigation where one exists. A few limits have no mitigation, for us or for anyone; we say so rather than imply otherwise.
The boundaries at a glance
| Boundary | Why it exists | What you can do |
|---|---|---|
Errors before init() | No handler is listening yet | Initialize as early as possible |
| Caught exceptions | Your catch handled them — they never escape | Forward with captureError() |
| Cross-origin script details | The browser redacts them for security | crossorigin="anonymous" + CORS header |
| Reports blocked by CSP | Your policy blocks the request before it leaves | Allowlist api.cross-deck.com in connect-src |
| Readable minified stacks | Browsers run the code you shipped, not your source | Upload source maps per release |
| Identity on anonymous errors | identify() hadn't run yet — attribution, not capture | Call identify() at sign-in |
| Some process-death classes | The OS kills the process without running another instruction | Platform-specific; see below |
| Events in flight at device death | A device that never comes back can't deliver its queue | Durable queues bound the loss; money never rides this path |
1 · Errors before the SDK initializes
The SDK's error hooks — the global error listener, the unhandled-rejection
listener, the fetch and XHR wraps — are installed when
Crossdeck.init() runs. An error thrown before that moment had no
handler listening, and no platform replays past errors for handlers registered
later. This is a platform fact, and it applies to every error-capture product
equally: there is always a blind window between the first instruction of your page
or process and the instruction that installs the hooks.
What you control is the size of that window. init() is synchronous and
returns immediately, so there is no cost to calling it first: in the
<head> for plain HTML, at the top of your provider tree or entry
module for bundled apps, before your server registers routes in Node. Initializing
early doesn't eliminate the window — nothing can — but it shrinks it to the few
milliseconds it takes your bundle to reach the call.
2 · Caught-and-swallowed exceptions
Automatic capture sees errors that escape — the ones that reach the global
handler because nothing in your code dealt with them. An exception your own
try/catch handles is invisible by definition: the platform delivered
it to your code, your code consumed it, and from the runtime's point of view
nothing went wrong. No SDK can see inside your catch blocks, and it
would be alarming if one could.
The bridge is one call: pass the error along when you want it on the dashboard anyway.
try {
await applyDiscount(cart);
} catch (err) {
Crossdeck.captureError(err); // captured as error.handled
showFallbackPricing(cart);
}
Framework error boundaries are catch blocks too. A React or Vue render crash that
your boundary catches never reaches the global handler — forward it from
componentDidCatch (or Vue's errorCaptured) with the same
call. The manual API exists on every platform:
captureError() on Web, Node, and React Native, and
captureError(_:handled:) on Swift. See
Capture an error for the full API.
3 · Cross-origin scripts: "Script error", blank frames
When a script served from a different origin throws, and that script was loaded
without crossorigin="anonymous" and a CORS header, the browser redacts
the error before any handler sees it: the message becomes
"Script error.", the error object is null, and there is no file, line,
or stack. This is the browser's security model working as designed — error details
can leak information about cross-origin responses, so the browser withholds them
unless the script's origin has explicitly opted in.
Crossdeck does not silently drop these. The SDK recognizes the redaction signature,
captures the event with an explicit label —
"Cross-origin script error (browser hid details — script needs crossorigin
attribute + CORS headers)" — and tags it cross_origin: true so the
dashboard groups them as one issue with one known fix. They share a single group
deliberately, because a single change fixes them all:
<script src="https://cdn.example.com/widget.js" crossorigin="anonymous"></script>
<!-- and on cdn.example.com: Access-Control-Allow-Origin: * (or your origin) -->
Until both halves are in place, the frames stay blank. That is the browser
withholding the details, not the SDK losing them — and if the third-party origin is
not yours to configure, the details are simply not obtainable, by any vendor. Apps
that would rather mute these entirely can add "Script error" back to
ignoreErrors in their init config.
4 · Strict CSP without a connect-src entry
If your site ships a Content-Security-Policy whose connect-src does not
include Crossdeck's reporting endpoint, the browser blocks every outgoing report
before it leaves the page. Nothing arrives — including the report that would have
told us something is wrong. From the dashboard this is indistinguishable from
silence, which is exactly why it is on this page.
The one place the failure is visible is your own browser console: the browser logs a CSP violation for each blocked request. The fix is one directive:
Content-Security-Policy: connect-src 'self' https://api.cross-deck.com
CSP is enforced by the browser, below any JavaScript. There is no workaround on our side and there shouldn't be — your policy is the authority. If errors stop arriving from a deployment that just tightened its CSP, check the console for connect-src violations first.
5 · Minified stacks without uploaded source maps
This boundary is about readability, not capture. Production JavaScript is minified;
the browser runs — and reports — the code you shipped. So the stack arrives intact
but reads as a.b.c is not a function at bundle.min.js:1:48217. The
original names exist only in the source maps your build produced, and Crossdeck
never asks you to publish those — you upload them privately, keyed to the release:
npx @cross-deck/cli upload-sourcemaps \
--release "$GIT_SHA" \
--url-prefix https://app.example.com/assets/ \
./dist
The error is captured either way; the upload is what buys readable frames. Until maps for a release are uploaded, that release's stacks stay minified, and the dashboard shows a card naming the missing release rather than leaving you to guess. Wiring this into CI — so every deploy uploads its maps automatically — is covered in Source maps.
6 · Anonymous errors: attribution, not capture
An error that fires before identify() has run for that visitor is
captured in full — stack, breadcrumbs, context — but it carries no developer
identity. The dashboard shows it as "Developer ID: Not linked". Nothing
failed: anonymity is an absence, not an error, so there is nothing for the SDK to
flag and nothing for the dashboard to warn about. The SDK cannot attribute an error
to a user it hasn't been told about.
The mitigation is to call identify(userId) as soon as you know who the
user is — at sign-in, at session restore — and every error after that attaches to
them. Errors from genuinely anonymous visitors (pre-signup traffic, logged-out
sessions) will always be anonymous, and that is the correct record of what happened.
7 · Process death, by platform
When the operating system kills a process outright, no instruction runs after the kill — including ours. What varies by platform is which death classes give the SDK a final moment, and what survives for the next launch. The scopes below follow each SDK's actual hooks, not a generic claim.
Web
The web SDK hooks window's error and unhandled-rejection events and
wraps fetch and XMLHttpRequest. None of those fire when
the tab itself dies: a tab killed by the user, a tab discarded under memory
pressure, or a browser crash stops JavaScript mid-instruction with no callback. No
browser exposes a hook for its own death, so no web SDK can capture it. What
does survive: every event already enqueued is persisted to
localStorage on enqueue, so errors captured before the death deliver on
the next visit.
Node
The Node SDK hooks process.on('uncaughtException') and
process.on('unhandledRejection'), so crashes that surface as JavaScript
errors are captured. SIGKILL and the kernel's OOM-killer are different:
they terminate the process without running another line of JavaScript — no process
on any runtime can observe its own SIGKILL. And because the Node queue
is in-memory, events buffered at that moment die with the process. The window is
small — the queue flushes every 1.5 seconds by default — and a graceful shutdown
(await server.shutdown()) drains it to zero. The full bounds are in
Event delivery & durability.
Swift (iOS / macOS / watchOS)
The Swift SDK does more here than the generic "SDKs can't capture crashes" claim
suggests, and less than a dedicated crash reporter — both halves stated precisely.
It does capture uncaught Objective-C exceptions
(NSException) via NSSetUncaughtExceptionHandler: the
handler snapshots the exception and stack in the few hundred milliseconds before
the OS kills the app, writes the event into the disk-persisted queue, and chains to
whatever crash reporter you registered first — Crashlytics, Sentry, or Bugsnag
still get their crash. The write races process death, so it is best-effort — but
because the queue persists to disk and rehydrates on launch, a snapshot that lands
delivers on the next launch even when no network send completes before the kill.
It does not capture signal-class deaths: Swift runtime traps
(force-unwrapping nil, out-of-bounds, fatalError) raise
signals rather than NSExceptions, and the SDK installs no signal or
Mach exception handlers. SIGSEGV, SIGABRT, watchdog
terminations, and jetsam (OOM) kills fall outside its scope. If you need
signal-level crash reporting, run a dedicated crash reporter alongside Crossdeck —
the handler chaining above exists precisely so the two coexist without either
losing a crash.
React Native
The React Native SDK hooks ErrorUtils.setGlobalHandler, which covers
uncaught JavaScript errors — fatal and non-fatal — and chains the prior handler.
Captured-but-unsent errors persist in AsyncStorage and deliver after a
restart. What it cannot see is a crash in the native layer: the SDK is
JavaScript-only, and a crash in iOS or Android native code never reaches the JS
runtime. As with Swift, a native crash reporter covers that layer and coexists
cleanly.
8 · Events in flight when the device dies
The last boundary is delivery itself. On Web, React Native, and Swift the queue is persisted on every enqueue, so a crash, force-quit, or power loss does not lose captured events — they deliver on the next launch. The genuine loss cases are the ones no queue can survive: a device that never comes back, a browser profile whose storage is cleared before the next visit, a visitor who never returns. The bounds are concrete — each device holds its most recent ~1,000 events, oldest evicted first — and they are documented as numbers, not adjectives, in Event delivery & durability.
Payments, subscriptions, and entitlements never travel through the SDK queue. Money moves on server-side rails — Stripe, Apple, and Google report directly to Crossdeck's backend via signed webhooks, with their own retries, idempotency keys, and reconciliation sweeps. A dead device, a cleared queue, or an uninstalled app cannot lose a payment record or flip an entitlement. Analytics is durable best-effort with honest bounds; money is multiply-guarded. The two never share a failure mode.
Related
- Capture an error — the manual API that bridges every "invisible by definition" case on this page.
- Source maps — uploading maps per release so minified stacks read as your source.
- Event delivery & durability — the precise promise behind the queue: per-platform persistence, the ~1,000-event bound, and the money-is-never-at-risk line.