index

When Browser Tracking Stops Being Reliable: Building Server-side Tracking with sGTM

After a Shopify store went Headless, browser tracking got cut off layer by layer. We built a server-side pipeline with Cloudflare Workers and sGTM that covers five platforms—and ran into every undocumented wall along the way.

After the Shopify Headless migration, browser-side tracking broke at scale. We put together a server-side tracking setup with sGTM (Server-side Google Tag Manager) to patch it, covering GA4, Google Ads, Meta, Reddit, and X. This is the full postmortem—not just what the final solution looks like, but more importantly the walls we hit along the way. It kept feeling like the next step should be simple, and then we would run into another undocumented wall. If you’re also evaluating sGTM, or already wrestling with server-side tracking, some of these detours might save you time.

Start with the full architecture. You only need to watch three layers: the entry point (Worker), the routing hub (sGTM), and the fallback layer (Webhook + Firestore). We’ll unpack them one by one below:

If you’re short on time, jump straight to:


Why Browser Tracking Stopped Being Enough

If you do not work on media buying day to day, you can reduce the problem to one sentence: after a user clicks in from an ad, their on-site actions—browsing, adding to cart, checking out, completing payment—need to be sent back to ad and analytics platforms. Those platforms need the signals for attribution, optimization, and dynamic remarketing. The most obvious example: if someone clicks a Google Ads ad and buys something, but that conversion never gets sent back, Ads has no idea the ad worked. All downstream optimization goes blind.

There are two reporting paths. Client-side tracking runs JavaScript in the user’s browser (basically each platform’s Pixel), and the browser sends events directly to the ad platform. Server-side tracking has your own server call each platform’s Conversions API (CAPI) instead. The client-side path has one big advantage: it sees the full browser context—Cookies, Click IDs, User-Agent—but it is easy for ad blockers to kill, and it also gets weakened by browser privacy policies such as Safari ITP and Chrome’s third-party Cookie restrictions. The server-side path is harder to block, but it is missing those attribution signals by default, so you have to bridge them back manually. Mature setups usually run both paths at the same time so they can cover for each other.

Once that premise is clear, the three stages we went through make sense. The first three attempts all stayed on the client side, and every one of them ran into the same wall.

Stage 1: relying on Shopify’s built-in tracking Apps. Back when the Shopify Storefront and the Gatsby Headless storefront were running side by side, tracking depended entirely on the official Apps provided by each ad platform inside Shopify. Gatsby had no instrumentation at all—traffic came in, but only the conversions that passed through the Storefront side could be reported.

Stage 2: putting client-side Pixels into Gatsby. Once it became obvious Gatsby was a tracking blind spot, we started instrumenting it: based on the event IDs configured in the Shopify App backend, the Gatsby frontend injected the corresponding reporting code. The problem was that the whole setup was loose. Each platform had its own implementation, and ecommerce event payloads like view_item and add_to_cart were not standardized. Maintenance cost shot up fast.

Stage 3: Web GTM + Google Tag Gateway. We wanted GTM to manage all tracking code in one place, while enabling Google Tag Gateway (a server-side proxy) to get around ad blockers. But Gateway only proxies Google’s own requests—Meta, Reddit, and X Pixels still go to third-party domains and still get blocked. In practice that only solved two-fifths of the problem.

Those three attempts separately improved coverage, unification, and block avoidance, but they never fixed the root issue: browser signals are inherently unstable. Ad blockers kill requests outright (with uBlock Origin’s default rules, pure client-side data loss is typically 15-30%), Safari ITP compresses Cookie lifetime, and Shopify Checkout locks Custom Pixels into a sandboxed iframe where attribution Cookies are no longer readable. That is the ceiling of browser tracking—and that is where the move to server-side starts.


Architecture Overview and the Key Decision

Once we decided to move server-side, the first question was: how exactly?

We were running ads on four platforms while using GA4 for analytics, and each platform’s CAPI format and deduplication logic is different. Shopify’s official ad Apps already come with their own server-side tracking (conversions reported through Webhooks). We had used them in the first stage, so we already knew the server-side path worked—but every App ran its own little kingdom, and the data format plus ecommerce payloads never lined up. If we built a separate server-side setup for every platform:

So what we needed to patch was not a single platform’s Pixel. We needed one unified server-side pipeline: first keep the request alive on the way in, then normalize ecommerce data and attribution signals in the middle, then fan the event out downstream in each platform’s required format.

Why sGTM as the Routing Hub

We were already using Web GTM, so sGTM felt like the most natural extension: client-side events can be sent directly into a Server Container, without rebuilding the entire event model from scratch. GTM’s UI also makes it easier for non-technical teammates to maintain Tag configuration, instead of asking engineering every time tracking needs to change.

At the data-flow level, Pixel is the primary path—it has the full browser context, so attribution quality is best there. Shopify’s orders/paid Webhook has no browser signals by nature, so it only exists as a fallback: if Pixel does not send successfully, Webhook fills the gap. How those two paths avoid double reporting is covered in the Firestore Deduplication section.

Each layer does its own job—if Worker fails, it does not stop sGTM from handling Webhooks; if Firestore has issues, it does not block the main Pixel path—so failures do not cascade across the whole pipeline. We will break down each layer below, starting with Worker: the request has to reach sGTM alive first, otherwise nothing downstream matters.


Worker: Outsmarting Ad Blockers

Worker is basically a reverse proxy that sits between the browser and Cloud Run (sGTM). It has one job: make tracking requests look like something other than tracking requests.

Ad blockers work by pattern matching. EasyList, which uBlock Origin loads by default, mostly blocks ad-related paths such as /pagead/ and googlesyndication.com. EasyPrivacy focuses on tracking, so it blocks things like /g/collect, /gtag/js, google-analytics.com, and parameter combinations such as cx=c&gtm. When we were investigating this, both rule sets had to stay open side by side: which paths get matched, which query parameter combinations trigger blocking, and then how to design aliases one by one to get around them. For example, EasyPrivacy has a rule ||googletagmanager.com/gtag/js, which directly blocks the gtag loader request. Another rule matches &cx=c&gtm=, which shows up in GA4 collection requests. If you do not handle both, GA4 collection and Google Ads conversion measurement both break.

Worker does four concrete things:

Path rewriting. It replaces Google’s fixed paths like /g/collect and /gtag/js with meaningless abbreviations. In production there are 20+ path aliases, covering GA4 collection, Google Ads conversion measurement, Consent Mode, gtag destination, and more. Miss even one, and the corresponding feature gets blocked.

Parameter rewriting. EasyPrivacy also matches query parameters like cx=c&gtm. Worker rewrites them into aliases before forwarding, and sGTM restores them on its side.

Runtime JS replacement. GTM’s JavaScript hardcodes www.googletagmanager.com and a bunch of Google paths. Before returning that JS, Worker replaces the domain, path names, and parameter names with our own aliases.

// Path + parameter rewriting (condensed)
const PATH_MAP = {
  '/main.js': '/gtm.js?id=GTM-XXXXXXX',
  '/d/c': '/g/collect',
  '/a/s': '/gtag/js',
  '/x/pa': '/pagead/viewthroughconversion'
}

function rewriteJS(body) {
  return body
    .replace(/\/g\/collect/g, '/d/c')
    .replace(/\/gtag\/js/g, '/a/s')
    .replace(/cx=c&gtm/g, '_cx=c&_g')
    .replace(/www\.googletagmanager\.com/g, 'tracking.example.com')
}

Header passthrough. Worker also passes browser context through to sGTM. Without it, the server sees incomplete data:

  • CF-Connecting-IP -> X-Forwarded-For (the real user IP)
  • Sec-CH-UA-* Client Hints (device and browser info)
  • bidirectional Cookie passthrough (_ga, _fbp, and other first-party Cookies)
  • X-Country-Code (the user’s country, used by the Enricher to match Merchant Center feed language)

One detail matters here: not every path can be proxied into sGTM. Some Google Ads side paths—conversion measurement, CCM (Consent Mode-related Cookie management), remarketing pixels, and so on—are not inbound endpoints of the sGTM container. If you blindly route them into sGTM, they will just 400. Worker has to recognize those paths and send them back to Google’s original upstream hosts (googleadservices.com, googlesyndication.com, and so on). Worker also performs CORS origin validation; requests from non-whitelisted domains get a straight 403.

In live testing with uBlock Origin’s default rule set (EasyList + EasyPrivacy), every rewritten tracking request passed normally. Looking at GA4, Safari and Chrome funnel conversion rates are basically in line with each other (Safari 0.72% vs Chrome 0.56%), which suggests Safari ITP’s attribution damage was also successfully bridged through the server-side path.


sGTM: One Event In, Five Platforms Out

sGTM runs on GCP Cloud Run. Once the GA4 Client receives an event, it first passes through the Items JSON Enricher for normalization, and then each platform Tag consumes the same event payload independently—GA4 Tag sends analytics events back, Google Ads Tag reports conversion plus cart details, and Meta / Reddit / X Tags each call their own CAPI. Each Tag fires on its own and does not depend on the others.

But for the event to fan out correctly, the ecommerce data itself has to line up first.

Standardizing Ecommerce Event Data

How messy was it? The item_id emitted by Shopify’s official tracking Apps was an internal ID string, and it did not match the Merchant Center feed offer ID at all—so Google Ads dynamic remarketing could not pull the correct product image and price. GA4 had a different problem: the same product would show up under multiple localized names, so Ecommerce reports split one product into multiple rows and the analysis data became fragmented.

Meta CAPI also needs content_id to match Catalog data if you want DPA to work. So ecommerce event data had to be standardized on two layers:

First layer: the front-end Pixel. Inside the Custom Pixel, there is a PRODUCT_NAMES dictionary that maps Shopify SKU to a canonical English product name. That keeps item_name consistent at the source, instead of mixing localized names.

Second layer: the Items JSON Enricher in sGTM. It parses the items_json string back into an items array, validates that item_id = Shopify SKU = Merchant Center offer ID, and fills aw_feed_country plus aw_feed_language for Google Ads dynamic remarketing.

That way, no matter where the event goes next, every platform consumes the same cleaned-up ecommerce payload.

Integrating Platform CAPIs

PlatformEvent scopeDeduplication
GA4Funnel events (Purchase stays browser-primary)event_id
Google AdsPurchase + cart line itemstransaction_id
MetaPurchase + funnel events48h event_id
RedditFull funnel + download_clickevent_id
XFull funnel + download_clickevent_id

The clearest proof is in Google Ads. The observed conversion rate of the sGTM server-side Purchase Tag stays stable at 95-100%, while Purchase conversions imported through GA4 in the same period were almost entirely being filled in by Google’s modeling (observed rate 0-8%). In plain terms: the server-side path was sending conversion data directly into Google Ads, instead of making the platform guess.

Some walls we hit:

  • Reddit CAPI requires eventType to be capitalized as "Purchase". Lowercase purchase gets silently ignored—no error, no logs.
  • X CAPI is now signed directly inside the sGTM template with OAuth 1.0a HMAC-SHA256 instead of going through an external proxy. If credentials or signing parameters are off, the request just fails.
  • For Google Ads purchase conversions, if you want to report cart line items, you must enable enableProductReporting on the Tag and attach Items JSON Enricher as the setupTag.

The Sandbox Limits We Hit While Writing Tags

Items JSON Enricher needs to parse JSON, walk arrays, and do type conversion. That sounds basic, but in our sGTM template setup, nearly every step ran into something:

  • The standard JS global surface is incomplete. If parseFloat is not available, you have to switch to require('makeNumber'). We also hit a real String.prototype.charCodeAt() compatibility issue in production, and ended up rewriting that logic with trim() or charAt() + indexOf().
  • addEventData is now part of the main path. The current live Items JSON Enricher uses addEventData inside the Tag template to write back items, ecommerce_items, aw_feed_country, and aw_feed_language. So the real pitfall here is not “you cannot use it,” but that setupTag execution order and field sources have to line up.

Those little constraints piled up into a two-day detour for an Enricher that should have taken half a day. The sandbox section later gets even more ridiculous.


Browser Signal Bridging: Cookies and Click IDs

sGTM solves the “who should receive the event” problem, but platforms still need browser-side Cookies and Click IDs for attribution. The annoying part is that the purchase funnel is not one continuous chain. It gets cut into two segments at the Gatsby -> Shopify Checkout handoff: the first half lives on our own domain, the second half lives on Shopify’s checkout domain.

The GTM JS running on Gatsby handles the first half: pageview, view_item, add_to_cart, and other on-site actions go straight from the browser to Worker and then into sGTM. Once the user reaches Shopify Checkout, that chain breaks, and the second half switches over to Custom Pixel. Cookie Bridge and Click ID Bridge are both there to reconnect those two halves.

The Custom Pixel takes over five key checkout milestones (from checkout_started to checkout_completed). Every event carries the full ecommerce payload plus user_data (email, phone, address), which is the foundation for Google Enhanced Conversions and Meta Advanced Matching.

sendBeacon + keepalive: true is now used mainly for the Cookie Bridge /store-cookies POST, not for the main Purchase event itself. The primary Purchase path is still dataLayer -> GTM -> Worker -> sGTM. sendBeacon is there to push _fbp, _fbc, twclid, rdt_cid, and similar context to the server as reliably as possible before the page unloads.

Shopify Custom Pixel runs inside a sandbox, isolated from the main page, so it cannot directly read attribution Cookies from each platform—Meta’s _fbp / _fbc, X’s twclid, Reddit’s rdt_cid, all of them are inaccessible. Without those, CAPI events cannot be tied back to the browser-side click, and attribution breaks.

Fortunately, Shopify exposes an async browser.cookie.get() API. Pixel uses it to pull those Cookies one by one and sends them out through two channels:

Channel A: the Cookie values get packed into a meta_cookies field and travel with the Purchase event through GTM -> Worker -> sGTM. This is the normal path.

Channel B: a separate copy gets written into Firestore, so if Pixel fails to send, Webhook can recover the Cookies from there.

Click ID Bridge: From URL to Cart

Cookie Bridge solves Cookie handoff from Pixel to Webhook. Click IDs have another layer of trouble: when a user lands from an ad, URL parameters like gclid, rdt_cid, and twclid have to be captured on the main site first, then carried across the checkout boundary into sGTM.

The current approach is: CF Worker serves rewritten GTM JS, Web GTM reads the URL parameters in the browser, stores them in first-party Cookies, and forwards them to sGTM as GA4 event params. The whole chain does not depend on any Shopify-side API.

We tried another route early on: writing Click IDs into note_attributes through Shopify’s /cart/update.js, so Webhook could carry them into the sGTM Webhook Client. But onekey.so is a Headless frontend and the /cart/update.js environment was not stable there. That route only survives now as a fallback inside the Webhook Client.

The bridging logic is not identical across platforms either: twclid has three fallback levels (URL / Cookie / localStorage), rdt_cid leans on dataLayer / first-party Cookie / URL fallback, and Google Ads relies more heavily on _gcl_* Cookies plus server-side recovery logic.


Firestore: Deduplication

Why are there two paths in the first place? Shopify’s orders/paid Webhook is emitted from the server, so it naturally has no browser context—no _fbp / _fbc (needed by Meta attribution), no gclid (needed by Google Ads attribution), and not even the real user User-Agent or IP. Cookie Bridge can recover part of that, but it is still second-hand data. The Pixel path is where attribution quality is highest. So architecturally, Pixel is the primary path and Webhook only exists as the fallback—if Pixel does not make it, Webhook fills the gap.

But Shopify sends the Webhook regardless of whether Pixel succeeded. Custom Pixel reports almost immediately, while the Webhook arrives roughly 40 seconds later. If both paths run and nothing deduplicates them, every order gets reported twice. That time gap is what shaped the dedup design: Pixel has enough time to write a marker into Firestore first, and when Webhook arrives later it can read that marker to decide whether it still needs to report.

Each platform already has its own event_id-based deduplication (Meta uses a 48-hour window, others have similar logic), but I did not want to rely on that. The real problem with duplicate reporting is not quota—it is that it pollutes the platform’s attribution calculation and event quality scoring.

So dedup happens upstream in sGTM through Firestore: once the Pixel path reports successfully, it writes { reported: true } into Firestore. When Webhook arrives, it checks that record first. If it exists, skip. If it does not, send through the currently enabled Fallback Tags.

Looking at GA4, the Pixel primary path has accounted for more than 99% of purchase events since launch. Pixel success rate is high enough that the Webhook fallback rarely has to fire in practice.

In the current live configuration, this dedup layer mainly protects Google Ads, Meta, Reddit, and X Purchase fallback flows. GA4 Native Purchase - Shopify Webhook is currently paused, so GA4 purchase events still rely on the browser primary path rather than Webhook backfill.

The Full Journey of a Single Order

Above, Worker, sGTM, signal bridging, and Firestore were explained layer by layer. Here is the full chain stitched back together. Once payment completes, two paths trigger independently: Pixel enters sGTM immediately through Worker, gets standardized by the Enricher, fans out to five platforms, and writes a dedup marker into Firestore at the same time; around 40 seconds later, the Shopify Webhook reaches sGTM and checks Firestore—if the marker exists, it skips; if not, it goes through the Google Ads, Meta, Reddit, and X fallback Tags (GA4 Purchase always stays on the Pixel primary path and does not use Webhook fallback).

What Gets Stored in Firestore

Two Collections:

sgtm_cookies/{cart_token} — in the current live Cookie Store Client, retention is 30 days

FieldTypeDescription
fbpstringMeta _fbp
fbcstringMeta _fbc
twclidstringX click ID
rdt_cidstringReddit click ID
expires_atnumberthe current template writes a millisecond timestamp

sgtm_purchases/{transaction_id} — in the current template, retention is also 90 days

FieldTypeDescription
reportedbooleantrue = already reported
sourcestringpixel | webhook
timestamp_msnumberwrite timestamp
expires_atnumberthe current template writes a millisecond timestamp

There is also sgtm_purchase_context/{transaction_id}, which is used to recover browser-side session context on the Webhook fallback path.


sGTM Sandbox: The Two Limits That Hurt Dev Experience the Most

The sGTM sandbox is a crippled JavaScript runtime: it looks like JS, but standard APIs are missing, permissions are enforced silently at runtime, and failures give you no feedback. We already hit some of this in the Enricher section above (parseFloat missing, charCodeAt() unavailable). Here are the two worse ones. What makes them bad is not just missing features—it is that you cannot even tell what went wrong.

try/catch turns debugging into a black box. Instinct says try/catch is there to make things safer. In the sGTM sandbox, if code inside the try block triggers a sandbox-level abort (for example, by calling an API without the declared permission), catch does not catch it. The whole Tag just stops executing—no error, no catch, no logs. Adding try/catch actually makes the problem harder to locate, because you lose even the basic clue of “which line did execution stop on?” What we do now is the dumbest possible thing: do not use try/catch at all, insert logToConsole line by line, publish a version, inspect logs, narrow the range.

Missing permissions do not raise errors. The Tag just aborts silently. This one came from a real production incident after Template #33 went live: we added getCookieValues calls for _twclid, _rdt_cid, and rdt_cid, but forgot to declare the corresponding Cookie names under get_cookies in the template permissions. The result was not an error, not even a console warning—the entire Tag silently aborted at runtime, and every live event flowing through that Tag was dropped until someone noticed the data had flatlined and started digging.

The lesson is blunt: code changes and permission changes have to ship together. Otherwise the worst-case outcome is not “it errors,” but “it quietly does nothing.”

Those two issues together define what debugging sGTM feels like. The problem is not that features are limited—you can work around limited features. The problem is that you never know which line execution stopped on, and you never know why it stopped.


If I Had to Do It Again

The data is stable now, but if I had to choose the stack again, I probably would not use sGTM.

At the time, it looked like the rational choice. The team was already on Web GTM, so the event model did not have to be rebuilt. The sGTM ecosystem also has a lot of ready-made server-side Tag templates—official ones for GA4 and Google Ads, community ones for Meta, Reddit, and X—so it looked like we could just snap the pieces together and run. In practice, whether a template was official or community-built, almost all of them had some issue: wrong parameter types, incomplete permission declarations, edge cases not handled. Every template had to be opened up, read at the source-code level, and patched before it was usable. The “works out of the box” expectation collapsed completely.

The debugging experience was more like whack-a-mole: fix one permission issue and another silent failure appears; patch one missing API and then hit a type incompatibility. Every iteration meant publishing a version, checking logs, and guessing which line stopped executing. It burned time and attention. If I had to do it again, my selection criteria would change to this: debug feedback > ecosystem compatibility > UI convenience.

More concretely: one Cloudflare Worker, one D1 database, and direct CAPI integrations written against each platform’s docs. One event payload comes in, we write our own mapping logic, and fan it out downstream ourselves—that is fundamentally the same job sGTM is doing, but deployment takes seconds and console.log is enough to debug it. Compared with the sGTM + GCP stack, the developer experience is not even in the same league.

The reason I did not seriously consider that route back then was simple: it felt like “writing the CAPIs ourselves” would be too much work. Looking back, the development time supposedly saved by sGTM templates mostly got paid back in debugging and sandbox workarounds. Net-net, it may not have saved anything.

Who Is sGTM Actually For?

If the team is already deeply invested in Web GTM, with a lot of existing Tags and trigger configuration, then sGTM as the natural extension makes sense.

But if you do not have GTM baggage, or if you need to integrate several non-Google platforms like we did while layering in custom dedup logic, writing the pipeline directly in code is much faster than wrestling the sandbox permission system.

Platforms like Google Tag Manager were originally built on a simple premise: “lower the barrier with a GUI so non-technical people can configure server-side tracking too.” But once the problem becomes highly customized and heavily dependent on debug feedback—integrating multiple platform CAPIs, repairing browser context, building custom deduplication—the abstraction layers and sandbox restrictions that were introduced to avoid writing code start becoming the obstacle instead. Especially now that Coding Agents can read docs directly, generate integration code, run tests, and fix bugs, it is worth asking how much of the original GUI advantage is really left.

Comments

Loading comments...