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);
});

Revocation is three actions, not one

Revoking is not the inverse of granting. Granting starts a vendor; revoking has to stop it, remove what it stored, and prevent it restarting — and only the first of those is something the vendor’s own API reliably offers.

Stop, clear, prevent Three actions in sequence. Stop collection, by calling the vendor's opt-out or teardown API and aborting in-flight requests. Clear stored identifiers, by removing the vendor's cookies and storage entries, which the vendor's own API usually will not do. Prevent re-initialisation, by updating the persisted consent record so the next navigation does not re-inject the vendor. A note records that omitting the third action produces a revocation that lasts exactly one page view. 1 · stop collection vendor teardown or opt-out call controller.abort() the only step vendors help with 2 · clear what it stored cookies on your domain, localStorage, IndexedDB entries almost never done by the vendor 3 · prevent restart persist the new state, broadcast it, and drop the vendor from the queue omit it and revocation lasts one page view Step 2 is where most implementations stop, and it is the step a regulator can verify from the browser. Enumerate each vendor's storage keys in the same registry that records its teardown call. Third-party cookies set on the vendor's own domain are outside your reach — say so in your policy.

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.
Which vendors can honestly be torn down in place Vendors sorted by what their API supports. Those exposing an explicit opt-out or teardown call can be stopped in place with no reload. Those supporting re-initialisation with cleared flags can be stopped in place but leave listeners attached, so their cost persists for the session. Those exposing nothing can only be stopped by reloading the document, and a reload-free revocation for them is a claim you cannot keep. explicit teardown stopped in place, cleanly listeners removed no reload needed re-init with flags cleared collection stops global listeners remain attached cost persists for the session no teardown at all nothing you call changes it a reload is the only stop do not promise reload-free Audit each vendor into one of these three before designing the revocation flow, not after. For the right-hand column, a reload prompt is the honest interface — a silent no-op is not.

Frequently Asked Questions

How do we prove a revocation actually took effect?

Verify it from outside your own code, because your code’s belief that it revoked is the thing under test. Open the Network panel, filter to the vendor’s origins, revoke, and then exercise the page — no further requests to those origins should appear. Then open the Application panel and confirm the vendor’s storage entries under your own domain are gone.

Add both as an automated check if the vendor matters. A headless run that grants consent, exercises a page, revokes, exercises again and asserts zero post-revocation requests is a few dozen lines, and it is the only form of evidence that survives a vendor changing their SDK without telling you. Manual verification catches the regression once; the automated version catches it every release.

Should revocation reload the page automatically?

Only for vendors in the third group, and preferably with the visitor’s knowledge. An automatic reload discards form state, scroll position and anything in progress, which turns a privacy control into a destructive action — and the visitor has no way to know that pressing “reject” will lose their half-completed checkout.

The workable pattern is to tear down everything you can in place, then tell the visitor plainly that finishing the change requires a refresh, with a button that does it. That is honest about what your code can and cannot do, and it puts the timing under the control of the person whose data it is.


Up: Syncing Consent States Across Multiple Vendors