Privacy & Compliance 2026-07-01

Wiring Session Replay Into Your Consent Banner Correctly

Most session replay consent banner integrations are wrong in the same way: the replay script loads and starts recording on page load, and the consent state is checked later — or worse, only used to decide whether to send the data. By the time the user clicks “reject,” the recorder has been watching for eight seconds. If your legal basis is consent, those eight seconds had no legal basis.

The correct pattern is strict: nothing initializes until consent exists. This article is the implementation guide — the pattern, the code, the tradeoffs, and the test that proves your reject path actually works.

The start-after-consent pattern

The rule: the replay SDK is not loaded, not initialized, and not buffering until you have an affirmative consent signal. Not “loaded but paused” — not loaded. A paused recorder is still a script accessing the user’s device, which is exactly what ePrivacy-style rules gate on.

Structurally, that means your consent manager owns the lifecycle:

let replayStarted = false

async function startReplayIfConsented(consent) {
  if (replayStarted || !consent.analytics) return
  replayStarted = true

  // Dynamic import: the SDK isn't even fetched pre-consent
  const { record } = await import('./replay-sdk.js')
  record({ maskAllInputs: true })
}

// 1. Returning visitor with stored consent: start immediately
const stored = getStoredConsent()
if (stored) startReplayIfConsented(stored)

// 2. New visitor: start only when the banner resolves
onConsentChange(startReplayIfConsented)

Three details matter more than they look:

The lost-session tradeoff, honestly

Gating on consent costs you data in two distinct ways, and it’s worth being precise about which one hurts.

The first is rejection: users who decline are simply invisible. Depending on region and banner design this can be a large fraction of traffic. There is no technical mitigation; that’s what consent means.

The second is the pre-consent gap: even users who accept are unrecorded between page load and the click. For a debugging tool this is genuinely annoying — landing-page bugs, first-paint errors, and broken signup flows live exactly in those first seconds.

Some SDKs offer a middle path: buffer events in memory only, and flush the buffer to the network only if consent arrives, discarding it otherwise. Whether this is acceptable depends on your legal read. The strict interpretation says the capture is the regulated act, not the transmission, so buffering pre-consent is still processing without a basis. The pragmatic interpretation says data that never leaves the browser and is discarded on rejection harms no one. Lawyers I’ve worked with split on this; my engineering opinion is that memory-only buffering with guaranteed discard is a reasonable position for a low-sensitivity product, and an unreasonable one for anything touching health, finance, or minors. Decide deliberately and write the decision down.

CMP and TCF integration realities

If you use a consent management platform — OneTrust, Cookiebot, Usercentrics, a homegrown banner — the integration is conceptually simple and operationally fiddly.

Category mapping. Session replay doesn’t fit the classic four-bucket model cleanly. It’s not “strictly necessary.” Most teams file it under “analytics”/”performance,” which is defensible; some CMPs let you define a custom “product diagnostics” category, which is more honest. Whatever you choose, the privacy policy and the banner description need to actually mention session recording — burying a recorder under a generic “analytics cookies” label invites the argument that consent wasn’t informed.

TCF is a poor fit. The IAB’s TCF framework is built around advertising purposes and vendor IDs. Replay vendors mostly aren’t TCF vendors, and none of the standard TCF purposes describe DOM recording well. If your banner is TCF-based, you’ll typically handle replay as a non-TCF “additional” consent alongside the TC string. Don’t contort replay into a TCF purpose that doesn’t fit just to keep one code path.

Race conditions. CMPs load asynchronously, consent state may arrive before or after your app boots, and some CMPs fire their “consent ready” event before stored preferences are readable. Treat the CMP like any flaky third-party dependency: subscribe to its event and poll the stored state once at boot, and make startReplayIfConsented idempotent so the order doesn’t matter.

Consent changes mid-session

Users can open preferences and withdraw consent while the recorder is running. GDPR requires withdrawal to be as easy as granting, and it must be effective — which for replay means three things happen, in order:

  1. Stop the recorder immediately. Every serious SDK has a stop(). Call it in the consent-change handler, synchronously.
  2. Stop pending transmission. Flush queues should be dropped, not sent. Check what your SDK does with buffered events on stop — several ship the buffer as a final payload, which is exactly wrong here.
  3. Decide about the already-recorded portion. Withdrawal isn’t retroactive as a matter of law — processing before withdrawal remains lawful — but lawyers commonly advise honoring it by deleting the session server-side anyway, and I agree: a partial session up to the moment of withdrawal has near-zero debugging value and nonzero complaint value.

The re-grant direction also needs handling: a user who rejects, then later accepts in preferences, should start a new recording from that point. That falls out naturally if your start function is idempotent and subscribed to changes.

Testing that the reject path actually works

Nobody tests the reject path, which is how “we gate replay on consent” becomes untrue six months after launch. The test is simple and belongs in your E2E suite. The assertion that matters is at the network layer, not the UI:

test('reject path records nothing', async ({ page }) => {
  const replayRequests = []
  page.on('request', (r) => {
    if (r.url().includes(REPLAY_INGEST_HOST)) replayRequests.push(r.url())
  })

  await page.goto('/')
  await page.click('[data-test=consent-reject]')
  await page.click('a[href="/pricing"]')   // generate activity
  await page.waitForTimeout(5000)          // outlive any flush interval

  expect(replayRequests).toHaveLength(0)
})

Add the mirror test (accept, then assert requests do appear) so the suite catches both failure directions, and a third for mid-session withdrawal. Run them against production config, not a test build — the regressions that matter come from tag managers, CMP config edits, and SDK upgrades, none of which touch your application code.

Whether you legally need consent at all varies by jurisdiction and configuration — that analysis is in our jurisdiction-by-jurisdiction look at session replay legality. But if consent is your basis, the wiring above is the difference between a banner that satisfies the requirement and a banner that decorates a recorder that never listened to it.

See the bug the way your user did

LogReplay captures session replays, console output, network requests, and errors in one timeline — so you stop guessing what happened before the ticket arrived.

Try LogReplay free