gtag.js only treats a dataLayer entry as a command if it is a real arguments object. Push an array instead — (...args) => dataLayer.push(args), dataLayer.push(['consent', 'default', {...}]), or [...arguments] — and the consent call is skipped without an error. In our test the tag then behaved as if no consent mode existed: _ga cookies were written on page load and the hit carried no gcs parameter. The same mistake on the update call means clicking “Accept” changes nothing. The fix is one line, and the usual cause is a lint rule.
Google’s snippet defines gtag as function gtag(){dataLayer.push(arguments);}. That looks like an old-fashioned way to write a variadic function, and in a modern codebase it tends to get “cleaned up”. This post shows what each rewrite does to Consent Mode, measured rather than assumed, and how to keep the function in a shape that works.
What we tested
A local page loaded the real gtag.js from googletagmanager.com in headless Chromium (Playwright 1.62.1), with a fresh browser context for every case. Every request to Google’s collection endpoints was intercepted and answered locally, so we could read the hit parameters without anything leaving the machine. Each page ran a consent snippet first, then gtag.js, then a correct gtag('js') and gtag('config') block. Only the shape of the consent call changed. Tested on 16 September 2026.
| How the default was pushed | Default applied? | Cookies on load | gcs on page_view |
|---|---|---|---|
function gtag(){dataLayer.push(arguments);} |
Yes | None | G100 |
function gtag(...args){dataLayer.push(arguments);} |
Yes | None | G100 |
const gtag = (...args) => dataLayer.push(args); |
No | _ga, _ga_<id> |
absent |
function gtag(){dataLayer.push([...arguments]);} |
No | _ga, _ga_<id> |
absent |
dataLayer.push(['consent', 'default', {...}]) |
No | _ga, _ga_<id> |
absent |
dataLayer.push({event: '...', ad_storage: 'denied', ...}) |
No | _ga, _ga_<id> |
absent |
In the working cases the gcd parameter read 13p3p3p3p5l1. In every failing case it read 13l3l3l3l1l1, the same value you get on a page with no consent code at all. There is no console warning in any of them. If you want to read those parameters on your own site, checking Consent Mode from the network tab walks through it.
Why arrays are ignored
The gtag.js served on the test date contains this test for whether a value is an arguments object (minified; names and layout will differ in other builds). It is consistent with every result in the table:
Object.prototype.toString.call(a) === "[object Arguments]"
|| Object.prototype.hasOwnProperty.call(a, "callee")
An array fails both conditions. A plain object fails both tests too, so a {event: ..., ad_storage: 'denied'} push is not a consent command either, whatever keys it carries. Rest parameters (...args) always produce a real array, which is why every “modern” rewrite fails the same way.
This check is observed behaviour in minified code, not a documented contract. Google’s consent mode setup guide (last updated 30 July 2026) simply uses the arguments form and warns that “if your consent code is called out of order, consent defaults won’t work”. It does not describe the array case at all, which is part of why this failure is hard to search for.
The two ways it shows up
Broken default: tracking before consent
The common layout is two separate gtag definitions. Google’s snippet, pasted into the layout, uses arguments and sends config correctly. The consent default lives somewhere else — a banner component, a consent.ts helper — with its own arrow-function gtag. The page_view still goes out, analytics still “works”, and nobody notices that the default never registered. That is the dangerous version, because the site looks correctly wired in every dashboard while writing _ga before the banner is answered. If every call goes through the array version, nothing is sent at all, which at least gets noticed.
Broken update: “Accept” does nothing
We also tested the reverse: a correct default, then an array-shaped gtag('consent', 'update', {... 'granted'}) after load. The update never registered, no cookies were written, and the tag stayed in its denied state. With the update pushed via arguments instead, _ga was set and subsequent hits carried gcs=G111. The symptom here is a consent rate that looks fine in your CMP while GA4 keeps receiving only denied-state hits. Worth checking before blaming default and update ordering, which produces similar reports.
Where the rewrite comes from
Usually a linter. ESLint’s prefer-rest-params reports any use of arguments and suggests rest parameters. The rule is off in eslint:recommended and has no autofix, so a person makes the change by hand. But it is switched on as an error in typescript-eslint’s eslint-recommended config, which its recommended preset includes for TypeScript files, and in eslint-config-airbnb-base. A TypeScript project on a default setup will flag Google’s snippet the moment it is moved into a .ts file.
Arrow functions are the other route: an arrow function has no arguments object of its own, so whoever converts the function to arrow syntax has to replace it with something, and rest parameters are the natural choice.
The fix
Keep a regular function, and push arguments. You can still declare typed rest parameters for the call signature; the runtime ignores them, as the second row of the table shows. Next.js does exactly this in @next/third-parties: sendGAEvent is declared as (..._args: Object[]) and pushes arguments.
// consent.ts
declare global {
interface Window { dataLayer: unknown[] }
}
// gtag.js only accepts a real Arguments object. Rest params or an arrow
// function produce an array, which is dropped silently.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function gtag(..._args: unknown[]): void {
window.dataLayer = window.dataLayer || [];
// eslint-disable-next-line prefer-rest-params
window.dataLayer.push(arguments);
}
gtag('consent', 'default', {
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
analytics_storage: 'denied',
});
Two further points. First, if the page also loads Google’s snippet, prefer calling that one — window.gtag — over redefining it, so only one definition exists to get wrong. Second, the default still has to reach the data layer before gtag.js processes the queue; a correct shape in a component that mounts after hydration does not help. The CookieConsent setup guide shows the inline placement for a self-hosted banner.
Checking an existing site in thirty seconds
Open the console on a fresh load, before touching the banner, and inspect what is in the queue:
dataLayer.map(e => [
Object.prototype.toString.call(e),
e[0] ?? e.event
]);
Every consent, js and config entry should report [object Arguments]; the gtm.dom and gtm.load objects gtag.js adds itself are normal. An [object Array] next to 'consent' is this bug. Then confirm from the outcome: no _ga cookie before a choice, and a gcs parameter on the first hit.
Scope of what was tested
These results are for gtag.js loaded directly. We did not test a Google Tag Manager web container (gtm.js), so do not read the table as a statement about GTM Custom HTML tags. Google also ships gtag.js continuously; the check quoted above is what was served on 16 September 2026, and the harness is simple enough to rerun if you need a current answer.
A default that never registers looks identical to a working one until you read the hits. Run the Consent Mode checker to see whether your page sends consent signals before the banner is answered.