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.
- Open Chrome DevTools → Network tab. Filter by
Fetch/XHRandWS. - In a second DevTools tab open Performance → click Record.
- Trigger the consent-revocation flow through your CMP UI (not by clearing cookies manually).
- Stop the recording. Note the timestamp of the revocation event in the timeline.
- 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
})));
- 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 vendordestroy()method. - No network-layer enforcement. Patching a flag in memory does not block the
fetchAPI, 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:
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 callclearInterval, close sockets, or abort pending fetch calls queued by the GA4 SDK. You still need the teardown manager. - Patching
fetchwithout preserving the original reference. If you assignwindow.fetch = ...without capturingconst _fetch = window.fetchfirst, you create an infinite call loop. Always capture the original in the same IIFE scope before overwriting. - Ignoring
navigator.sendBeaconqueues. Beacon calls queue duringvisibilitychangeand 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 —sendBeaconcannot be aborted after queuing.
Related
- Syncing Consent States Across Multiple Vendors — the BroadcastChannel architecture that powers cross-tab propagation
- Designing Graceful Fallback Chains for Blocked Scripts — what to serve in place of a script whose consent has lapsed
- Architecting GDPR-Compliant Consent Gating — the upstream consent-state machine that triggers revocation events
- Debugging CMP Integration Failures with Analytics Tags — diagnosing CMP callbacks that never fire