Engineering

Exit Intent Detection Without the Mouse-Out Hack

Phones have no cursor, and desktop mouse-out fires every time someone reaches for another tab. The real leave signal is behavioral: visitor.scores.abandonment rising while sessionMomentum goes negative — and it works on mobile.

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

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

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 — engagedslowingdisengagingexit_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:

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:

The Bottom Line

The cursor was never the visitor. The behavior is. Watch the deceleration, intervene once, and let people leave.

Retire the Mouse-Out Listener

Install the pixel, configure Signals, and gate one respectful save-flow on abandonment and momentum. If the read fails, no popup — that's the point.

Start free