Third-party scripts that were granted consent at page load continue executing after a user withdraws that consent — because deleting a cookie does not terminate a running timer, close an open WebSocket, or cancel an in-flight fetch. A full-page reload solves the symptom but fractures SPA routing state, resets hydration caches, and guarantees a jarring user experience. The correct fix is a deterministic teardown protocol that stops execution in-place, within milliseconds of the revocation signal.

Triage: Confirm That Scripts Are Still Running After Revocation

Before writing any code, verify the problem is real and measure its scope.

  1. Open Chrome DevTools → Network tab. Filter by Fetch/XHR and WS.
  2. In a second DevTools tab open Performance → click Record.
  3. Trigger the consent-revocation flow through your CMP UI (not by clearing cookies manually).
  4. Stop the recording. Note the timestamp of the revocation event in the timeline.
  5. Run this in the Console to surface any requests that fired after revocation:
// Set window.consentRevokedAt to performance.now() inside your CMP callback
// before running this check
const revokedAt = window.consentRevokedAt ?? 0;
const leaking = performance.getEntriesByType('resource')
  .filter(r => r.startTime > revokedAt);

console.table(leaking.map(r => ({
  url: r.name,
  startMs: r.startTime.toFixed(0),
  type: r.initiatorType
})));
  1. Switch to the Memory tab → take a heap snapshot → search Detached DOM tree. Any detached nodes that reference vendor script state confirm execution continued after revocation.

Reproduction checklist:

Root Cause: Async Initialization Races the Synchronous Revocation Signal

The failure is structural. When syncing consent states across multiple vendors most CMPs emit a single callback — a TCF useractioncomplete event, a custom onConsentChange handler, or a window.__tcfapi response — but vendor SDKs initialize through entirely separate async pipelines. A script loaded with async or via window.dataLayer.push() may still be executing its initialization chunk when revocation fires. That chunk registers setInterval timers, opens WebSocket connections, and queues fetch calls before any teardown logic can intercept them.

The core breakdown has three layers:

  • No registration contract. Vendor SDKs do not declare which handles they create, so there is nothing to unwind.
  • No de-registration hook. The CMP’s revocation API (gtag('consent', 'update', ...)) updates a flag but does not call any vendor destroy() method.
  • No network-layer enforcement. Patching a flag in memory does not block the fetch API, which the SDK already holds a reference to.

This is the same async-lifecycle hazard that affects delaying third-party scripts until user consent — the difference is that here the SDK has already started.

The diagram below shows the state machine from grant to revocation:

Consent revocation state machine Five states connected left-to-right: Consent Granted, SDK Initialising, SDK Active (timers/sockets/fetch), Revocation Signal received, Teardown Manager fires, then Halted. An arrow from SDK Active loops back to itself labelled "ongoing requests". The Teardown Manager box shows three outputs: clearInterval, ws.close(), ctrl.abort(). Consent Granted SDK Initialising SDK Active timers · sockets fetch calls ongoing requests Teardown Manager Halted (zero leaks) clearInterval() ws.close() ctrl.abort() revoke()

Resolution Path: ConsentTeardownManager + window.fetch Interception

The fix has two parts that must both be in place: a registration layer that tracks every handle each vendor creates, and a network-layer patch that blocks outbound calls whose consent has lapsed.

Part 1 — Registration and teardown

Wrap SDK initialization so every handle gets registered before the SDK runs. The ConsentTeardownManager holds a Map of vendor IDs to their active handles, and revoke() unwinds them atomically.

class ConsentTeardownManager {
  constructor() {
    // vendorId -> { intervals, timeouts, sockets, abortControllers }
    this.handles = new Map();
  }

  // Call this immediately after each SDK registers a handle
  register(vendorId, handles) {
    const existing = this.handles.get(vendorId) ?? {
      intervals: [], timeouts: [], sockets: [], abortControllers: []
    };
    this.handles.set(vendorId, {
      intervals: existing.intervals.concat(handles.intervals ?? []),
      timeouts: existing.timeouts.concat(handles.timeouts ?? []),
      sockets: existing.sockets.concat(handles.sockets ?? []),
      abortControllers: existing.abortControllers.concat(handles.abortControllers ?? [])
    });
  }

  revoke(vendorId) {
    const h = this.handles.get(vendorId);
    if (!h) return;

    h.intervals.forEach(id => clearInterval(id));
    h.timeouts.forEach(id => clearTimeout(id));
    h.sockets.forEach(ws => {
      // Only close if not already closing/closed
      if (ws.readyState < WebSocket.CLOSING) ws.close(1000, 'consent_revoked');
    });
    h.abortControllers.forEach(ctrl => ctrl.abort());

    this.handles.delete(vendorId);
    this._purgeDOM(vendorId);
  }

  _purgeDOM(vendorId) {
    // Remove any elements the vendor injected, identified by data-vendor attribute
    document.querySelectorAll(`[data-vendor="${vendorId}"]`)
      .forEach(el => el.remove());
  }
}

// Single global instance — initialize before any vendor script loads
window.__ctm = new ConsentTeardownManager();

Part 2 — Network-layer enforcement

Removing DOM nodes does not cancel fetch calls already in flight or block new ones that a still-running timer queues. Patch window.fetch before vendor hydration to enforce consent state on every outbound call:

(function patchFetch() {
  const _fetch = window.fetch; // store original before any vendor touches it

  window.fetch = async function (input, init) {
    const url = typeof input === 'string' ? input : input.url;

    // window.__consentState is your CMP's live consent map: vendorDomain -> boolean
    if (window.__consentState && !window.__consentState.isPermitted(url)) {
      // Beacon the violation so compliance audits have a complete log
      navigator.sendBeacon('/compliance/audit', JSON.stringify({
        blocked: url,
        ts: Date.now(),
        reason: 'consent_revoked'
      }));
      // DOMException with AbortError is the closest semantic match
      return Promise.reject(
        new DOMException(`Fetch blocked post-revocation: ${url}`, 'AbortError')
      );
    }

    return _fetch.apply(this, arguments);
  };
})();

Part 3 — Cross-tab propagation via BroadcastChannel

A user who revokes consent in one tab expects the same effect in every open tab. Syncing consent state across tabs is handled with BroadcastChannel, which delivers within a single event-loop tick to all same-origin contexts:

const _ch = new BroadcastChannel('consent_revoke_v1');

// Call this from your CMP's revocation callback
function revokeConsent(vendorId) {
  window.__ctm.revoke(vendorId);                          // local teardown
  _ch.postMessage({ type: 'REVOKE', vendorId, ts: Date.now() }); // other tabs
  navigator.sendBeacon('/compliance/audit', JSON.stringify({
    event: 'consent_revoked', vendorId, ts: Date.now()
  }));
}

// Other tabs receive and mirror the teardown
_ch.onmessage = ({ data }) => {
  if (data.type === 'REVOKE') window.__ctm.revoke(data.vendorId);
};

Verification: Zero Post-Revocation Requests

Run this in the Console immediately after triggering revocation. A passing teardown returns an empty array; any entries indicate leaked execution:

// window.__consentRevokedAt must be set inside revokeConsent() above:
//   window.__consentRevokedAt = performance.now();
const leaked = performance.getEntriesByType('resource')
  .filter(r => r.startTime > (window.__consentRevokedAt ?? Infinity));

if (leaked.length === 0) {
  console.log('PASS — no post-revocation network requests');
} else {
  console.error('FAIL —', leaked.length, 'requests after revocation:');
  console.table(leaked.map(r => ({ url: r.name, ms: r.startTime.toFixed(0) })));
}

Complement this with a Playwright assertion in CI:

// playwright test — confirm no vendor requests fire after CMP revocation click
test('no requests after consent revocation', async ({ page }) => {
  const postRevocationRequests = [];
  let revokedAt = 0;

  page.on('request', req => {
    if (revokedAt && req.url().includes('analytics-vendor.com')) {
      postRevocationRequests.push(req.url());
    }
  });

  await page.goto('https://staging.example.com/');
  await page.click('[data-testid="revoke-consent"]');
  revokedAt = Date.now();
  await page.waitForTimeout(2000); // allow any in-flight requests to complete

  expect(postRevocationRequests).toHaveLength(0);
});

Common Pitfalls

  • Assuming gtag('consent', 'update', { analytics_storage: 'denied' }) is sufficient. This updates Google’s consent mode flag but does not call clearInterval, close sockets, or abort pending fetch calls queued by the GA4 SDK. You still need the teardown manager.
  • Patching fetch without preserving the original reference. If you assign window.fetch = ... without capturing const _fetch = window.fetch first, you create an infinite call loop. Always capture the original in the same IIFE scope before overwriting.
  • Ignoring navigator.sendBeacon queues. Beacon calls queue during visibilitychange and fire on the next visibility event or page unload. If your teardown fires during a visibility-hidden period, beacons from that window may still flush. Treat them as accepted-but-logged rather than blocked — sendBeacon cannot be aborted after queuing.

Up: Syncing Consent States Across Multiple Vendors