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 withtransport_type: 'beacon', which routes it throughnavigator.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.
sendBeaconis 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 setlocation). That works, but couples your navigation to your analytics and adds latency that the reader feels.beacondecouples 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:
"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 nowindow, and this stops it throwing.&& window.gtaghandles 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 — sowindow.gtagmay 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
- MDN —
Navigator.sendBeacon()(what it is and the page-unload guarantee that makes this work): https://developer.mozilla.org/en-US/docs/Web/API/Navigator/sendBeacon - Google — GA4
gtag('event')reference (event syntax and parameters): https://developers.google.com/tag-platform/gtagjs/reference - Google — Measure outbound link clicks / recommended events (current GA4 guidance for click measurement): https://support.google.com/analytics/answer/9216061
Keep reading