┌── POST 09.16 · Google Consent Mode · 6 min read

gtag(‘consent’, ‘default’) Silently Ignored? Check the Shape of Your dataLayer Push

gtag.js only runs a consent command if it arrives as a real arguments object. Rest parameters, arrow functions and literal arrays are dropped without an error, so the default never applies and _ga is written before the banner is answered. Tested, with the fix.

Illustration: a funnel receiving square and round shapes, only the round ones passing through into a teal box.
TL;DR

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.

C
About the author
Consent Mode HQ
Editorial team at Consent Mode HQ
Read more by author ↗