← QAScript Placement

Where Should a Tracking Snippet Actually Go on the Page - Head, End of Body, Async or Defer?

Answer in brief

For nearly every modern tracking snippet (GA4, GTM, most pixels), the correct placement is high in <head> but loaded asynchronously (async, or a native async-loading snippet) - never as a plain blocking <script> tag, and never deliberately delayed to the end of <body> if the tool depends on capturing early page-load events. The old "end of body" rule existed to stop render-blocking scripts from delaying visible content; async loading solves that same problem without losing early events.

Why this happens

Tracking snippets historically shipped as synchronous <script src="..."> tags, which block the browser from parsing the rest of the page until the script downloads and executes. Placing that blocking script at the end of <body> was the standard workaround: the visible page renders first, then the tracker loads.

That workaround has a cost most sites now pay for without realizing it: a script at the very end of the page can miss events that happen fast - a bounce, a quick scroll, a click before the script has loaded - and it delays interactivity if the script does any DOM-touching. Modern tag snippets ship with async or defer attributes specifically so they no longer need to be exiled to the bottom of the page.

GA4's own gtag.js and GTM's container snippet both already ship as async-loading by default from Google's setup instructions. The mistake usually isn't the snippet's own attributes - it's a developer copy-pasting an outdated synchronous version into a template, or wrapping the snippet in code that forces it to wait for something unrelated (a slow dependency, a cookie-consent library) before it can even start downloading.

Fix it

  1. 1

    Use the vendor's current async snippet, not an old copy-pasted version

    GA4's gtag.js and GTM's container snippet both ship as async by default from Google's own install instructions. If your snippet doesn't include async or defer, or wraps document.write, it's an outdated copy - replace it with the current version from the platform's own setup screen rather than patching the old one.

  2. 2

    Keep it high in <head>, not at the end of <body>

    Because it's async, the script downloads without blocking rendering, so there's no longer a performance reason to push it to the bottom of the page. Placing it early in <head> means it starts downloading immediately and is ready to capture events - including very early ones, like a fast bounce - as soon as possible.

  3. 3

    For frameworks (React, Next.js, Vue, Nuxt), load it through the framework’s script-loading primitive, not a raw <script> tag

    In Next.js, use the next/script component with strategy="afterInteractive" (or the framework's third-party-script helper) instead of injecting a <script> tag inside a React component - a plain component-mounted script re-injects on every client-side navigation and can duplicate the tag. In Vue/Nuxt, load it once in the root app shell or via a dedicated plugin, not per-page.

  4. 4

    If consent-gating delays the script, gate the network request, not the placement

    When a consent management platform must approve tracking before it loads, the correct pattern is to hold the actual script execution (via a consent-mode mechanism or a conditional loader) while keeping the snippet's position in <head> unchanged - moving the tag lower on the page doesn't achieve compliance and only reintroduces the missed-early-event problem.

House Differentiator

How to verify it worked

View page source (not just DevTools’ rendered DOM) and confirm the script tag carries async or defer and sits in <head>.

Passing: The raw HTML response includes the async attribute - meaning the browser downloads it without blocking the parser, regardless of where it sits on the page.

Still broken: If the tag is missing async/defer, or was injected only via client-side JavaScript after the fact, it either blocks rendering or loads later than intended - check Lighthouse's "Render-blocking resources" audit to confirm.

Run a quick-bounce test: load the page and navigate away within one second, then check DebugView/Realtime for the corresponding pageview or session-start event.

Passing: The event still registers, confirming the snippet was ready to capture it despite the fast exit.

Still broken: If fast-exit sessions are consistently missing from reports, the snippet is likely loading too late - check for unnecessary delays before the script tag executes.

Related

If this is happening on a client-critical property, we'll verify it directly.

A data audit checks this exact failure mode against your actual implementation, not a generic checklist.