Why server-side, and why Workers
Client-side GA4 has been quietly rotting for years. iOS Safari ITP caps _ga cookies at 7 days, ad blockers strip outbound requests to google-analytics.com and analytics.google.com, and Meta's iOS Pixel loses 20–30% of mobile conversions on most stores we audit. The fix Google itself recommends is server-side tagging — your client sends events to a domain you own, your server enriches and forwards them to GA4, Meta CAPI, TikTok Events API, and anyone else.
The standard implementation is a Server-Side GTM container running on Google Cloud or Stape. That works. It also costs $20–$120 per month for an extra hop you didn't need.
Cloudflare Workers does the same job for fractions of a cent. At our typical client volume — somewhere between 100k and 5M monthly events — the bill rounds to zero. The Worker also runs at the edge, so latency is lower than App Engine.
Below is the exact setup we deploy. It's not a tutorial — it's the production pattern, with the gotchas we hit so you don't have to.
What you'll have at the end
- A Cloudflare Worker on a subdomain you own (e.g.
t.yourdomain.com) - GA4 events received via the Measurement Protocol with proper
client_idcontinuity - Meta CAPI events sent server-side with deterministic
event_idfor deduplication against the browser Pixel - iOS purchase events recovered from Safari that would otherwise go missing
- A request log queryable in Cloudflare Logs / Logpush
- A simple deny-list for known bot user-agents, no extra cost
The architecture
Browser ── fetch ──▶ t.yourdomain.com/collect (Worker)
│
├── GA4 Measurement Protocol (region: same as GA4 property)
├── Meta CAPI /events
├── (optional) TikTok Events API /track
└── Cloudflare Logs → R2 / Workpush
The Worker is the single ingestion point. Every event comes in once, gets normalized, then fans out. This is the part that trips up most teams: you do not want the browser sending separate requests to GA4, Meta, and TikTok directly. You want one request to your domain, one normalized payload, one place to add the next destination.
Step 1 — The Worker code
Create a Worker project (we use wrangler, but anything works):
npm create cloudflare@latest tagging-worker -- --type=hello-world --ts
cd tagging-worker
src/index.ts:
interface Env {
GA4_MEASUREMENT_ID: string; // G-XXXXXXXXXX
GA4_API_SECRET: string; // from GA4 Admin → Data Streams → Measurement Protocol API secrets
META_PIXEL_ID: string;
META_ACCESS_TOKEN: string; // generated in Events Manager → Settings → System Users
META_TEST_EVENT_CODE?: string; // for staging only
}
export default {
async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(req.url);
// Only accept POST /collect from your domain
if (req.method !== "POST" || url.pathname !== "/collect") {
return new Response("Not found", { status: 404 });
}
const origin = req.headers.get("Origin") ?? "";
if (!origin.endsWith(".yourdomain.com") && origin !== "https://yourdomain.com") {
return new Response("Forbidden", { status: 403 });
}
const payload = await req.json<TrackingEvent>().catch(() => null);
if (!payload) return new Response("Bad payload", { status: 400 });
// CRITICAL: capture the request's IP and UA for Meta — you can't fabricate these later
const ip = req.headers.get("CF-Connecting-IP") ?? "";
const userAgent = req.headers.get("User-Agent") ?? "";
// Fan out without blocking the response
ctx.waitUntil(
Promise.all([
sendToGA4(payload, env),
sendToMetaCAPI(payload, env, ip, userAgent),
]),
);
return new Response(null, {
status: 204,
headers: {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Credentials": "true",
},
});
},
};
interface TrackingEvent {
client_id: string; // GA4 client_id, set on first visit
event_id: string; // UUID for dedup with browser Pixel
event_name: string; // 'purchase', 'sign_up', etc
page_location: string;
page_title?: string;
user_data?: {
email_sha256?: string; // hashed in the browser, never raw
phone_sha256?: string;
};
params?: Record<string, unknown>;
}
Key things this gets right:
ctx.waitUntil — fan-out happens after the 204 response is sent. The browser doesn't wait on Meta's API to finish, which on a bad day can take 800ms. Latency stays edge-local.
Origin check — stops random scripts from hammering your endpoint. You'd be surprised how often a competitor's audit script will hit your /collect URL just because it's reachable.
CF-Connecting-IP and User-Agent — Meta CAPI requires these for matching. They have to come from the request, not the payload. If you let the browser send its own client_ip_address field, Meta's quality score tanks.
Step 2 — GA4 destination
async function sendToGA4(event: TrackingEvent, env: Env): Promise<void> {
const url = `https://www.google-analytics.com/mp/collect?measurement_id=${env.GA4_MEASUREMENT_ID}&api_secret=${env.GA4_API_SECRET}`;
const body = {
client_id: event.client_id,
events: [
{
name: event.event_name,
params: {
...event.params,
page_location: event.page_location,
page_title: event.page_title,
// Required for GA4 to attribute to the same session as the browser
session_id: event.params?.session_id,
engagement_time_msec: 1,
},
},
],
};
const resp = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!resp.ok) {
console.error("GA4 send failed", resp.status, await resp.text());
}
}
The two things teams miss here:
session_idcontinuity. If the browser already started a session and you send a Measurement Protocol event without the samesession_id, GA4 treats it as a new session. Your sessions count doubles. Pullsession_idfrom the GA4 cookie (_ga_<container>) on the client and forward it inparams.engagement_time_msec: 1. Without it, GA4 marks the event as "non-engaged" and it won't show up in standard reports. One millisecond is enough to count as engaged.
Step 3 — Meta CAPI destination, with deduplication
async function sendToMetaCAPI(
event: TrackingEvent,
env: Env,
ip: string,
userAgent: string,
): Promise<void> {
// Map GA4 event names to Meta's standard event names
const metaEventName = mapToMetaEvent(event.event_name);
if (!metaEventName) return;
const url = `https://graph.facebook.com/v19.0/${env.META_PIXEL_ID}/events?access_token=${env.META_ACCESS_TOKEN}`;
const body = {
data: [
{
event_name: metaEventName,
event_time: Math.floor(Date.now() / 1000),
event_id: event.event_id, // ← THIS IS THE DEDUP KEY
event_source_url: event.page_location,
action_source: "website",
user_data: {
em: event.user_data?.email_sha256 ? [event.user_data.email_sha256] : undefined,
ph: event.user_data?.phone_sha256 ? [event.user_data.phone_sha256] : undefined,
client_ip_address: ip,
client_user_agent: userAgent,
fbc: event.params?.fbc, // _fbc cookie value
fbp: event.params?.fbp, // _fbp cookie value
},
custom_data: {
currency: event.params?.currency,
value: event.params?.value,
},
},
],
test_event_code: env.META_TEST_EVENT_CODE,
};
const resp = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!resp.ok) {
console.error("Meta CAPI failed", resp.status, await resp.text());
}
}
function mapToMetaEvent(name: string): string | null {
const map: Record<string, string> = {
purchase: "Purchase",
sign_up: "CompleteRegistration",
add_to_cart: "AddToCart",
begin_checkout: "InitiateCheckout",
view_item: "ViewContent",
generate_lead: "Lead",
};
return map[name] ?? null;
}
The deduplication is the entire point. Both your browser Pixel and your server send the same purchase event to Meta. If they share an event_id, Meta keeps one and discards the duplicate. If they don't, you'll see double the conversions in Events Manager and Meta's optimization will start chasing inflated targets.
The event_id has to be deterministic and generated once in the browser, then passed through both paths. We generate it in the page tag right before firing, and store it on the order in case we need to replay.
Step 4 — Browser side, in GTM
In GTM, your purchase tag fires two places:
Browser (Meta Pixel):
const eventId = crypto.randomUUID();
fbq('track', 'Purchase', {
value: {{order_total}},
currency: 'USD',
}, { eventID: eventId });
// Also send to your Worker
fetch('https://t.yourdomain.com/collect', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: getGa4ClientId(),
event_id: eventId,
event_name: 'purchase',
page_location: location.href,
page_title: document.title,
user_data: {
email_sha256: await sha256(customerEmail),
},
params: {
value: {{order_total}},
currency: 'USD',
session_id: getGa4SessionId(),
fbc: getCookie('_fbc'),
fbp: getCookie('_fbp'),
},
}),
});
The crypto.randomUUID() — the same value goes to both Pixel and Worker. That's the dedup key.
getGa4ClientId() parses the _ga cookie. getGa4SessionId() parses the _ga_XXXXXXX cookie. Helpers below; copy them verbatim:
function getGa4ClientId() {
const m = document.cookie.match(/_ga=GA1\.\d+\.(\d+\.\d+)/);
return m ? m[1] : crypto.randomUUID();
}
function getGa4SessionId() {
const m = document.cookie.match(/_ga_[A-Z0-9]+=GS\d+\.\d+\.(\d+)/);
return m ? m[1] : null;
}
async function sha256(value) {
if (!value) return undefined;
const data = new TextEncoder().encode(value.trim().toLowerCase());
const hash = await crypto.subtle.digest('SHA-256', data);
return Array.from(new Uint8Array(hash))
.map((b) => b.toString(16).padStart(2, '0')).join('');
}
Step 5 — Subdomain, DNS, and the cookie that matters
Your Worker has to be on your domain or first-party cookies don't survive iOS Safari.
In Cloudflare:
wrangler deploy
# then route it
# Cloudflare Dashboard → Workers Routes → Add: t.yourdomain.com/* → tagging-worker
DNS: a CNAME or AAAA pointing t.yourdomain.com to your zone, proxied (orange cloud).
Then in your browser code, send credentials: 'include' on the fetch. iOS Safari treats t.yourdomain.com as same-site to yourdomain.com, which means the _ga cookie persists past the 7-day ITP cap. Without the subdomain trick, you lose attribution on every iOS user after a week.
What we see after this ships
On a typical Shopify or B2B SaaS site:
- Meta CAPI event match quality jumps to 8.0+ (from 5–6 with browser-only Pixel)
- Purchase events recovered: 18–32% on iOS Safari, 8–14% on Chrome with ad blockers
- GA4 reports stop showing the "(direct)/(none)" inflation that ITP causes — because the
_gacookie now lives on a first-party subdomain - Cloudflare bill: $0.00 most months. We've seen it tick to $0.40 once on a 12M-event month
What to watch for
Don't proxy ads platform endpoints. Some guides tell you to proxy connect.facebook.net through your Worker. Meta detects this and drops your event match quality. Send to graph.facebook.com/v19.0/... from the Worker — that's the supported path.
Don't forget the event_id on the browser side. Sending CAPI without dedup means you double-count. Meta's reporting will still look great. Your CAC will look terrible. We've inherited accounts where this was the entire reason ROAS reports diverged from Shopify revenue.
Hash PII in the browser, not the Worker. If you let raw email hit your Worker, you've now got a server processing PII that you didn't have before. Do sha256 in the browser. The Worker only sees the hash.
Test mode is your friend. Set META_TEST_EVENT_CODE in your staging Worker env. Meta Events Manager → Test Events shows them in real time. Without this, you'll spend a day wondering why nothing arrives.
When to graduate to Stape or sGTM
If your team writes server-side GTM tags fluently, you'll outgrow the Worker pattern around the third destination (TikTok + Pinterest + Snapchat + LinkedIn + Microsoft Ads, all with different event schemas). At that point, sGTM's tag templates pay for themselves.
For 90% of agency clients — even ones spending six figures monthly on paid — the Worker pattern is enough. Cheaper, faster, and you control every line of code.
If you want a Worker like this set up against your stack — including the GTM and CMS-side wiring — that's roughly two weeks of work and the ongoing cost is functionally zero. Get in touch and we'll send you a written audit of where your current tracking is leaking before we touch anything.