Bangkok.Digital Free CRO Audit
// ANALYTICS · 2026-02-14 · 12 min read

GA4 Server-Side on Cloudflare Workers

Cost (~฿8K/month), latency profile, and the deploy scripts. Why we run server-side GA4 on Cloudflare Workers instead of Cloud Run for almost every Thai-traffic site under 50M monthly events.

By Yunmin Shin · Published 2026-02-14 · Bangkok

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:

ClientVerticalMonthly eventsWorkers costR2/KV costTotal (THB)
D2C beautyE-commerce4.2M$5.00$0.30~฿185
SaaSB2B1.1M$5.00$0.10~฿180
Marketplace2-sided38M$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:

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:

  1. Cloudflare Workers SSGTM (this article) — ingestion + first-party cookies + experiment assignment.
  2. BigQuery streaming + dbt — warehouse modeling, channel grouping, retention.
  3. Markov attribution — channel-level revenue allocation.
  4. Sequential testing — experimentation analysis with continuous monitoring.
  5. Thai checkout patterns — what to test based on our scraper data.
  6. 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.

Tags: ga4 server-side cloudflare analytics-engineering thailand
// RELATED INSIGHTS
// ATTRIBUTION · 2026-04-18

Markov Attribution in BigQuery: A Working Example

Step-by-step Markov-chain attribution in BigQuery SQL with real anonymized data.

// EXPERIMENTATION · 2026-03-25

Stop Peeking: Sequential Testing for Real Experimentation

Why fixed-horizon A/B tests inflate FPR by 5-15x. Python for mSPRT and AVI.

// THAI MARKET · 2026-01-22

Thai Checkout Patterns: PromptPay · LINE Pay · TrueMoney

Conversion patterns scraped across 200+ TH e-commerce sites.

// COMPLIANCE · 2025-12-15

PDPA Thailand for Analytics: What You Actually Need

Required vs optional vs overkill. Sample consent strings.

Migrate your SSGTM stack.

Free 30-minute audit of your current GA4 + tag setup. We'll quote the migration to Cloudflare Workers, fixed-fee, no surprises.

Free CRO Audit Call +66 61 093 4014
💬 LINE

Yunmin Agency Network

Bluewich · SitPlay Media · SEO Agency Bangkok · Bangkok Digital

// WEEKLY THAI MARKET INSIGHTS

Get the data we scraped this week.

Rising keywords. SERP shifts. AI citation changes. Bangkok-market specific. No fluff, no sales — one email Tuesday morning.

No spam · Unsubscribe in one click

📱 WhatsApp · 💬 LINE · 📞 +66 61 093 4014

© 2026 · Operated by Yunmin Co., Ltd. · Thai Co. Reg. (pending) · 3rd Floor, 272 Than Thip 3 Alley, Phlabphla, Wang Thonglang, Bangkok 10310

Privacy · Terms · Atelier · umma@xx.gg