Skip to content

The dual-send pattern

The browser fires the conversion. The server also fires the conversion. Both calls use the same transactionId. Google sees both, treats them as the same event, counts it once.

That’s the pattern. The rest of this page is why it’s the right pattern.

Client-side gtag('event', 'conversion', ...) is the historical default. It fails — silently — for a measurable percentage of real conversions:

  • Ad blockers. uBlock Origin, AdGuard, Brave Shields, Ghostery, and PiHole all block requests to googleadservices.com and google-analytics.com by default. Estimates of desktop blocker share vary, but the relevant number for ad-spend ROI is the share among the audiences that convert, which is typically higher than the population average.
  • Intelligent Tracking Prevention (Safari/WebKit). ITP caps third-party storage and aggressively short-lives identifiers. The browser-side gtag conversion still fires, but the click attribution it carries gets degraded.
  • Consent denials. Under GDPR/CCPA with Consent Mode v2, a “decline” button means no _gcl_aw cookie, no gtag conversion ping, no Ads-side record.
  • Redirect drops during checkout. If the success page is hit via a server-side redirect (Stripe Checkout, hosted payment pages), the gtag fire often misses the user-interaction window before the next navigation tears down the page.
  • Network failures. Any normal HTTP failure mode.

Aggregate effect: a typical SaaS or ecommerce site loses 20–40% of conversions on the browser side that the server-side path would have captured. The exact number varies by audience and geography, but it’s never zero.

Server-only is the obvious counter-proposal. It also fails:

  • No click identifier in the URL on landing. The server only sees what the client tells it. If the user clicked an ad, landed on the homepage, browsed, then bought a week later from a separate session, the server has no way to know about the click unless the browser captured gclid and stored it in a cookie that’s later forwarded to the server. Trackbridge does that — but it requires the browser to have run.
  • Missing the user-data signal. Browser-side gtag uploads user_data for enhanced conversions over a session-tied path that Google’s match-rate models prefer. The server-side Ads API path works, but Google’s documentation is explicit that the browser path is preferred when both are available.
  • GA4 session stitching. Server-side Measurement Protocol calls need the GA4 clientId from the browser (via the _ga cookie). Without a recent browser visit to mint that cookie, the server-side GA4 event lands without a session and is harder to attribute.

The server side is your floor — what gets through when the browser fails. The browser side is your ceiling — what Google’s models work best with. You want both.

When both sides fire and both reach Google with the same transactionId, Google deduplicates:

If multiple conversions are uploaded for the same conversion action with the same order_id (transaction_id), they are deduplicated. Only the first conversion is counted.

So the conversion appears in your Ads UI once. The reporting is correct.

When both sides fire with different transactionIds, Google has no way to know they’re the same event and counts two conversions. Your reporting double-counts. Your bid optimization runs on inflated signal. This is the failure mode Trackbridge is most paranoid about — see Deduplication & transactionId for the SDK’s behavior on this.

When the browser fires and the server doesn’t (for whatever reason), Google sees one conversion. Correct.

When the server fires and the browser doesn’t (ad blocker, ITP, consent), Google sees one conversion. Correct.

The browser-side and server-side trackConversion calls are intentionally similar:

// Browser
await tracker.trackConversion({
label: 'purchase',
value: order.total,
currency: 'USD',
transactionId: order.id, // ← the dedup key
userData: { email: order.customer.email, /* ... */ },
});
// Server
await serverTracker.trackConversion({
label: 'purchase',
value: order.total,
currency: 'USD',
transactionId: order.id, // ← MUST match
gclid: order.gclid, // ← forwarded from browser
userData: { email: order.customer.email, /* ... */ },
});

The differences are exactly what you’d expect from each side: the server takes gclid / gbraid / wbraid explicitly (because it doesn’t see the URL the user landed with); the browser doesn’t, because it captured them itself at init time.

The userData is identical on both sides — same fields, same raw input. The SDK normalizes and hashes them with byte-identical output on both sides, so Google’s matcher sees the same hashes from both paths.

  1. Pick a stable string per conversion. Almost always your order ID. Pass it as transactionId on both sides.
  2. Forward gclid / gbraid / wbraid from the browser to the server with the order data — see getClickIdentifiers().
  3. Pass the same raw userData on both sides.
  4. Trust the SDK to do the dedup-friendly thing.

If you do those four, dual-send works.