@clickstreamhq/signalsis a developer preview (0.1.0-alpha). Every API in this post is the real, current surface — checkable against the package source — but expect it to evolve before a stable release. Of the ClickStream packages, only@clickstreamhq/sdk(1.4.0) is stable today.
Graceful Degradation Starts with One Rule: Never Break the Page
Every personalization system has a quiet dependency problem: the moment a page waits on a score before rendering, the scoring service has joined your critical path. ClickStream Signals reads a VisitorContext snapshot from your first-party tracking domain in roughly 50–150 ms — fast enough to feel instant, and exactly slow enough that treating it as guaranteed will eventually burn you. Networks partition. Content blockers block. Rate limiters trip. Coverage budgets run out three days before the billing period resets.
So the client is designed around one rule: the default page is the contract; personalization is the bonus. When anything in the read path fails, the page fails open — it renders its normal experience — while every personalization gate fails closed — no human-only action fires on data that isn't live and trustworthy. Fail-open page, fail-closed gates. That inversion is the whole design, and this post walks through the five patterns that implement it. (If you're new to the API itself, start with Getting Started with the Signals API.)
Pattern 1: getVisitorOrNull() on Render-Critical Paths
The client ships two snapshot reads. getVisitor() throws typed errors and is the right call for code that wants to observe failures. getVisitorOrNull() is the one for anything that runs on a render-critical path:
import { configure, getVisitorOrNull } from '@clickstreamhq/signals';
configure({
apiKey: 'cs_live_xxx',
endpoint: 'https://t.example.com', // your verified first-party tracking domain
});
const visitor = await getVisitorOrNull();
// null means "no signal" — the page simply keeps its default experience.
if (visitor && !visitor.stale && !visitor.bot.isBot && visitor.scores.intent >= 70) {
showComparisonConcierge();
}
Per its own source, getVisitorOrNull() exists so that when Signals is unavailable, rate-limited, misconfigured, or blocked by the browser, site code keeps showing its default page. It catches everything getVisitor() can throw and collapses it to null. There is no failure mode in which it takes your render down with it.
Notice the guard order in the condition: existence (visitor), freshness (!visitor.stale), humanity (!visitor.bot.isBot), then score (visitor.scores.intent >= 70). Each check is cheaper and more conservative than the next, and the helpers encode the same conventions — isHighIntent() uses the ≥ 70 intent threshold, isFrustrated() uses ≥ 60 frustration. For human-only actions, check visitor.behavioralClass === 'human' as well: automation-controlled browsers can briefly sit in suspicious or likely_bot before they resolve to a named bot category.
Pattern 2: Reuse the Stale Snapshot on 429
Rate limits are the failure people hit first, and the client degrades through them in two stages rather than one cliff:
- Inside the stale window — for up to
staleTtlMs(30 seconds by default) after the last good read —getVisitor()returns that last good snapshot, markedstale: true. Your freshness gate from Pattern 1 already declines to fire enhancements on it, so the page quietly holds its current state. - Past the window,
getVisitor()throwsSignalsRateLimitError— carryingretryAfterwhen the collector supplied it — andgetVisitorOrNull()turns that intonull.
import { configure, getVisitor, SignalsRateLimitError } from '@clickstreamhq/signals';
configure({
apiKey: 'cs_live_xxx',
endpoint: 'https://t.example.com',
staleTtlMs: 30_000, // default: how long the last good snapshot may be reused
});
try {
const visitor = await getVisitor();
if (visitor.stale) keepDefaultExperience(); // reused data — enhance nothing
} catch (err) {
if (err instanceof SignalsRateLimitError) {
// Rate-limited past the stale window. err.retryAfter (seconds) says when
// to try again — but the page rendered long ago, so "do nothing" is correct.
}
}
Long-lived subscriptions get the same treatment without your help: onVisitor() keeps retrying through a 429 with Retry-After-aware backoff, and a transient 5xx on an individual poll tick is logged with console.warn but does not stop the subscription. A listener that survives the night is worth more than one that dies loudly at 2 a.m.
Pattern 3: Coverage Exhaustion Fails the Gates Closed — While the Page Renders
The failure mode people design for least is the one that's guaranteed to happen eventually: the Signals Coverage budget for the billing period runs out. (Coverage allowances are part of your ClickStream plan; pageview billing itself only counts humans, a philosophy covered in our fail-open billing post.)
Here's the part that surprises people: coverage exhaustion is not an error. REST reads don't 4xx. The server returns a 200 with a placeholder VisitorContext: coverageMode: 'degraded', reason: 'signals_coverage_limit_reached', pending: true, stale: true, all scores zeroed, and behavioralClass: 'suspicious'.
Look at what those values do to the gates you already wrote. intent >= 70 fails on a zeroed score. behavioralClass === 'human' fails on 'suspicious'. !visitor.stale fails on true. Every reasonable personalization gate fails closed by construction — including gates in code that has never heard of coverageMode. That's the defense-in-depth for the copy-paste path: the placeholder is shaped so that naive code degrades safely.
Explicit code should still gate on coverage directly:
import { configure, getVisitorOrNull } from '@clickstreamhq/signals';
configure({ apiKey: 'cs_live_xxx', endpoint: 'https://t.example.com' });
const visitor = await getVisitorOrNull();
if (!visitor || visitor.pending || visitor.coverageMode === 'degraded') {
keepDefaultExperience();
} else if (!visitor.bot.isBot && visitor.behavioralClass === 'human' && visitor.scores.intent >= 70) {
document.documentElement.dataset.signalEffect = 'high_intent_action';
}
So exhaustion isn't silent, the client logs a one-time console.warn the first time it sees a degraded snapshot — the condition persists for the rest of the billing period and is otherwise easy to mistake for a visitor that never warms up. The single exception to the no-error rule is the Scale+ per-visitor realtime stream, which rejects coverage exhaustion outright with a 402 signals_coverage_limit_reached before the WebSocket handshake completes — and subscribeVisitor() falls back to polling by default (fallbackToPolling defaults to true), where it receives the same safe placeholder.
Pattern 4: coverageMode Is Transparency, Not an Error Code
coverageMode is deliberately boring: 'full' or 'degraded', present on the context and mirrored on the X-ClickStream-Signals-Coverage-Mode response header, so you can see the account's state in a network tab or an access log without parsing a body. The documented guidance is one sentence: treat 'degraded' like stale — keep the default experience and do not fire human-only actions.
What the header buys you operationally is honesty about why personalization went quiet. A dashboard that alerts on the ratio of degraded reads tells you "the budget ran out on the 27th" instead of "conversion-influencing UI silently stopped appearing and nobody knew." Degradation you can see is a feature; degradation you discover in a quarterly review is an incident.
Pattern 5: The Brand-New Visitor Gets a Pending Context, Not a 404
There's a second placeholder, and it happens on every visitor's first pageview: the browser exists client-side, but the first scored event hasn't reached Signals yet. Many systems return a 404 here and force every caller to write a special case for "new person." Signals returns a normal-shaped VisitorContext with pending: true and reason: 'visitor_initializing' — conservative in exactly the same way as the coverage placeholder, and handled by exactly the same gates.
// The two placeholder reasons, and how long each lasts:
visitor.reason === 'visitor_initializing'; // clears within seconds
visitor.reason === 'signals_coverage_limit_reached'; // persists until the billing
// period resets (or overage
// billing is enabled)
The reason field is what separates them. A pending visitor resolves as soon as the first scored event lands — scoring runs 26 models with a CI-enforced p95 under 3 ms per event, so "seconds" here is dominated by event delivery, not scoring. An exhausted budget persists until the period resets. Same safe shape, very different alerting story.
The Freshness Vocabulary
All five patterns read from the same small set of fields on every snapshot:
visitor.transport; // 'rest' | 'stream' | 'cache' | 'stale'
visitor.snapshotVersion; // usually the producing event timestamp
visitor.sourceEventId; // event id when the SDK supplied one
visitor.ageMs; // approximate client-observed age
visitor.stale; // reused stale-but-safe data OR a server-marked placeholder
visitor.pending; // first scored event still catching up
visitor.coverageMode; // 'full' | 'degraded'
visitor.reason; // 'signals_coverage_limit_reached' | 'visitor_initializing'
If you only remember one line from this post, make it if (visitor.stale) keepDefaultExperience();. Every degraded state in the system — client-side reuse, coverage exhaustion, the initializing visitor — sets that flag.
Every Failure Mode, One Table
| Failure | What the client does | What your page does |
|---|---|---|
| Endpoint unreachable or blocked | getVisitorOrNull() returns null |
Renders the default |
429, within staleTtlMs (30 s default) |
Reuses the last good snapshot, marked stale: true |
Freshness gates decline; default holds |
429, past staleTtlMs |
getVisitor() throws SignalsRateLimitError; getVisitorOrNull() returns null; onVisitor() backs off per Retry-After |
Renders the default |
| Signals Coverage exhausted | 200 placeholder: coverageMode 'degraded', scores zeroed, behavioralClass 'suspicious', pending + stale; one-time console.warn |
Gates fail closed; page renders |
| Brand-new visitor | Pending placeholder, reason 'visitor_initializing' |
Default until scores land (seconds) |
| Realtime stream capped or rejected | subscribeVisitor() falls back to polling (default) |
Nothing visible changes |
| Transient 5xx on a poll tick | onVisitor() logs console.warn, keeps the subscription |
Nothing visible changes |
The Bottom Line
Fail-open personalization isn't one API — it's a discipline the whole surface repeats. The same rules-with-safe-defaults shape shows up in the effects engine's declarative when gates, and the server-side Next.js adapter applies the identical contract at render time: getServerVisitor() never throws either. In practice it reduces to four habits:
- Read with
getVisitorOrNull()on anything render-critical; reservegetVisitor()for code that wants to log the typed error. - Gate in order: existence, freshness (
stale), coverage (coverageMode), humanity (bot.isBotandbehavioralClass), then score. - Trust the placeholders: zeroed scores and
'suspicious'exist precisely so gates you already wrote fail closed on degraded data. - Watch
coverageModeandreasonso degradation is a graph you check, not a surprise you excavate.
A personalization system earns trust in its worst moments. The page that renders anyway is the product; the signal is the upgrade.