Engineering

Fail-Open Personalization: Graceful Degradation Patterns

What the Signals client does when the network is slow, the snapshot is stale, the rate limiter says no, or the coverage budget is gone — and why your page never notices.

July 2026 • 8 min read • Developer preview (0.1.0-alpha)

@clickstreamhq/signals is 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:

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:

A personalization system earns trust in its worst moments. The page that renders anyway is the product; the signal is the upgrade.

Personalize Without a Single Point of Failure

Install the pixel, configure the Signals developer preview, and unplug your network cable — the page renders anyway. That's the contract.

Start free