@clickstreamhq/signalsis a developer preview on the 0.1.0-alpha line. Every API in this article is the real, current surface — verbatim-checkable against the package — but expect it to evolve before a stable release.
The Hack Everybody Ships
Somewhere in most marketing stacks is a version of this:
// The folklore version. Please don't.
document.addEventListener('mouseout', (e) => {
if (e.clientY <= 0 && !e.relatedTarget) {
showExitPopup();
}
});
The theory: a cursor racing toward the top of the viewport is headed for the close button or the URL bar, so interrupt now. It's a 2011-era desktop heuristic that has survived on inertia — it demos convincingly, because in a demo you deliberately mouse toward the tab bar and the popup appears. In production, exit intent detection built on cursor position fails in both directions at once, and on the fastest-growing slice of your traffic it never fires at all.
Why Mouse-Out Exit Intent Detection Fails
Mobile has no cursor
This is the disqualifying problem, and there's no patch for it. A phone or tablet session has no mousemove stream and no viewport-top to cross — the close control lives in browser chrome that a touch gesture reaches without ever generating the event your listener is waiting on. Every mobile-web session on your site is invisible to mouse-out exit intent. Teams sometimes substitute scroll-velocity hacks or visibilitychange listeners on mobile, but by the time visibilitychange fires, the visitor is already gone — that's an exit notification, not exit intent.
Desktop fires on everything except leaving
On desktop the event exists but the correlation doesn't hold. The cursor crosses the top edge when someone switches tabs to check their email, reaches for a bookmark, types in the URL bar to open your docs in a second tab, drags toward a second monitor, or responds to a notification. All false positives — and each one interrupts a visitor who wasn't leaving with a modal that gives them a reason to.
Meanwhile the actual exits often never touch the trigger: Cmd+W, Ctrl+L and away, closing from the taskbar, an OS-level gesture. The deeper problem isn't tuning — it's that cursor position was never evidence of intent. It's evidence that a mouse moved. If you want to know whether a visitor is about to leave, you need to look at what they've been doing, not where their pointer is.
What Behavioral Abandonment Detection Looks Like
ClickStream's collector runs an abandonment detector as one of its 26 behavioral scoring models (the CI benchmark holds the full set to p95 under 3 ms per event). Instead of one cursor coordinate, it reads the session's behavioral features for deceleration patterns — the slowdown that precedes an exit:
- Scroll velocity near zero on a non-first page — they stopped engaging with content.
- Idle drift — a session past 60 seconds producing fewer than one event per minute.
- Scroll-depth plateau — scrolling stopped partway down a page they've been on a while.
- Declining click rate — clicks happened earlier, but the pace has collapsed.
- Landed but not reading — under 5% scroll after 10+ seconds on a page.
- Abandoned form — started, then explicitly left.
- Rapid page bouncing — multi-page session averaging under 3 seconds per page: the classic “I can't find it” exit.
- Frustration exit — rage clicks or dead clicks in the current session.
Crucially, the model also scores retention counter-signals that subtract from the probability: an actively-in-progress form, very recent clicks, active scrolling, a multi-page session with solid scroll depth. Someone deep in your checkout who pauses to find their card is not abandoning, and a cursor heuristic can't tell the difference — this model can, because the in-progress form is pulling the score down while the idle time pulls it up.
The output surfaces in the public score snapshot as visitor.scores.abandonment, 0–100, defined as abandonment probability right now. Internally the detector also classifies a stage ladder — engaged → slowing → disengaging → exit_imminent — and its own default intervention threshold sits at 0.6 probability, which is 60 on the public scale. (Model weights and tiers are per-site overridable, but the defaults are what ship.)
sessionMomentum: the direction of the trend
abandonment answers “how likely is a leave right now?” Its companion field answers “which way is this session heading?” visitor.scores.sessionMomentum runs −100 to 100: progress markers push it positive — passing 50% scroll depth, going three-plus pages deep, starting a form, identifying — while the session's frustration score drags it down, weighted heavily enough that mounting frustration can wipe out every progress marker. A negative number means the engagement trend is deteriorating, not just paused.
Why you gate on both
Either field alone has failure cases. Abandonment can run high for a visitor whose momentum is still positive — they went deep, started a form, then a long idle spike hit; interrupting them is the tab-switch false positive all over again. Momentum can dip negative for a visitor who's frustrated but still actively working the page — they need a different intervention than a save-flow. The conjunction is the leave signal worth acting on: probability high and trend negative — decelerating, and not on their way back up.
Build the Save-Flow
The pattern is a subscription watching both fields, a threshold gate, one respectful intervention, and a cooldown so it can never become a nag loop. As with every Signals read, configure() comes first — if you haven't set the client up before, start with the getting-started guide:
import { configure, subscribeVisitor } from '@clickstreamhq/signals';
configure({
apiKey: 'cs_live_xxx',
endpoint: 'https://t.example.com', // your verified first-party tracking domain
});
Then the watcher:
const COOLDOWN_KEY = 'cs_save_flow_shown';
function shouldOfferSave(visitor) {
// Never act on placeholder or stale data — fail open to doing nothing.
if (visitor.stale || visitor.pending) return false;
// Humans only: check both bot axes.
if (visitor.bot.isBot || visitor.behavioralClass !== 'human') return false;
// One intervention per session, ever.
if (sessionStorage.getItem(COOLDOWN_KEY)) return false;
// The leave signal: probability high AND trend negative.
return visitor.scores.abandonment >= 60 && visitor.scores.sessionMomentum < 0;
}
const sub = subscribeVisitor(
(visitor) => {
if (!shouldOfferSave(visitor)) return;
sessionStorage.setItem(COOLDOWN_KEY, String(Date.now()));
sub.unsubscribe(); // done watching — the gate can never re-arm
showSaveBanner(); // one dismissible element; not a modal wall
},
{ fallbackToPolling: true },
);
Each line of the gate is doing real work:
- The
stale/pendingguard skips rate-limit reuse, warming-up visitors, and the zeroed placeholder served when Signals Coverage is exhausted. The default behavior of this save-flow is no save-flow — the full defensive playbook is in Fail-Open Personalization Patterns. - The human gate checks both axes.
visitor.bot.isBotis network-level classification;visitor.behavioralClassis the behavioral verdict. Automation with a clean user agent can pass one and fail the other — and a bot shown a save-flow is wasted noise at best. - The threshold is the model's own. 60 on the public scale is where the detector itself starts recommending intervention, so your page-level gate agrees with what an operator sees in the dashboard.
- The cooldown is absolute.
sessionStorageplusunsubscribe()means one offer per session, full stop. A visitor who dismisses it and keeps browsing will trip the threshold again — deceleration precedes every exit, including the one where they just leave — and nothing must fire the second time.
subscribeVisitor() is the visitor-scoped realtime stream — a Scale-and-above feature that requires a session ID and reserves Signals Coverage when it opens. With fallbackToPolling: true (the default) the same code runs on any plan: below Scale, or whenever the stream is capped or rejected, it degrades to onVisitor() polling automatically. In a React app, the hooks package wraps this same subscription lifecycle for you.
The honest latency note
Be clear-eyed about cadence: this is score-read latency, not mousemove latency. Scores are recomputed by the collector as events are ingested; your page sees them through a REST read documented at roughly 50–150 ms, and the polling path refreshes at a configurable interval defaulting to 2,000 ms with a 1,000 ms floor. You will not catch the final 200 milliseconds before a tab closes — no server round trip can.
That's a smaller loss than it sounds, because the model's inputs are themselves multi-second patterns: idle past 60 seconds, dwell past 30, a click rate collapsing across minutes. Abandonment is a window, not an instant — the deceleration typically unfolds over tens of seconds, and a signal read every couple of seconds lands your intervention inside it. The mouse-out hack traded that window for a single frame of cursor data; this trades the frame for the window.
Mobile Web Is the Point
Reread the signal list above: scroll depth, taps, dwell time, page counts, form starts, rage taps. Not one requires a cursor. Touch sessions produce every input the abandonment and momentum models consume, so the exact code above works on mobile web with zero changes — the population mouse-out could never see is the one this was built for. If you want the intervention itself to differ by form factor, visitor.device.isMobile is on the same snapshot: a bottom banner on phones, an inline card on desktop.
Guardrails: a Save-Flow, Not a Trap
Behavioral exit intent detection gives you a better trigger; it doesn't license a worse interruption. The rules that keep it respectful:
- One intervention, one dismissal. The cooldown above isn't optional decoration — it's the difference between an offer and a nag loop.
- Offer something real. Save the cart, email the half-finished configuration, surface the shipping answer they were hunting for, open a help path. “Wait! Before you go…” over a newsletter field is the old popup wearing a new trigger.
- Never block the exit. No history hijacking, no confirm dialogs, no full-screen walls. The banner is dismissible and the page behind it stays usable.
- Never score-gate pricing or core content. Scores personalize additions to the page. The moment a visitor's abandonment score changes what your product costs or what information they're shown, you've crossed from assistance into a dark pattern.
The Bottom Line
- Mouse-out exit intent is folklore — structurally blind on mobile, wrong in both directions on desktop.
- The real leave signal is behavioral:
visitor.scores.abandonment >= 60whilevisitor.scores.sessionMomentum < 0— probability high, trend negative. - The save-flow is a subscription plus a gate:
configure()first,subscribeVisitor()with polling fallback, stale/pending and human checks, a hard cooldown. - Cadence is honest: score reads at seconds-scale, matched to a signal that unfolds over tens of seconds — not a race against the close button.
The cursor was never the visitor. The behavior is. Watch the deceleration, intervene once, and let people leave.