brokeinprod
brokeinprod
Theme

Debugging

Why your GA4 events vanish on target="_blank" links — the beacon fix

GA4 click events fire but never arrive on outbound or new-tab links. The cause is page unload racing the request; the fix is transport_type 'beacon'.

July 16, 2026

Why Your GA4 Events Vanish on target="_blank" Links — the Beacon Fix

Start here: If a GA4 event fires on a link that navigates away — an outbound link, an affiliate redirect, anything with target="_blank" or a full navigation — and the event never lands in your reports, the cause is almost always that the page unloaded before the request finished. The fix is one parameter: send the event with transport_type: 'beacon', which routes it through navigator.sendBeacon() — a browser API built specifically to fire-and-forget a request even as the page is going away.

The setup

I added a custom affiliate_click event in GA4 to track which product links readers actually click. The implementation looked right: an onClick handler on the link calls gtag('event', 'affiliate_click', { … }), the link does its normal thing, the reader lands on the retailer’s site. Inline links in prose tracked perfectly.

Then I noticed the clicks from my call-to-action buttons — the ones that open the retailer in a new tab — were under-counting badly. The event seemed to fire. It just wasn’t showing up.

The symptom

This is the maddening part: by every local signal, the event worked. The onClick ran. The Network panel showed the /collect request to GA initiated. No console error. But GA4 Realtime and the reports showed far fewer of those events than there were actual clicks — specifically for the new-tab CTAs.

The tell is the type of link. Inline links the reader opened in the same tab (and came back from) tracked fine. Links that triggered a navigation or opened a new tab — the ones where the originating page starts unloading the instant the click resolves — were the ones losing events.

The diagnosis

GA’s default transport for an event is a normal background request (historically XHR, effectively a fetch). That request needs the page to stay alive long enough to complete. On a link that navigates the current page — or in the split second the browser spends handling the click before opening the new tab — the originating document can begin tearing down. When it does, the in-flight request is canceled. The browser doesn’t owe a dying page its pending network calls.

So the event genuinely started (which is why the Network panel shows it) and genuinely never arrived (which is why GA shows nothing). It’s a race between the request completing and the page unloading — and on a link that navigates away, the page usually wins.

The fix: transport_type: 'beacon'

Add one parameter to the event:

gtag('event', 'affiliate_click', {
  product_slug: 'example-product',
  retailer: 'example-retailer',
  link_type: 'cta-button',
  transport_type: 'beacon',
});

transport_type: 'beacon' tells gtag to send the hit with navigator.sendBeacon() instead of the default transport. sendBeacon exists for exactly this situation: it queues a small request that the browser guarantees to send even as the page unloads, and without blocking navigation or keeping the page open. The request is no longer racing the unload — it’s explicitly designed to survive it.

After adding it, the new-tab CTA clicks started landing in GA4 like the inline ones always had. Same event, same parameters — the only change was how the hit leaves the browser.

A few things worth knowing so you apply this with eyes open:

  • It's effectively free to add. sendBeacon is supported across essentially all current browsers; where it isn’t, gtag falls back to the standard transport — the same behavior you have today. There’s no downside to setting it on events that accompany navigation.
  • Modern gtag often uses beacon-style sending already, but it is not guaranteed for every event in every setup — and the moment events are vanishing on navigating links, being explicit is the reliable fix rather than assuming the default covers you.
  • The old pattern was event_callback + delaying the navigation (fire the event, wait for its callback, then set location). That works, but couples your navigation to your analytics and adds latency that the reader feels. beacon decouples them — the link navigates immediately, and the hit goes out independently. Prefer beacon.

Where the beacon event actually lives

The parameter is the fix, but where you put the event matters for the rest of your architecture — and it's worth showing, because the placement solves a second problem at the same time.

My affiliate links resolve through a server-side registry (the product slug maps to a cloaked /go/[slug] URL), and that registry is validated with Zod. None of that should ship to the browser. So the actual click event lives in a deliberately minimal "use client" leaf that receives an already-resolved href and does exactly one browser-only thing — fire the gtag event:

components/mdx/AffiliateLink.tsx
"use client";
 
import Link from "next/link";
 
// Minimal client component: fires the GA click event and renders the link.
// It receives the already-resolved href so the affiliate registry (and Zod)
// stay server-only and never reach the browser bundle.
export function AffiliateLink({
  href,
  slug,
  retailer,
  linkType,
  className,
  children,
}: AffiliateLinkProps) {
  return (
    <Link
      href={href}
      rel="sponsored nofollow"
      target="_blank"
      onClick={() => {
        if (typeof window !== "undefined" && window.gtag) {
          window.gtag("event", "affiliate_click", {
            product_slug: slug,
            retailer,
            link_type: linkType,
            transport_type: "beacon",
          });
        }
      }}
      className={className}
    >
      {children}
    </Link>
  );
}

Two details in that onClick guard are doing real work:

  • typeof window !== "undefined" keeps it safe during server rendering — the component renders on the server first, where there is no window, and this stops it throwing.
  • && window.gtag handles the case where gtag isn't loaded yet. On this site, analytics are consent-gated — GA loads only after the visitor accepts the cookie banner — so window.gtag may legitimately not exist. The guard means a click before consent simply doesn’t fire an event, instead of crashing. No consent, no gtag, no tracking — which is exactly the behavior you want.

This is the same principle as keeping server-only modules out of the client bundle: the client component is kept as thin as possible — one browser-only job, a pre-resolved href handed in — so the registry and Zod stay on the server. The beacon parameter resolves the dropped events; the thin-leaf structure is what keeps the fix from dragging your whole affiliate system into the browser.

FAQ

Why did inline links track fine but my buttons didn't? Inline links the reader opened in the same tab (and returned from) kept the page alive long enough for the default request to complete. Links that navigate away or open a new tab start unloading the page immediately, canceling the in-flight request. Beacon survives the unload.

Doesn't GA4 already use beacon by default? Modern gtag often does, but it's not guaranteed for every event and setup. If events are disappearing on navigating links, set transport_type: 'beacon' explicitly rather than trusting the default — it's the difference between "probably" and "reliably."

Is there any cost to adding transport_type: 'beacon'? Practically none. It's near-universally supported, and unsupported browsers fall back to the standard transport. Add it to any event that fires alongside a navigation.


The takeaway: the Network panel showing a request isn't the same as that request arriving. I was asking a page that was already leaving to finish my homework — beacon is how you hand it off on the way out.

Further reading

Keep reading

Newsletter

New posts in your inbox. No spam — unsubscribe anytime.

Occasional emails when we publish something worth your time. Unsubscribe anytime.

More about what you'll get on the newsletter page.

THEME
↑↓ navigate↵ openesc close