Home / Blog / How to debug consent race conditions in lazy-loaded components

How to debug consent race conditions in lazy-loaded components

Your consent setup works perfectly in testing: the banner loads, consent resolves, and blocked trackers stay blocked. Then a consent scan flags trackers firing on real visits. The cause is almost always a race condition: a lazy-loaded component mounts before the consent state resolves, checks a default value, and fires its tracker in the gap. Here is how to find that gap and close it.

What the race looks like

Modern apps split code aggressively. The component that loads the analytics SDK or the chat widget arrives in a separate chunk, mounts when it becomes visible, and initializes on mount. Consent state, meanwhile, resolves asynchronously: it reads from a cookie, merges with a server record, or waits for the consent management platform to respond. Those two timelines are independent, and when the component wins the race, it reads consent state before it exists.

The nasty part is that the bug is timing dependent. On your machine with a fast disk and warm cache, consent resolves in 40 milliseconds and the component mounts after. On a real phone on 4G, the component chunk arrives in 300 milliseconds while consent still waits on a network round trip, and the tracker fires before anyone chose anything. This is why the bug shows up in scans and production but never in your dev tools.

Step one: reproduce it reliably

Do not try to reproduce by scrolling slowly and hoping. Force the race. In your test harness, delay consent resolution artificially: wrap your consent provider so it resolves after a configurable timeout, and set the timeout to 2 seconds. Then load every page that lazy-loads third-party components. Any tracker that fires during those 2 seconds is a race you have now made deterministic.

Run the scan with the network throttled to Slow 4G and CPU throttling at 4x. That combination reproduces the real-world ordering that your laptop hides. Capture the HAR file so you can see exactly which requests fired and at what timestamp relative to consent resolution. The timestamps are the evidence: a tracker request logged before the consent-resolved marker is the bug, documented.

Step two: find the ordering assumption

Once you can reproduce it, read the component code that fires the tracker. You are looking for the consent check on mount. It almost always takes one of three broken forms:

  1. Reading a default. The component reads consent.analytics where consent starts as an empty object, gets undefined, and treats it as allowed. The fix: treat unresolved consent as denied. Nothing should interpret the absence of a decision as a yes.
  2. Subscribing after the event. The component mounts, subscribes to the consent store, and fires its tracker in the same effect before the current value arrives. The fix: make the consent API return the current state synchronously when it is available, and have components gate tracker initialization on a promise that resolves only after consent resolves.
  3. Third-party defaults. The SDK itself fires on load with no consent check at all, and the component assumed the CMP would block it. The fix: initialize the SDK in a disabled state and enable it programmatically after consent resolves, or load the SDK chunk only after the consent gate passes.

Step three: make the ordering impossible to break again

Fixing the one component is not enough, because the next lazy-loaded component will reinvent the same bug. The durable fix is a loader-level gate: a single function that every third-party integration must pass through before it can initialize, and that function does not resolve until consent state is final.

async function whenConsentReady() {
  if (consentState.resolved) return consentState;
  await consentReadyPromise; // resolves exactly once
  return consentState;
}

// every integration goes through this
const state = await whenConsentReady();
if (state.analytics) initAnalytics();

Note the key property: an unresolved consent state is a denied consent state. The gate holds everything closed until the decision exists, so there is no ordering the component author can get wrong. Even a component that calls the gate wrong (forgetting to await, for example) gets a promise it cannot misuse into an early yes.

Add the delayed-consent test from step one to your CI suite. Run the consent scan against a build where consent resolution is artificially delayed to 2 seconds, and fail the build if any non-essential network request fires before the consent-resolved marker. That test turns the race condition from a production surprise into a compile-time fact.

Get a free consent audit of your website

Free consent audit