Field Fix

Server-Side Cookies Are Not Persisting

You moved to a server container specifically to escape browser cookie limits, and Safari users still reset every week. There are two likely reasons, and one of them is invisible in every interface you can check.

Get My Free Audit →
Symptoms

Are you seeing this?

  • Safari users appear as new visitors on nearly every session
  • Attribution windows collapse to about a week regardless of your configured settings
  • Returning-visitor rates are far lower in Safari than in Chrome
  • Click IDs are present on landing pages and gone by checkout
  • You migrated to server-side tagging and measured no improvement in data retention
Causes

Why it happens

Cookies are still being set by JavaScript.

The server container is live, but cookies are written client-side via document.cookie rather than by the server in an HTTP response header. Safari's Intelligent Tracking Prevention caps script-written first-party cookies at seven days. The migration delivered no benefit because the mechanism that caused the problem never changed.

Your container hostname is CNAME-cloaked.

If your tracking subdomain is a CNAME pointing at a third-party provider's domain, Safari resolves the chain, detects that it terminates off-site, and applies the same seven-day cap it would apply to a third-party cookie. The setup looks perfectly first-party in DNS tooling and in your browser's address bar. It is not treated as first-party by the browser.

The cookie domain is set too narrowly.

A cookie scoped to tracking.yoursite.com is not readable on www.yoursite.com. Set the domain to the registrable root so it works across your subdomains.

SameSite or Secure are misconfigured.

SameSite=None without Secure is rejected outright by modern browsers. SameSite=Strict breaks cookies on cross-site entry, which includes every visitor arriving from an ad.

The client is not configured to write cookies.

In a server container, the GA4 client has settings governing cookie writing. Left at defaults, or with a custom client in place, it may not be setting the identifier cookies at all.

Diagnose

How to diagnose it

01

Step 01 — Find out what actually sets the cookie.

DevTools → Application → Cookies. Compare against the Network tab: look for Set-Cookie in the response headers of requests to your tracking subdomain. If the cookie exists but no response header set it, JavaScript did — that is cause 01.

02

Step 02 — Resolve your tracking hostname.

Run a DNS lookup on your container subdomain. If it is a CNAME terminating at a third-party domain, you have cause 02, regardless of how first-party the URL looks.

03

Step 03 — Check the cookie's expiry as the browser recorded it.

Not what you configured — what is actually stored. Set it to 400 days, then check in Safari after a week. An expiry that has silently become seven days confirms ITP capping.

04

Step 04 — Inspect the cookie attributes.

Domain, path, Secure, SameSite, HttpOnly. A cookie scoped to the wrong domain will be invisible to the pages that need it.

05

Step 05 — Test the click-ID journey end to end.

Land on your site with a ?fbclid= or ?gclid= parameter, browse to a product, add to cart, and reach checkout. Check at each step whether the identifier survives. Losing it mid-journey is a persistence failure, not a capture failure.

Fix

How to fix it

For cause 02 — the invisible one. Your tracking subdomain must be a genuine first-party endpoint. Point an A or AAAA record at infrastructure you control, or use a provider that supports a true first-party setup rather than a CNAME to their domain. If you are on Cloud Run, map a custom domain on your own root domain rather than delegating the subdomain elsewhere.

There is no code fix for this one. If the DNS is wrong, everything downstream is capped regardless of how correctly it is configured.

For cause 01 — set cookies server-side. In your server container, the response must carry the Set-Cookie header:

HTTP
Set-Cookie: _propulse_id=<value>;
  Domain=.yoursite.com;
  Path=/;
  Max-Age=34560000;
  Secure;
  SameSite=Lax;
  HttpOnly

Attribute by attribute:

Domain=.yoursite.com — the registrable root, so the cookie is readable across www and your tracking subdomain Max-Age=34560000 — 400 days, the current practical ceiling for a first-party cookie set this way Secure — required, and required for SameSite=None if you ever need it SameSite=Lax — allows the cookie on top-level navigation from an ad click, which Strict would block HttpOnly — prevents JavaScript access, which also means scripts cannot accidentally overwrite it with a capped version

For click-ID capture, grab the identifier on arrival and persist it to the order so it survives the entire journey:

PHP
add_action( 'init', 'propulse_capture_click_ids' );

function propulse_capture_click_ids() {
	if ( is_admin() ) {
		return;
	}

	// Facebook click ID — stored in the format Meta expects.
	if ( isset( $_GET['fbclid'] ) ) {
		$fbclid = sanitize_text_field( wp_unslash( $_GET['fbclid'] ) );
		$fbc    = 'fb.1.' . time() . '.' . $fbclid;

		setcookie( '_propulse_fbc', $fbc, array(
			'expires'  => time() + ( 90 * DAY_IN_SECONDS ),
			'path'     => '/',
			'domain'   => COOKIE_DOMAIN,
			'secure'   => is_ssl(),
			'httponly' => false,
			'samesite' => 'Lax',
		) );
	}

	if ( isset( $_GET['gclid'] ) ) {
		setcookie( '_propulse_gclid', sanitize_text_field( wp_unslash( $_GET['gclid'] ) ), array(
			'expires'  => time() + ( 90 * DAY_IN_SECONDS ),
			'path'     => '/',
			'domain'   => COOKIE_DOMAIN,
			'secure'   => is_ssl(),
			'httponly' => false,
			'samesite' => 'Lax',
		) );
	}
}

/**
 * Persist to the order so attribution survives beyond any cookie lifetime.
 */
add_action( 'woocommerce_checkout_create_order', 'propulse_attach_click_ids', 10, 2 );

function propulse_attach_click_ids( $order, $data ) {
	if ( ! empty( $_COOKIE['_propulse_fbc'] ) ) {
		$order->update_meta_data( '_propulse_fbc', sanitize_text_field( wp_unslash( $_COOKIE['_propulse_fbc'] ) ) );
	}

	if ( ! empty( $_COOKIE['_fbp'] ) ) {
		$order->update_meta_data( '_propulse_fbp', sanitize_text_field( wp_unslash( $_COOKIE['_fbp'] ) ) );
	}

	if ( ! empty( $_COOKIE['_propulse_gclid'] ) ) {
		$order->update_meta_data( '_propulse_gclid', sanitize_text_field( wp_unslash( $_COOKIE['_propulse_gclid'] ) ) );
	}
}

Writing the click ID onto the order is the important part. Once it is in order meta it is permanent — a delayed server-side send, a retry days later, or an offline conversion upload all still have the identifier available regardless of what happened to the cookie.

On consent: none of this should run before consent where consent is required. Gate the capture on your consent state rather than treating persistence as exempt from it.

Verify

How to verify the fix worked

  1. Confirm Set-Cookie appears in response headers, not written by script
  2. Resolve your tracking hostname and confirm it is not a CNAME to a third party
  3. Set a long expiry, then check in Safari after seven days — the cookie should still be there with its original expiry intact
  4. Land with a ?fbclid= parameter, complete a test purchase, and confirm _propulse_fbc is present in the order meta
  5. Compare Safari returning-visitor rates against Chrome after two to three weeks; the gap should narrow substantially
Escalation

When this needs more than a snippet

If your DNS is genuinely first-party and cookies are set by response headers and persistence is still failing, the remaining causes are usually in the container configuration — client settings, cookie writing behaviour, or a conflict between the container and an existing client-side implementation writing the same cookie names.

Tracing that is part of our server-side tracking work, including the infrastructure and DNS setup that makes first-party actually first-party.

Get Your Free Audit

Tell us about your setup. We respond within 24 hours.

FAQ

Common
Questions

Ready to Build Something That Works?

Book a free 30-minute strategy audit. No pitch deck, no pressure — just an honest look at your setup and what to fix first.

Get My Free Audit →