Why server-side at all
If you still run client-side GA4 only, you're losing roughly 20-35% of your conversion data in Thailand. Three things eat client-side analytics: iOS Safari ITP truncates first-party cookies to 7 days, Brave/Firefox enhanced tracking blocks www.googletagmanager.com outright, and in-app browsers (Facebook, Instagram, LINE — which is dominant in Thailand) strip query parameters and break referrer tracking. Server-side fixes all three. Your conversion data goes from "directional" to "actually usable for attribution" — which is the precondition for the Markov attribution pipeline we run in BigQuery.
The standard Google answer is "deploy server-side GTM on App Engine or Cloud Run." That works. It also costs $120-180/month minimum, has cold-start latency that hits Thai users hard (us-central regions add 150-220ms RTT to Bangkok), and requires a non-trivial GCP IAM setup that most agency clients can't manage themselves.
We've moved every new SSGTM deployment in 2025-2026 to Cloudflare Workers. This piece documents the cost, the latency, and the deploy script — without the marketing fluff.
Cost: real billing data
Three Bangkok client deployments, monthly events and Cloudflare Workers Paid plan billing for January 2026:
| Client | Vertical | Monthly events | Workers cost | R2/KV cost | Total (THB) |
|---|---|---|---|---|---|
| D2C beauty | E-commerce | 4.2M | $5.00 | $0.30 | ~฿185 |
| SaaS | B2B | 1.1M | $5.00 | $0.10 | ~฿180 |
| Marketplace | 2-sided | 38M | $11.20 | $2.40 | ~฿475 |
The "~฿8K/month" figure in the headline is the all-in number we charge clients for the SSGTM module of our retainer — that includes the Workers cost, the BigQuery streaming inserts ($0.05 per GB ingested, typically $20-40/month), our maintenance time, and a buffer for traffic growth. The raw Cloudflare bill on a typical client site is under ฿500/month.
Compare to Cloud Run for the same workload: minimum 1 instance always-on (~$45/month for 1 vCPU/512MB), plus per-invocation costs, plus egress to Google's Measurement Protocol endpoint. For the marketplace client above we'd expect ~$140/month on Cloud Run. Cloudflare Workers is roughly 12x cheaper at this scale.
Latency: where edge actually matters
For Thai users, the Bangkok and Singapore Cloudflare PoPs handle nearly all traffic. P50 latency from our Bangkok client browsers to the Worker is ~9ms; p95 is ~34ms (slower mobile networks). Compare to a us-central1 Cloud Run endpoint at p50 ~198ms and p95 ~340ms. The difference matters less than you think for analytics events fired in navigator.sendBeacon (they're async), but it matters a lot for two scenarios:
- Conversion-event firing on page-unload. Beacon has a few seconds before browser tear-down kills it. A 200ms RTT eats 5-10% of beacons on slow Thai 4G; a 9ms RTT loses essentially none.
- Server-side experiment assignment. If you want the Worker to also assign experiment variant (so the page renders with the variant, not after a flash), the latency is on the critical render path. 9ms is invisible; 200ms is a measurable LCP regression.
This second point is why we co-locate sequential testing assignment with SSGTM in the same Worker. One round trip handles both.
The Worker: a minimal viable SSGTM
This is roughly what we ship. Tweak the CHANNELS mapping per client. The full version has more guardrails, but this is the load-bearing 80%.
// worker.ts
export interface Env {
GA4_MEASUREMENT_ID: string;
GA4_API_SECRET: string;
BIGQUERY_INGEST_URL: string;
BIGQUERY_INGEST_TOKEN: string;
EXPERIMENT_KV: KVNamespace;
}
const ALLOWED_ORIGINS = [
'https://example.com',
'https://www.example.com',
];
export default {
async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise {
const url = new URL(req.url);
const origin = req.headers.get('origin') || '';
if (!ALLOWED_ORIGINS.includes(origin)) {
return new Response('forbidden', { status: 403 });
}
if (url.pathname === '/collect' && req.method === 'POST') {
const body = await req.json();
// 1. Forward to GA4 Measurement Protocol
ctx.waitUntil(forwardGA4(body, env));
// 2. Stream to BigQuery for our own analytics
ctx.waitUntil(streamBQ(body, req, env));
// 3. Return experiment assignment if requested
const exp = body.exp_id
? await assignExperiment(body, env)
: null;
return new Response(JSON.stringify({ ok: true, exp }), {
headers: corsHeaders(origin),
});
}
return new Response('not found', { status: 404 });
},
};
async function forwardGA4(body: any, env: Env) {
const url = `https://www.google-analytics.com/mp/collect?measurement_id=${env.GA4_MEASUREMENT_ID}&api_secret=${env.GA4_API_SECRET}`;
await fetch(url, {
method: 'POST',
body: JSON.stringify({
client_id: body.client_id,
user_id: body.user_id,
events: body.events,
}),
});
}
async function streamBQ(body: any, req: Request, env: Env) {
const enriched = {
...body,
server_ts: Date.now(),
cf_country: req.cf?.country,
cf_city: req.cf?.city,
cf_colo: req.cf?.colo,
user_agent: req.headers.get('user-agent'),
};
await fetch(env.BIGQUERY_INGEST_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${env.BIGQUERY_INGEST_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(enriched),
});
}
async function assignExperiment(body: any, env: Env) {
const key = `exp:${body.exp_id}:${body.client_id}`;
const cached = await env.EXPERIMENT_KV.get(key);
if (cached) return cached;
// Deterministic 50/50 split via FNV-1a hash of client_id
const h = fnv1a(body.client_id);
const variant = (h % 100) < 50 ? 'control' : 'treatment';
await env.EXPERIMENT_KV.put(key, variant, { expirationTtl: 60*60*24*30 });
return variant;
}
function fnv1a(s: string): number {
let h = 0x811c9dc5;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return h >>> 0;
}
function corsHeaders(origin: string) {
return {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'POST,OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Content-Type': 'application/json',
};
}
Deploy: wrangler in 5 lines
The deployment script is shorter than the Worker itself. We commit it to the client's analytics repo and run it from CI:
# wrangler.toml
name = "client-ssgtm"
main = "src/worker.ts"
compatibility_date = "2026-01-15"
workers_dev = false
routes = [
{ pattern = "track.example.com/*", zone_name = "example.com" }
]
kv_namespaces = [
{ binding = "EXPERIMENT_KV", id = "abc123..." }
]
[vars]
GA4_MEASUREMENT_ID = "G-XXXXXXXX"
# secrets set via: wrangler secret put GA4_API_SECRET
# deploy.sh
#!/bin/bash
set -e
npm ci
npm run typecheck
wrangler deploy --env production
echo "Deployed to track.example.com"
That's it. From a clean repo to a production SSGTM endpoint takes under 4 minutes. We typically migrate a client off Google Tag Manager Server-Side on Cloud Run in one afternoon.
The first-party cookie trick
The reason this Worker beats client-side is largely the cookie. We serve the Worker on a subdomain of the client's main domain (track.example.com), which means the cookies it sets are first-party and survive ITP truncation if you set them with HTTP Set-Cookie headers (not document.cookie from JS — that's still 7-day capped on Safari).
// in the response handler
return new Response(JSON.stringify({ ok: true }), {
headers: {
...corsHeaders(origin),
'Set-Cookie': `_ga_srv=${clientId}; Domain=.example.com; Path=/; Max-Age=63072000; HttpOnly; Secure; SameSite=Lax`,
},
});
2-year retention, ITP-safe, HttpOnly so it can't be exfiltrated by JS. This single change is worth ~12-18% of recovered conversion data on Safari mobile traffic, which in Thailand is roughly 25% of the total — call it 3-4% recovered conversions sitewide. On a ฿4M/month e-commerce site that's ฿120-160K/month of revenue that becomes attributable instead of ghost.
Compliance and PDPA
Server-side analytics is more exposed to PDPA, not less. The Worker now holds IP, user-agent, geolocation (via req.cf), and a stable client identifier. We hash IP with a per-client salt and drop the raw value before it hits BigQuery. We documented the full setup in our PDPA for analytics piece — read that before going live.
What this doesn't replace
Cloudflare Workers is not a drop-in replacement for full server-side GTM if you need the GTM UI for non-developers. We trade off the GUI for code simplicity and 12x cheaper hosting. Most of our clients are happy with that trade — the people configuring tags are the same people who can read TypeScript. If your marketing team genuinely manages tags themselves and won't switch to code, run GTM on Cloud Run instead.
It also doesn't replace a dedicated CDP. We pipe the BigQuery stream into the client's CDP (usually Segment or RudderStack) when one exists, but the Worker itself is just a relay + hash + assign + log.
Pairing with the rest of the stack
SSGTM is one piece. The full Bangkok Digital analytics stack:
- Cloudflare Workers SSGTM (this article) — ingestion + first-party cookies + experiment assignment.
- BigQuery streaming + dbt — warehouse modeling, channel grouping, retention.
- Markov attribution — channel-level revenue allocation.
- Sequential testing — experimentation analysis with continuous monitoring.
- Thai checkout patterns — what to test based on our scraper data.
- PDPA compliance — consent capture and data retention.
We ship all six on a 60-90 day onboarding for any new CRO retainer. The Worker takes the first afternoon. The rest takes the rest.
Get the template
If you want the production-grade Worker template (with consent gating, bot filtering, and the BigQuery sink writer), email us and we'll send the repo. We also do straight-up engineering builds for clients of Bluewich and SEO Agency Bangkok who don't want a full retainer — see our case studies or visit SitPlay Media for the content side of the same accounts.
ga4 server-side cloudflare analytics-engineering thailand