Skip to content

💸 Perf - Defer GTM to lazyOnload without dropping early events - #4910

Open
isaaclombardssw wants to merge 3 commits into
mainfrom
perf/4908-defer-gtm
Open

💸 Perf - Defer GTM to lazyOnload without dropping early events#4910
isaaclombardssw wants to merge 3 commits into
mainfrom
perf/4908-defer-gtm

Conversation

@isaaclombardssw

@isaaclombardssw isaaclombardssw commented Jul 23, 2026

Copy link
Copy Markdown
Member

Closes #4908.

What changed

app/layout.tsx loaded GTM via <GoogleTagManager> from @next/third-parties/google, which only offers an afterInteractive load — so GTM (and the GA4/Ads/FB/Hotjar tags configured in the container) landed in the hydration window.

Replaced it with a small local GoogleTagManager component (app/components/google-tag-manager.tsx) that emits the equivalent GTM snippet as two next/script tags:

  • eager init (afterInteractive): window.dataLayer = window.dataLayer || [] + the gtm.start push, so the dataLayer exists and early events queue.
  • gtm.js itself on strategy="lazyOnload" (loads on idle, after the page is interactive).

The call site is unchanged: <GoogleTagManager gtmId={process.env.NEXT_PUBLIC_GOOGLE_GTM_ID} />. Added a guard so nothing renders when the id is unset (previously it would have requested gtm.js?id=undefined).

Conversions / dataLayer still work

sendGTMEvent from @next/third-parties/google does not depend on <GoogleTagManager> rendering — its source pushes straight to window.dataLayer (defaulting the layer name to dataLayer and creating the array if missing):

const sendGTMEvent = (data, dataLayerName) => {
  const dataLayer = dataLayerName || currDataLayerName; // currDataLayerName defaults to "dataLayer"
  window[dataLayer] = window[dataLayer] || [];
  window[dataLayer].push(data);
};

So the eventbrite_order_complete conversion in components/eventbrite/eventbriteModalButton.tsx still queues into window.dataLayer and is flushed when GTM finishes loading on idle — including the edge case of a CTA clicked immediately after load, since the push is queued, not dropped.

Regression test

ui-tests/gtm-tracking.spec.ts (Playwright, existing ui-tests/ convention) loads /, waits for network idle, then asserts:

  1. gtm.js is still requested (tracking isn't silently disabled by the deferral), and
  2. window.dataLayer contains the gtm.js start event (queuing is intact).

It self-skips the live-beacon assertion when NEXT_PUBLIC_GOOGLE_GTM_ID isn't set on the target server (e.g. local dev), so it's meaningful against a real environment and green everywhere else. Run it against a server that has the id, e.g. HOST_URL=https://www.ssw.com.au pnpm exec playwright test ui-tests/gtm-tracking.spec.ts.

Out of code scope — follow-up for whoever owns the GTM container

The bulk of the ~650 KB win is retiming the individual tag triggers (GA4 / Google Ads / Facebook Pixel / Hotjar) to fire on Window Loaded / interaction / a short timer instead of the initial page view. That's GTM container config, not code, and lives entirely in the container. This PR only changes how/when GTM itself loads.

Manual dashboard verification (needs the real container)

  • GTM Tag Assistant — which tags fired and in what order
  • GA4 Realtime — page views land
  • Meta Events Manager → Test Events — Pixel events land
  • Hotjar dashboard — session recorded
  • Network beacons: GA4 google-analytics.com/g/collect, Ads googleads.g.doubleclick.net/pagead, FB facebook.com/tr?, Hotjar *.hotjar.io

🤖 Generated with Claude Code

https://claude.ai/code/session_01Q8genJyZboXaa4vgYhJF4P

GTM loaded via @next/third-parties' <GoogleTagManager> (afterInteractive),
pulling the container's marketing tags (GA4/Ads/FB/Hotjar) into the hydration
window. Replace it with the equivalent GTM snippet split across two next/script
tags: the dataLayer + gtm.start init stays eager so early sendGTMEvent /
dataLayer.push calls (e.g. eventbrite_order_complete) queue and flush, while
gtm.js itself loads on idle (lazyOnload).

sendGTMEvent keeps working: it pushes to window.dataLayer directly and does not
depend on <GoogleTagManager> rendering. Adds a Playwright regression test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q8genJyZboXaa4vgYhJF4P
@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Coverage report

St.
Category Percentage Covered / Total
🔴 Statements 0.18% 84/46861
🔴 Branches 5.03% 25/497
🔴 Functions 0.84% 4/475
🔴 Lines 0.18% 84/46861

Test suite run success

13 tests passing in 1 suite.

Report generated by 🧪jest coverage report action from b611589

- Drop the flaky waitForLoadState("networkidle") and the #gtm-script
  presence check (a lazyOnload script is only injected after idle, so
  the check was coupled to the very wait it was meant to replace).
- Use the awaited gtm.js request as the skip signal, and poll the
  dataLayer with waitForFunction so the gtm.js-event assertion can't
  race the afterInteractive init.
- Add gtm-tracking to the ui-tests run in both deploy workflows so the
  regression guard (#4908 AC4) actually executes on staging, which has
  NEXT_PUBLIC_GOOGLE_GTM_ID from the build secret; self-skips elsewhere.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q8genJyZboXaa4vgYhJF4P
@isaaclombardssw

Copy link
Copy Markdown
Member Author

/deploy

1 similar comment
@isaaclombardssw

Copy link
Copy Markdown
Member Author

/deploy

@github-actions

Copy link
Copy Markdown
Contributor

Deployed changes to https://app-sswwebsite-9eb3-pr-4910.azurewebsites.net

ℹ️ Staging slots are no longer created automatically - comment /deploy to deploy new commits.

@github-actions

Copy link
Copy Markdown
Contributor

🚀 Lighthouse Report

🌐 URL ⚡ Performance ♿ Accessibility ✅ Best Practices 🔍 SEO 📦 Bundle Size 🗑️ Unused Bundle
https://app-sswwebsite-9eb3-pr-4910.azurewebsites.net/ 44 100 59 69 0.00 MB 0.00 MB
https://app-sswwebsite-9eb3-pr-4910.azurewebsites.net/articles 89 97 59 57 0.00 MB 0.00 MB
https://app-sswwebsite-9eb3-pr-4910.azurewebsites.net/company/about-us 63 100 59 69 0.00 MB 0.00 MB
https://app-sswwebsite-9eb3-pr-4910.azurewebsites.net/company/clients 88 95 59 69 0.00 MB 0.00 MB
https://app-sswwebsite-9eb3-pr-4910.azurewebsites.net/company/contact-us 71 92 59 61 0.00 MB 0.00 MB
https://app-sswwebsite-9eb3-pr-4910.azurewebsites.net/consulting 43 92 56 69 0.00 MB 0.00 MB
https://app-sswwebsite-9eb3-pr-4910.azurewebsites.net/consulting/net-upgrade 69 99 56 61 0.00 MB 0.00 MB
https://app-sswwebsite-9eb3-pr-4910.azurewebsites.net/consulting/web-applications 71 99 59 61 0.00 MB 0.00 MB
https://app-sswwebsite-9eb3-pr-4910.azurewebsites.net/employment 64 95 59 69 0.00 MB 0.00 MB
https://app-sswwebsite-9eb3-pr-4910.azurewebsites.net/events/angular-superpowers-tour 76 97 56 69 0.00 MB 0.00 MB
https://app-sswwebsite-9eb3-pr-4910.azurewebsites.net/events/ai-workshop 95 91 56 61 0.00 MB 0.00 MB
https://app-sswwebsite-9eb3-pr-4910.azurewebsites.net/events/ai-workshop-essentials 40 98 59 69 0.00 MB 0.00 MB
https://app-sswwebsite-9eb3-pr-4910.azurewebsites.net/netug/sydney 74 94 56 61 0.00 MB 0.00 MB
https://app-sswwebsite-9eb3-pr-4910.azurewebsites.net/training/internship-fullstack 78 91 59 61 0.00 MB 0.00 MB

@isaaclombardssw
isaaclombardssw marked this pull request as ready for review July 23, 2026 05:55
Copilot AI review requested due to automatic review settings July 23, 2026 05:55
@isaaclombardssw
isaaclombardssw requested a review from 18-th as a code owner July 23, 2026 05:55
@isaaclombardssw

Copy link
Copy Markdown
Member Author

Needs GTM follow up

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Defers Google Tag Manager loading to lazyOnload (post-load/idle) while preserving early dataLayer queuing, to reduce hydration-window JS cost and protect tracking events.

Changes:

  • Replaces @next/third-parties/google GTM component with a local component that eagerly initializes window.dataLayer and lazily loads gtm.js.
  • Adds a Playwright regression test to verify gtm.js is still requested and that dataLayer contains the GTM start event.
  • Updates CI workflows to include the new gtm-tracking UI test in PR deploy and main build pipelines.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
ui-tests/gtm-tracking.spec.ts Adds Playwright regression coverage for deferred GTM loading and dataLayer initialization.
app/layout.tsx Switches GTM import to the new local component while keeping the call site the same.
app/components/google-tag-manager.tsx Implements split GTM snippet: eager dataLayer init + lazyOnload gtm.js load, with guard for missing ID.
.github/workflows/pr-deploy-command.yml Includes gtm-tracking in PR deployment UI test suite.
.github/workflows/main-build-and-deploy.yml Includes gtm-tracking in staging slot UI test suite.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +23 to +40
const gtmRequest = page
.waitForRequest(
(req) => req.url().includes("googletagmanager.com/gtm.js"),
{ timeout: 15000 }
)
.catch(() => null);

await page.goto("/", { waitUntil: "load" });

// lazyOnload requests gtm.js on idle after load; resolves to the request, or null on timeout.
const request = await gtmRequest;

// Tracking not dropped: reaching here (not skipping) means gtm.js was fetched after the deferral.
// No id on this server (e.g. local dev) => no request => skip the live assertion cleanly.
test.skip(
request === null,
"NEXT_PUBLIC_GOOGLE_GTM_ID not set on this server — skipping the live GTM beacon assertion (verify tags via the manual dashboard checks in the file header)."
);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

💸 Perf - Defer GTM and its marketing tags out of the critical window

2 participants