Server-side tagging pays a lead business when enough Safari and iPhone visitors book more than a day after the ad click, because for click ids a server buys one thing: a cookie Safari keeps longer than a page script's. A container on a hosted tagging subdomain keeps that cookie for 7 days, and only a cookie set from your site's own origin and address range keeps it past the first week.
Server-side tagging moves tag requests from the browser to a server you control, which forwards them to vendors.
How long does a click id survive in Safari?
| Who sets the click-id cookie | How long Safari keeps it | Source |
|---|---|---|
| Page script, landing from a domain Safari has classified, with a query string or fragment | 24 hours | WebKit, ITP 2.2 and 2.3 |
| Page script, any other landing | 7 days | WebKit, ITP 2.1 |
| A server whose IP differs from the site's in the first half of the address (/16 for IPv4, /64 for IPv6), or a subdomain cloaked to another provider | 7 days | WebKit PR #5347, WebKit CNAME defense |
| A server on the site's own origin and address range | The cookie's own expiry | Same sources: neither cap applies |
Checked October 1, 2026. Safari's Intelligent Tracking Prevention (ITP) classifies domains on-device without a public list, so treat a paid-search landing as the first row.
Most hosted setups land in the third row: a tagging subdomain such as metrics.example.com at a host's address only moves the cliff from 24 hours to a week.
Link Tracking Protection strips known tracking parameters before the page loads, by default only in Mail, Messages and Private Browsing unless a user extends Advanced Tracking and Fingerprinting Protection to all browsing. No server restores an id that never arrived.
Estimate the expiry shortfall before you buy hosting
Only Safari and iPhone ids booked after the first day can die of a cookie clock, so the estimate borrows the other browsers' lag curve to say how many should exist and counts the missing.
- Rule out a broken page with the entry-path split of click id capture. If paths disagree, fix that and stop.
- Bucket browsers as WebKit (Safari and every iPhone browser) or other.
- Take click-to-booking lag from your own stored click time. Google Ads returns only a click date per gclid (click_view, last 90 days, one day per query) and nothing for gbraid or wbraid.
- Split the late ids at 7 days, where a hosted subdomain's cookie dies. For each late bucket (24 hours to 7 days, over 7 days): expected WebKit ids = WebKit same-day ids × (other ids in that bucket ÷ other same-day). Expected minus observed is that bucket's shortfall. A hosted 7-day cookie recovers the first bucket's, a same-origin cookie both.
- Stop under 10 a month: other browsers need 10 in each lag bucket or their ratios are guessing, and below 10 expected WebKit over-24-hour ids even a perfect cookie cannot pay.
- Split the browsers' capture-rate gap into the expiry slice (both buckets' shortfall ÷ WebKit bookings) and the remainder: ids gone before any cookie existed. Over 30 points, the top of the entry-path post's band for ordinary loss, the remainder hides a fixable leak: gbraid and wbraid unread, or a booking iframe keeping the id in a cross-site cookie, which Safari blocks by default. No server touches that part, so fix it first.
Weakest assumption: Safari and iPhone users book on everyone else's lag curve. If your iPhone traffic skews to impulse bookings, the estimate runs high.
-- Expiry shortfall: Safari and iPhone click ids a cookie clock removed before the booking.
-- browser_family: 'webkit' for Safari and every iPhone browser (all WebKit in the US).
-- click_at comes from your own click cookie, not from the ad platform.
with rows_90d as (
select
if(browser_family = 'webkit', 'webkit', 'other') as browser,
-- an id with no click time cannot be bucketed by lag, so it counts as not captured
coalesce(gclid, gbraid, wbraid) is not null and click_at is not null as captured,
timestamp_diff(booked_at, click_at, hour) as lag_h
from crm.web_bookings
where booked_at >= timestamp_sub(current_timestamp(), interval 90 day)
),
counts as (
select browser,
count(*) as bookings,
countif(captured and lag_h < 24) as same_day,
countif(captured and lag_h >= 24 and lag_h < 168) as in_week, -- a hosted 7-day cookie keeps these
countif(captured and lag_h >= 168) as after_week -- only a same-origin cookie keeps these
from rows_90d
group by browser
),
est as (
select
w.bookings as w_bookings, w.same_day + w.in_week + w.after_week as w_captured,
w.in_week as w_in_week, w.after_week as w_after_week,
o.bookings as o_bookings, o.same_day + o.in_week + o.after_week as o_captured,
safe_divide(w.same_day * o.in_week, o.same_day) as w_in_week_expected,
safe_divide(w.same_day * o.after_week, o.same_day) as w_after_week_expected
from counts w
cross join counts o
where w.browser = 'webkit'
and o.browser = 'other'
and o.same_day >= 30 and o.in_week >= 30 and o.after_week >= 30 -- 10 a month per bucket, else no row back
),
shortfall as (
select *,
greatest(w_in_week_expected - w_in_week, 0) as short_in_week,
greatest(w_in_week_expected - w_in_week, 0)
+ greatest(w_after_week_expected - w_after_week, 0) as short_all
from est
)
select
round(w_in_week_expected + w_after_week_expected, 1) as webkit_later_expected,
(w_in_week_expected + w_after_week_expected) / 3 >= 10 as ceiling_clears_bar, -- false: a perfect cookie cannot pay, stop
round(short_in_week / 3, 1) as hosted_7d_per_month, -- 7-day cookie from a hosted subdomain
round(short_all / 3, 1) as same_origin_per_month, -- full-expiry cookie from your own origin
-- captured ids stand in for uploaded ids; use your upload log if the two differ
round(100 * short_in_week / (w_captured + o_captured), 1) as hosted_pct_of_uploads,
round(100 * short_all / (w_captured + o_captured), 1) as same_origin_pct_of_uploads,
round(100 * (o_captured / o_bookings - w_captured / w_bookings), 1) as device_gap_pts,
round(100 * short_all / w_bookings, 1) as expiry_slice_pts,
round(100 * (o_captured / o_bookings - w_captured / w_bookings)
- 100 * short_all / w_bookings, 1) as remainder_pts
from shortfall;Most CRMs store no click time. Do not buy a server to find out: the free edge cookie below stores it, a hidden field passes it to the CRM with the user agent, and the query runs 90 days later.
My yes bar: 10 recoverable ids a month and a tenth of monthly uploads, a remainder of 30 points or less, and an upload running to carry the ids. Below it, I spend on capture first. Read hosted_7d_per_month for a container on a tagging subdomain and same_origin_per_month for a cookie from your own origin; the gap between them is the over-7-day bucket, the only case for same-origin over hosted.
Can you get a server-set click id cookie without a tagging server?
Yes, behind a CDN that runs code. This Cloudflare Worker sets the click cookie from the site's own hostname.
// Runs on the site's own hostname (route: example.com/*), so the cookie comes from the
// same origin and IP as the page: neither Safari's script-cookie caps nor its third-party-IP
// rule applies.
// [URL parameter, click id type].
const CLICK_PARAMS = [
["gclid", "gclid"],
["gbraid", "gbraid"],
["wbraid", "wbraid"],
];
const MAX_AGE = 90 * 24 * 60 * 60; // match your conversion window, no longer
export default {
async fetch(request) {
const response = await fetch(request);
const url = new URL(request.url);
const hit = CLICK_PARAMS.find(([name]) => url.searchParams.get(name));
if (!hit) return response;
const [name, type] = hit;
// Same consent rule as your ad cookies: replace with your CMP's ad_storage signal.
// Landing before the banner is answered? Post the id to a same-origin path after consent
// and set the cookie there instead.
const cookies = request.headers.get("Cookie") || "";
if (!/(?:^|;\s*)ad_consent=granted/.test(cookies)) return response;
// Keep the click time: the expiry estimate needs it.
// Stored as type|id|unix_seconds, URL-encoded; the form script runs decodeURIComponent,
// then splits on "|".
const value = [type, url.searchParams.get(name), Math.floor(Date.now() / 1000)].join("|");
const out = new Response(response.body, response);
out.headers.append(
"Set-Cookie",
`lead_click=${encodeURIComponent(value)}; Max-Age=${MAX_AGE}; Path=/; Secure; SameSite=Lax`
);
return out; // not HttpOnly: the booking form's script has to read it
},
};Served from the page's own origin and address, the cookie keeps its 90 days and stays readable for the form's hidden field; a container's HttpOnly click cookie would leave that field, and the CRM row, blank. Narrow the route to landing paths to stay in the free tier.
Set up a same-origin click cookie when the estimate says yes
- Deploy the edge cookie above. Tag gateway does not count: its cookies are still set by script.
- Verify in Safari. Load a landing URL with a dummy
gclidand find the cookie in Web Inspector's Storage tab: it should expire more than 7 days out and not be HttpOnly. A direct load is representative, because the 24-hour cap applies only to script-set cookies. - Point the form at your own cookie for click id and click time.
- Rerun the estimate on the 90 days before and after go-live. The expiry slice should shrink toward zero with the upload job untouched.
- Add a container only for what a cookie cannot do: one consent check for several destinations, stripped fields, a warehouse event stream. Build it with same-origin routing, two instances, the request-log filter and a consent check in every non-Google tag.
Does moving tags to a server change what consent allows?
No. Per Google's server-side consent guide, the Google tag passes consent choices to the server container as request parameters, and Google's product tags there adjust what they send. A tag for any other vendor reads nothing until someone writes the check, so I treat it as consent-blind until I have read its code. More in what server-side capture may do under consent. For a group with several brand sites, I put the check in each tag as part of consent-gated measurement.
Where does the offline upload belong, if not in the container?
- Ad click landsgclid, gbraid or wbraid
- Consent state read
- Click id cookie setsame origin
- Dedupe key storesurvives re-runs
- Rolling re-read windowlate status edits
- Retraction logtakes conversions back
A tagging server answers each request and forgets it. The upload needs memory: a dedupe key that survives a re-run, a rolling re-read window for late status edits, and a log for retracting a conversion after a no-show.
A kept appointment settles days after the click, in a system the tagging server never sees, so none of my offline uploads run through a server container. The scheduled job that connects kept appointments back to Google Ads starts at the booking row and sends Google only a click id, a conversion value and a timestamp.
What does each hosting option cost per month?
Vendor list prices, checked October 2, 2026.
| Option | List price | Safari click-cookie lifetime |
|---|---|---|
| Edge cookie: the short Cloudflare Worker above, on your own hostname | $0 within Workers Free (100,000 requests a day); Paid from $5 a month | The cookie's own expiry |
| Google tag gateway: a CDN setting, no container | No Google fee listed; free on any Cloudflare plan | Set by Google's script, so the first table's script rows apply: 24 hours after a classified referrer, else 7 days |
| Stape: a hosted server container | Free to 10K requests; Pro $20 a month or $200 a year, to 500K | 7 days unless served from your own origin or address range (same-origin path or Stape's Own CDN) |
| Your own server container on Cloud Run | About $45 per server a month, two recommended: about $90 before logging | 7 days on a Google-address subdomain; full expiry through same-origin path routing |
Stape's guide says "Cookie Keeper keeps first-party cookies valid for up to 400 days." It restores cookies from a master cookie that, per Stape's own Safari ITP guide, follows Safari's rules: from a different-IP subdomain it lasts 7 days too, so a lead who books on day 10 with no visit between finds nothing to restore.
Cloud Run also logs every request URL, click id included. Google's guide warns of "significant logging charges" past about a million requests a month and gives the filter NOT LOG_ID("run.googleapis.com/requests"). For a practice, condition-page URLs are browsing data it never chose to keep.
Tags
Frequently asked questions
Does server-side tagging fix Safari's 7-day cookie limit?
Server-side tagging lifts Safari's 7-day cookie cap only when the server that sets the cookie answers from your site's own address range, ideally from a path on the same hostname. In Safari, a cookie set by a server whose IP differs from the site's in the first half of the address is still capped at 7 days. That still beats a page-script cookie on a paid-search landing, which Safari can cap at 24 hours, so a hosted container keeps bookings made within a week but not the ones after it.
How much does server-side Google Tag Manager cost per month?
Self-hosted on Cloud Run, Google's setup guide puts each server at about $45 a month and recommends at least two, so about $90 before logging charges. Stape lists a free tier up to 10,000 requests and a Pro plan at $20 a month, or $200 a year, up to 500,000. What decides it is whether the recovered click ids reach an upload.
Is Google tag gateway the same as server-side GTM?
No, Google tag gateway serves Google's tag from a path on your domain through your CDN, while server-side GTM is a container you host. Tag gateway has no container to configure and no Google fee listed, but its cookies are still set by script, so Safari's script-cookie caps apply. Only the container can drop fields, enforce one consent check and send to non-Google destinations.
Should Google Ads offline conversion uploads run through the server container?
No, offline conversion uploads belong in a scheduled job. An upload needs memory across runs: a dedupe key that survives re-runs, a re-read window for late status edits, and a record of what to retract. A container answers one request at a time unless you bolt on a store, so in the systems I build it stops at the first hop.