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.
Why the browser side fails on its own
Section titled “Why the browser side fails on its own”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.comandgoogle-analytics.comby 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_awcookie, 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.
Why the server side fails on its own
Section titled “Why the server side fails on its own”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
gclidand 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_datafor 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
clientIdfrom the browser (via the_gacookie). 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.
What dedup does to the math
Section titled “What dedup does to the math”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.
What “the same call shape” means
Section titled “What “the same call shape” means”The browser-side and server-side trackConversion calls are intentionally similar:
// Browserawait tracker.trackConversion({ label: 'purchase', value: order.total, currency: 'USD', transactionId: order.id, // ← the dedup key userData: { email: order.customer.email, /* ... */ },});
// Serverawait 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.
What you have to do
Section titled “What you have to do”- Pick a stable string per conversion. Almost always your order ID. Pass it as
transactionIdon both sides. - Forward
gclid/gbraid/wbraidfrom the browser to the server with the order data — seegetClickIdentifiers(). - Pass the same raw
userDataon both sides. - Trust the SDK to do the dedup-friendly thing.
If you do those four, dual-send works.
See also
Section titled “See also”- Deduplication & transactionId — the dedup rules in detail.
- Click identifiers (gclid / gbraid / wbraid) — how click IDs are captured and forwarded.
- The dual-send invariant — the testable contract the SDK guarantees.