What happened?
Tracker.setConsentGiven() drains consentRequestsQueue with a for loop
whose bound is re-evaluated on every iteration, while the callee appends to that
same array. If the consent-removed cookie cannot be deleted, the loop never
terminates and the browser tab crashes.
The loop
// js/piwik.js — this.setConsentGiven
deleteCookie(CONSENT_REMOVED_COOKIE_NAME, configCookiePath, configCookieDomain);
var i, requestType;
for (i = 0; i < consentRequestsQueue.length; i++) {
requestType = typeof consentRequestsQueue[i][0];
if (requestType === 'string') {
sendRequest(consentRequestsQueue[i][0], configTrackerPause, consentRequestsQueue[i][1]);
} else if (requestType === 'object') {
sendBulkRequest(consentRequestsQueue[i][0], configTrackerPause);
}
}
consentRequestsQueue = [];
sendRequest() pushes back onto the array being iterated:
// js/piwik.js — sendRequest
function sendRequest(request, delay, callback) {
refreshConsentStatus();
if (!configHasConsent) {
consentRequestsQueue.push([request, callback]); // <-- grows the loop bound
return;
}
...
}
// js/piwik.js — refreshConsentStatus
function refreshConsentStatus() {
if (getCookie(CONSENT_REMOVED_COOKIE_NAME)) {
configHasConsent = false;
} else if (getCookie(CONSENT_COOKIE_NAME)) {
configHasConsent = true;
}
}
So when the deleteCookie() on the first line fails to remove the cookie,
each iteration re-reads mtm_consent_removed, still finds it, sets
configHasConsent = false, and re-queues the request. i advances by one and
consentRequestsQueue.length grows by one, so the condition never becomes
false.
The loop is synchronous and never yields. It ends only when the renderer
exhausts its heap and the tab is killed.
Why the delete can fail
deleteCookie uses only the currently configured scope:
function deleteCookie(cookieName, path, domain) {
setCookie(cookieName, '', -129600000, path, domain);
}
Cookie identity is (name, domain, path). Deletion requires the domain and
path attributes to match those the cookie was written with. If
mtm_consent_removed exists under a different scope than the tracker's
current configCookieDomain / configCookiePath, the delete writes a second,
already-expired cookie beside the original and the original survives — so
getCookie(CONSENT_REMOVED_COOKIE_NAME) keeps returning a value indefinitely.
Ways this arises in practice:
- A changed
setCookieDomain between deploys, leaving orphaned cookies from
the previous configuration that the new configuration cannot delete. These
persist until expiry — and forgetConsentGiven() sets a 30-year expiry by
default, so effectively forever.
- Any
configCookiePath change, including the implicit default derived from the
current page path.
- Two tracker instances with different cookie domains on the same page — less
common in general, but this was our case, and it fails within a single page
load. Tracker A calls setCookieDomain('*.example.com'); tracker B does not
call it at all. forgetConsentGiven() on A writes domain=.example.com; B's
setConsentGiven() attempts a host-only delete and fails.
In all cases the misconfiguration is on the site's side. The consequence — a
hard renderer crash rather than degraded tracking — comes from the loop.
Observed consequences
- Tab renders, then becomes unresponsive during load, then crashes.
- Nothing is logged. No exception, no console warning.
- CPU sits at roughly 20%, not 100%, because ~90% of the loop's wall time is
spent blocked in the synchronous document.cookie IPC. This defeats the usual
"look for the pegged core" heuristic.
- The DevTools debugger cannot break in and the Performance panel cannot
finish processing, because the thread never reaches an interruptible point.
We could only capture this via chrome://tracing, which collects out of
process.
- Once a visitor has an undeletable cookie, the site is permanently broken for
them. It cannot self-heal.
Because the failure is silent and unrecoverable, affected visitors are likely to
close the tab rather than report anything — so this may already be occurring
undetected on other installations.
What should happen?
A stale or unexpectedly-scoped mtm_consent_removed cookie should, at worst,
cause tracking requests to be withheld. It should never be able to hang or crash
the page. Specifically:
setConsentGiven() should complete in bounded time regardless of cookie state.
- A failed cookie deletion should be surfaced to the developer, not silent.
- Requests that cannot be sent should be retried on a later consent event, not
cause an unbounded retry within the current call.
1. Make the drain loop bounded (primary)
Detach the queue before iterating, so the callee cannot extend the bound:
var pending = consentRequestsQueue;
consentRequestsQueue = [];
for (i = 0; i < pending.length; i++) {
...
}
Requests re-queued by sendRequest() then land in a fresh array and are retried
on the next consent event instead of extending the current pass. This change
alone converts the crash into a no-op.
2. Trust the explicit call over the cookie
setConsentGiven() is an explicit statement that consent exists. Having
refreshConsentStatus() immediately override configHasConsent back to false
from a cookie the caller just asked to have deleted is surprising. Consider
skipping the refresh for requests drained by setConsentGiven(), or setting a
flag for the duration of the drain.
3. Verify deletion and warn
After deleteCookie(CONSENT_REMOVED_COOKIE_NAME, ...), re-read the cookie; if
it survives, console.warn naming the attempted domain and path. That single
log line would have saved us a great deal of time, and would let site owners
find the misconfiguration themselves.
4. Reduce document.cookie reads
refreshConsentStatus() runs on every sendRequest(), and each read is a
blocking IPC whose cost scales with cookie-jar size. A short-lived cache of the
parsed cookie string, invalidated on write, would cut this substantially even in
non-pathological cases.
5. Consider a broader delete
Optionally attempt deletion across the current host and its registrable-domain
variants, so scope changes do not leave permanently undeletable orphans.
How can this be reproduced?
change setCookieDomain between 2 deployments
Matomo version
Saas
PHP version
No response
Server operating system
No response
What browsers are you seeing the problem on?
No response
Computer operating system
No response
Relevant log output
Environment
- Matomo Cloud tracker,
matomo.js 156 KB (bundling FormAnalytics,
MediaAnalytics, AbTesting; Heatmap/SessionRecording not enabled)
- Two tracker instances via Google Tag Manager
- Axeptio CMP, pushing consent to GTM on every page load via
replayInitialGtmEvents
- Nuxt 3 SSR application
- Reproduced in Brave 152 and Chrome 152 on macOS 26.6.2 (arm64)
- Cookie jar 5,271 bytes at time of crash (unusually large, which made the
per-iteration cost worse and the crash faster)
- Chrome heap limit ~4 GB
What we measured on the real crash
From a chrome://tracing capture with v8.cpu_profiler (Performance panel cannot be used — the thread never reaches an interruptible point):
getCookie — 6,629 ms self time, 40,530 samples, 66.9% of all CPU, in a single profile node
CookieJar::Cookies — 5,947 ms across 13,463 calls (~0.44 ms each), i.e. ~90% of the loop's wall time is blocked in synchronous IPC
One contiguous execution, 3.26 s → 9.92 s, zero gaps above 4.5 ms — a single setConsentGiven() invocation that never yields
Validations
--
Note: bug reported assisted by Claude after deep anaysis
What happened?
Tracker.setConsentGiven()drainsconsentRequestsQueuewith aforloopwhose bound is re-evaluated on every iteration, while the callee appends to that
same array. If the consent-removed cookie cannot be deleted, the loop never
terminates and the browser tab crashes.
The loop
sendRequest()pushes back onto the array being iterated:So when the
deleteCookie()on the first line fails to remove the cookie,each iteration re-reads
mtm_consent_removed, still finds it, setsconfigHasConsent = false, and re-queues the request.iadvances by one andconsentRequestsQueue.lengthgrows by one, so the condition never becomesfalse.
The loop is synchronous and never yields. It ends only when the renderer
exhausts its heap and the tab is killed.
Why the delete can fail
deleteCookieuses only the currently configured scope:Cookie identity is
(name, domain, path). Deletion requires thedomainandpathattributes to match those the cookie was written with. Ifmtm_consent_removedexists under a different scope than the tracker'scurrent
configCookieDomain/configCookiePath, the delete writes a second,already-expired cookie beside the original and the original survives — so
getCookie(CONSENT_REMOVED_COOKIE_NAME)keeps returning a value indefinitely.Ways this arises in practice:
setCookieDomainbetween deploys, leaving orphaned cookies fromthe previous configuration that the new configuration cannot delete. These
persist until expiry — and
forgetConsentGiven()sets a 30-year expiry bydefault, so effectively forever.
configCookiePathchange, including the implicit default derived from thecurrent page path.
common in general, but this was our case, and it fails within a single page
load. Tracker A calls
setCookieDomain('*.example.com'); tracker B does notcall it at all.
forgetConsentGiven()on A writesdomain=.example.com; B'ssetConsentGiven()attempts a host-only delete and fails.In all cases the misconfiguration is on the site's side. The consequence — a
hard renderer crash rather than degraded tracking — comes from the loop.
Observed consequences
spent blocked in the synchronous
document.cookieIPC. This defeats the usual"look for the pegged core" heuristic.
finish processing, because the thread never reaches an interruptible point.
We could only capture this via
chrome://tracing, which collects out ofprocess.
them. It cannot self-heal.
Because the failure is silent and unrecoverable, affected visitors are likely to
close the tab rather than report anything — so this may already be occurring
undetected on other installations.
What should happen?
A stale or unexpectedly-scoped
mtm_consent_removedcookie should, at worst,cause tracking requests to be withheld. It should never be able to hang or crash
the page. Specifically:
setConsentGiven()should complete in bounded time regardless of cookie state.cause an unbounded retry within the current call.
1. Make the drain loop bounded (primary)
Detach the queue before iterating, so the callee cannot extend the bound:
Requests re-queued by
sendRequest()then land in a fresh array and are retriedon the next consent event instead of extending the current pass. This change
alone converts the crash into a no-op.
2. Trust the explicit call over the cookie
setConsentGiven()is an explicit statement that consent exists. HavingrefreshConsentStatus()immediately overrideconfigHasConsentback tofalsefrom a cookie the caller just asked to have deleted is surprising. Consider
skipping the refresh for requests drained by
setConsentGiven(), or setting aflag for the duration of the drain.
3. Verify deletion and warn
After
deleteCookie(CONSENT_REMOVED_COOKIE_NAME, ...), re-read the cookie; ifit survives,
console.warnnaming the attempted domain and path. That singlelog line would have saved us a great deal of time, and would let site owners
find the misconfiguration themselves.
4. Reduce
document.cookiereadsrefreshConsentStatus()runs on everysendRequest(), and each read is ablocking IPC whose cost scales with cookie-jar size. A short-lived cache of the
parsed cookie string, invalidated on write, would cut this substantially even in
non-pathological cases.
5. Consider a broader delete
Optionally attempt deletion across the current host and its registrable-domain
variants, so scope changes do not leave permanently undeletable orphans.
How can this be reproduced?
change setCookieDomain between 2 deployments
Matomo version
Saas
PHP version
No response
Server operating system
No response
What browsers are you seeing the problem on?
No response
Computer operating system
No response
Relevant log output
Environment
matomo.js156 KB (bundling FormAnalytics,MediaAnalytics, AbTesting; Heatmap/SessionRecording not enabled)
replayInitialGtmEventsper-iteration cost worse and the crash faster)
What we measured on the real crash
From a chrome://tracing capture with v8.cpu_profiler (Performance panel cannot be used — the thread never reaches an interruptible point):
getCookie — 6,629 ms self time, 40,530 samples, 66.9% of all CPU, in a single profile node
CookieJar::Cookies — 5,947 ms across 13,463 calls (~0.44 ms each), i.e. ~90% of the loop's wall time is blocked in synchronous IPC
One contiguous execution, 3.26 s → 9.92 s, zero gaps above 4.5 ms — a single setConsentGiven() invocation that never yields
Validations
--
Note: bug reported assisted by Claude after deep anaysis