Delaying `Google Tag Manager` or `Google Analytics` for a Faster Page — Without Losing Analytics

A quick dive into how I improved my site’s performance by delaying Google Tag Manager (GTM) loading — without breaking analytics. This simple lazy-loading trick balances tracking needs with better Core Web Vitals.

Delaying `Google Tag Manager` or `Google Analytics` for a Faster Page — Without Losing Analytics

As someone passionate about web performance, I'm always looking for ways to make websites load faster and smoother. I recently embarked on a mission to significantly improve my site's Google PageSpeed (Lighthouse) score, and one of the biggest culprits I identified was Google Tag Manager (GTM).

Initially, my website was scoring a respectable 70 on Mobile Lighthouse, which isn't terrible, but there was definitely room for improvement. Looking at the "Diagnose performance issues" section, GTM was a noticeable contributor to the main thread time and transfer size under "3rd parties".

The Problem

GTM was loading immediately on page start, even before any user interaction. That means the browser had to:

  • Parse and execute GTM scripts before rendering was complete.
  • Delay the First Contentful Paint (FCP) and Largest Contentful Paint (LCP).
  • Consume CPU time for tracking setup, even if the user bounced right away.

Here’s what my initial setup looked like — the default snippet straight from Google Tag Manager:

<script>
(function(w,d,s,l,i){
  w[l]=w[l]||[];
  w[l].push({'gtm.start': new Date().getTime(), event:'gtm.js'});
  var f=d.getElementsByTagName(s)[0],
      j=d.createElement(s), dl=l!='dataLayer'?'&l='+l:'';
  j.async=true;
  j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;
  f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','GTM-XXXXXXXX');
</script>

Google Tag Manager default snippet

That’s fine for analytics-heavy sites — but for performance-focused projects, this was a problem.


The Optimization: Delay GTM Loading

Instead of loading GTM as soon as the page starts, I wanted it to load only when needed — either:

  • when the user interacts (scrolls, clicks, types, etc.), or
  • after a short delay (e.g., 3.5 seconds) as a fallback.

This ensures:

  • Faster initial render
  • Better Core Web Vitals
  • No “third-party script” blocking issues in PageSpeed

Here’s the final version:

<script>
(function () {
  const GTM_ID = "GTM-XXXXXXXX"; // ← Replace with your GTM ID
  const LOAD_DELAY = 3500; // milliseconds
  let gtmInitialized = false;

  /**
   * Initializes the Google Tag Manager script dynamically.
   * Prevents multiple injections by checking `gtmInitialized`.
   */
  function loadGTM() {
    if (gtmInitialized) return;
    gtmInitialized = true;

    const script = document.createElement("script");
    script.type = "text/javascript";
    script.async = true;
    script.src = `https://www.googletagmanager.com/gtm.js?id=${GTM_ID}`;

    script.onload = () => {
      window.dataLayer = window.dataLayer || [];
      window.dataLayer.push({
        event: "gtm.js",
        "gtm.start": new Date().getTime(),
        "gtm.uniqueEventId": 0,
      });
    };

    document.head.appendChild(script);
  }

  function onFirstInteraction(event) {
    loadGTM();
    event.currentTarget.removeEventListener(event.type, onFirstInteraction);
  }

  /**
   * Initializes GTM after user interaction or fallback timeout.
   * This ensures analytics load only when the visitor engages
   * with the page, improving initial load and Lighthouse score.
   */
  function setupDelayedGTM() {
    // Fallback: load GTM after delay
    document.addEventListener("DOMContentLoaded", () => {
      setTimeout(loadGTM, LOAD_DELAY);
    });

    // Load GTM as soon as the user interacts
    const triggers = ["scroll", "pointerdown", "keydown", "touchstart", "mousemove"];
    triggers.forEach((eventName) => {
      document.addEventListener(eventName, onFirstInteraction, { passive: true });
    });
  }

  // Run setup
  setupDelayedGTM();
})();
</script>

Delaying Google Tag Manager snippet

Or use final uglified version:

<script>
(function(w,d,id,delay,l){
  let a=false;
  function g(){
    if(a)return; a=true;
    const s=d.createElement("script");
    s.async=1;
    s.src="https://www.googletagmanager.com/gtm.js?id="+id;
    s.onload=()=>{w[l]=w[l]||[];w[l].push({event:"gtm.js","gtm.start":Date.now(),"gtm.uniqueEventId":0});};
    d.head.appendChild(s);
  }
  function o(e){g();e.currentTarget.removeEventListener(e.type,o);}
  d.addEventListener("DOMContentLoaded",()=>setTimeout(g,delay));
  ["scroll","pointerdown","keydown","touchstart","mousemove"].forEach(ev=>{
    d.addEventListener(ev,o,{passive:!0});
  });
})(window,document,"GTM-XXXXXXXX",3500,"dataLayer");
</script>

Perfect — let’s break it down in detail. Below is a step-by-step deep dive explaining how the delayed Google Tag Manager (GTM) loading works, based on the exact script we used.


How It Works (Step-by-Step Breakdown)

1. Wait until the page’s DOM is ready

document.addEventListener("DOMContentLoaded", () => {
  setTimeout(loadGTM, LOAD_DELAY);
});
  • The DOMContentLoaded event ensures this script doesn’t block rendering.
  • Once the HTML is parsed (not waiting for all images/resources), a timer starts.
  • After 3.5 seconds, it calls loadGTM to load GTM — only if the user hasn’t interacted yet.

We give the browser some breathing room to load critical CSS, JS, and above-the-fold content first. By deferring GTM by a few seconds, you reduce early JavaScript execution pressure — improving metrics like First Contentful Paint (FCP) and Largest Contentful Paint (LCP).

2. Add multiple user interaction listeners

const triggers = ["scroll", "pointerdown", "keydown", "touchstart", "mousemove"];
triggers.forEach((eventName) => {
  document.addEventListener(eventName, onFirstInteraction, { passive: true });
});
  • We attach listeners to several common user actions:
    • scroll → user starts exploring
    • pointerdown → mouse click / touch press
    • keydown → keyboard interaction
    • touchstart → mobile gesture
    • mousemove → desktop pointer movement
  • The moment any of these events occur, we call onFirstInteraction().

This means GTM will load immediately when a user engages, even before the 3.5s timer — because by that time, the user is active, and analytics are relevant.

3. Trigger GTM only once per session

function onFirstInteraction(event) {
  loadGTM();
  event.currentTarget.removeEventListener(event.type, onFirstInteraction);
}
  • loadGTM() ensures GTM is initialized when the event fires.
  • It also removes its own listener for that specific event to avoid duplicate triggers.
  • We also protect GTM from multiple calls (see next section).

Without this cleanup, multiple events (like scroll + keydown) could trigger loadGTM() several times — creating multiple GTM <script> elements and redundant tracking.

4. Initialize GTM safely

let gtmInitialized = false;

function loadGTM() {
  if (gtmInitialized) return;
  gtmInitialized = true;

  ...
}
  • We add a flag gtmInitialized to prevent duplicate initialization.
  • This flag ensures GTM loads only once, whether by timeout or user action.

5. Create and inject GTM script tag

const script = document.createElement("script");
script.type = "text/javascript";
script.async = true;
script.src = `https://www.googletagmanager.com/gtm.js?id=${GTM_ID}`;
    
document.head.appendChild(script);
  • This dynamically creates the GTM <script> tag that normally lives in the <head> of your site.
  • Using async means it won’t block rendering.
  • The browser will fetch the GTM JS file in parallel, without delaying other tasks.

This replicates the default GTM behavior but triggers it lazily — only when appropriate.

6. Handle GTM “load complete”

script.onload = () => {
  window.dataLayer = window.dataLayer || [];
  window.dataLayer.push({
    event: "gtm.js",
    "gtm.start": new Date().getTime(),
    "gtm.uniqueEventId": 0,
  });
};
  • Once the GTM script has finished downloading, we manually push a gtm.js event into the dataLayer.
  • This event simulates the initial GTM startup, signaling that GTM is ready to process subsequent tags.

Normally, GTM’s inline snippet does this automatically. Since we’re injecting the script manually, this line ensures the same startup sequence occurs.

Here’s how it behaves in practice:

Timeline Trigger What Happens
Page loads DOM ready Timer starts (3.5s)
Before 3.5s User interacts GTM loads immediately
After 3.5s No interaction GTM loads automatically
After GTM loads script.onload Sends gtm.js event to dataLayer

The Result

After applying this optimization:

  • My PageSpeed performance score improved from 70 → 94 🎉
  • GTM disappeared from the 3rd-party impact list
  • Initial page load and LCP improved significantly
Metric Before After
Lighthouse Performance 70 94
LCP 5.3s 2.6s
Total Blocking Time 170ms 0ms
GTM Main Thread Impact High None before interaction

This single change cleaned up my third-party script impact, leading to smoother initial loading and a much more responsive feel.

PageSpeed Insights

Takeaways

  • GTM, while useful, is a render-blocking third-party script if loaded too early.
  • Deferring it intelligently keeps analytics accurate without sacrificing performance.
  • You can tweak the delay time or event list depending on your UX needs.
  • Works great for static sites, blogs, and landing pages where instant tracking isn’t critical.

✨ What I Learned

Optimizing isn’t just about removing things — sometimes it’s about loading them smarter.
This small tweak taught me how timing can be just as powerful as content when it comes to web performance.