Skip to content
System nominalUTC --:--:--Articles 041

The Checkout That Forgets the Cart, and the Five Things That Cause It

WooCommerce keeps the cart in a table row that a cookie points at. Here is the order to check when a customer's session disappears at checkout.

Ana Ferreiro · @aferreiro

WooCommerce engineering

Topic
WOO
Section
WooCommerce & Storefront
Published
23 JUL 2026
Reading time
8 MIN
Words
1905W
Technical cover graphic for The Checkout That Forgets the Cart, and the Five Things That Cause It
Generated schematic — WOO series, REV 2.6.

A customer adds a shirt to the cart, reaches checkout, and gets a page telling them the cart is empty. Reloading brings it back. A minute later it is gone again. Nothing shows up in the PHP error log and no query fails. The request that loses the cart completes in about 300 ms and returns perfect HTML describing somebody else's basket.

That is what makes session loss expensive. It leaves no trace where you normally look, and the ticket describes a symptom rather than a cause. The notes below come from a WooCommerce 9.6 store on WordPress 6.7 and PHP 8.2, with Redis for the object cache and an Nginx FastCGI page cache in front. Most of the time the answer is in the first two causes.

The cart is a row in a table, addressed by two cookies

WooCommerce does not use PHP sessions. WC_Session_Handler writes to a custom table, wp_woocommerce_sessions, and three columns carry the weight: session_key, session_value and session_expiry. The key is the customer ID, the value is the serialised session array, and the cart lives inside it under the cart key. Saving is an INSERT ... ON DUPLICATE KEY UPDATE against session_key. Nothing about the cart is in a cookie.

The cookie is a pointer to that row. Its name is wp_woocommerce_session_ plus COOKIEHASH, and the constructor puts the name through a filter:

// WC_Session_Handler::__construct(), condensed
$this->_cookie = (string) apply_filters(
    'woocommerce_cookie',
    'wp_woocommerce_session_' . COOKIEHASH
);
$this->_table = $GLOBALS['wpdb']->prefix . 'woocommerce_sessions';

COOKIEHASH is md5() of the siteurl option, so changing the site URL changes the cookie name. That is why an http:// to https:// migration empties every cart in flight, and why a caching rule written against a hardcoded name stops matching afterwards. The value is four pipe-separated fields, the last of which is an HMAC keyed with wp_hash(), and therefore by the salts in wp-config.php.

The second cookie, woocommerce_cart_hash, is a cheap signal rather than a store. WC_Cart_Session sets it to WC()->cart->get_cart_hash() and sets woocommerce_items_in_cart to 1 whenever the cart is not empty, both through wc_setcookie(); when the cart empties, both are unset with an expiry in the past. Themes and caching layers read them to decide whether a visitor has a cart without loading the session at all. A stale hash does not lose the cart on its own, but it is the first thing a cache plugin looks at.

Guests and logged-in customers are keyed and expire differently

generate_customer_id() returns the user ID as a string for a logged-in customer, and a random value prefixed with t_ for a guest. Expiry splits the same way: two days for a guest, a week for a logged-in customer, refreshed once the one-day mark passes. Both are filterable through wc_session_expiration and wc_session_expiring.

The user-meta copy is a third thing. A logged-in customer's cart is also mirrored into user meta under _woocommerce_persistent_cart_{blog_id}, behind the woocommerce_persistent_cart_enabled filter. That is what restores a basket after a login on a new device. It holds cart lines only: no shipping selection, no coupons, no totals. And the claim that logged-in sessions live in user meta while guest sessions live in the table is out of date. The class docblock records that WooCommerce 2.5 replaced that arrangement with the custom table, and every version since keeps both in wp_woocommerce_sessions. There is no _wc_session_ key to find.

A cached cart page serves the next visitor somebody else's nonce

When the page cache is the cause, the symptom has a fingerprint. The cart survives the first request from a fresh browser and fails on the second. It works logged in and fails logged out. It fails on a phone and never on a laptop.

Cart and checkout pages contain a nonce. For logged-out visitors WordPress builds nonces against the user ID, and WooCommerce hooks nonce_user_logged_out to return the customer ID instead, which binds a cart nonce to one session. Print that nonce into a body that a page cache then stores, and every visitor who receives the cached copy gets a token minted for somebody else. Their first POST is rejected with a message about an expired session, which sends them to their browser settings rather than to you. The tell is an Age header or X-Cache: HIT on the cart URL. A cart page with an Age above zero is the bug.

This separates two failures that look identical from the outside: the session was never created, or it was created and then lost.

# 1. Add an item, capture the response headers.
curl -s -D /tmp/add.headers -o /dev/null \
  -X POST https://example.com/wp-json/wc/store/v1/cart/add-item \
  -H 'Content-Type: application/json' \
  -d '{"id":123,"quantity":1}'

grep -i 'set-cookie\|cart-token\|nonce\|age\|x-cache' /tmp/add.headers

# 2. Reuse the cart token and see whether the cart survived.
TOKEN=$(grep -i '^cart-token:' /tmp/add.headers | cut -d' ' -f2 | tr -d '\r')
curl -s https://example.com/wp-json/wc/store/v1/cart \
  -H "Cart-Token: $TOKEN" | head -c 400

Four outcomes, four bugs. No Set-Cookie for wp_woocommerce_session_* in step one means the response came from cache, or headers were already sent and wc_setcookie() did nothing. A cookie in step one but an empty cart in step two means the write is not landing where the next request reads. Both steps working in curl and not in a browser is a cookie-jar problem, with nothing to change on the server. A surviving cart with a 403 on the checkout POST is the nonce, which points back at the cache.

One detail matters here: wc_setcookie() takes the path from COOKIEPATH and the domain from COOKIE_DOMAIN, and passes the whole options array through the woocommerce_set_cookie_options filter, which has existed since WooCommerce 6.7. If a plugin added a samesite value or a domain, that filter is where it happened.

The remaining causes, in the order worth checking

SymptomLikely causeFirst check
Cart empties on reload, guests onlyCached cart page with a stale nonceAge or cache HIT on the cart URL
Cart empties at random, roughly half the timeTwo app servers with different saltswp-config.php on each node
Cart empties after crossing www and apexHost-only cookie, empty COOKIE_DOMAINDomain attribute on Set-Cookie
Cart survives, checkout POST returns 403Nonce or SameSite mismatchSameSite on the session cookie
Row exists in the table, cart still emptyStale object cache on one nodeRead the row directly and compare

A load balancer without sticky sessions. The folklore says you need affinity, and usually you do not. If both nodes share the database and the salts, the row and the HMAC verify wherever the request lands. What must be identical is the salts in wp-config.php, the session table, and the object cache. Two app servers built from different images, one with NONCE_SALT defined and one without, produce a specific failure. On the node where the HMAC does not verify, get_session_cookie() returns false, and the handler does not ignore the cookie: it calls destroy_session(). The cart is deleted rather than mislaid, and the next request to the healthy node finds no row at all.

Scope

The storage, cookie name and HMAC scheme described here are the behaviour of WC_Session_Handler on WooCommerce 9.x and 10.x. On older releases, or a site that replaces the handler through woocommerce_session_handler, read the class first.

A cookie domain mismatch between www and apex. On WordPress 6.6 and newer, COOKIE_DOMAIN defaults to an empty string; it was false before that. Either value means setcookie() omits the Domain attribute, which makes the cookie host-only: one set on www.example.com is never sent to example.com. Because COOKIEHASH comes from siteurl, inconsistent hosts also give you two different cookie names. Pick one canonical host, redirect the other, and let the redirect happen before any cookie is set.

A Secure or SameSite attribute that does not match the transport. WooCommerce decides the Secure flag with wc_site_is_https() && is_ssl(), and the result goes through the wc_session_use_secure_cookie filter. WordPress's is_ssl() reads $_SERVER['HTTPS'] and $_SERVER['SERVER_PORT']. It does not read X-Forwarded-Proto. Behind a proxy that terminates TLS and forwards plain HTTP to PHP, is_ssl() is false and the session cookie comes back without Secure. Set SameSite=None on that cookie, which is what plugins do to make an embedded widget work, and the browser rejects it outright, because SameSite=None is only honoured on a cookie that is also Secure. The customer gets a new session on every request and the server logs nothing unusual. Fix the edge rather than the symptom: have the proxy set HTTPS=on and SERVER_PORT=443 for the PHP worker.

Object cache eviction, and the difference between eviction and staleness. Session reads go through wp_cache_get() in the WC_SESSION_CACHE_GROUP group, with a TTL taken from the session expiration. The database stays authoritative, so a plain eviction costs one extra query and nothing more. Staleness is what loses the cart: a second Redis instance per node, or a per-node APCu holding yesterday's copy while a different node writes the new one.

SELECT session_key,
       session_expiry,
       LENGTH(session_value) AS bytes
FROM wp_woocommerce_sessions
WHERE session_expiry > UNIX_TIMESTAMP()
ORDER BY session_expiry DESC
LIMIT 20;

If the row for the failing request is not in that list, you have a write problem or a genuine expiry. If it is there with recent bytes and the cart is still empty, stop looking at the database.

Excluding cart and checkout from the page cache is mandatory

There is no configuration in which caching the cart page is correct. The body contains a nonce, the nonce belongs to one session, and a page cache stores one body for every visitor. You cannot do this carefully, and you cannot do it for anonymous visitors only.

Exclude the pages that carry session state: cart, checkout, my-account and order-received, plus anything with wc-api or wc-ajax in the query string. WooCommerce helps here by prefixing its cookie names with wp: the docblock on set_customer_session_cookie() says the prefix exists so cache systems like Batcache decline to cache a page when the cookie is present. That is a useful default, not a plan.

A plugin that "fixes" session loss by disabling page caching for the whole site has not fixed anything. It has converted a cart bug into a site-wide performance problem, and on a store doing a few thousand orders a month the bill arrives as TTFB and crawl budget rather than as a support ticket. The narrow exclusion is the fix, and it belongs in the caching layer's own configuration rather than a settings screen that the next update will overwrite.

One limit belongs here. None of this helps a customer whose browser is blocking first-party cookies, or whose corporate proxy strips them. The curl sequence above will tell you that is what you are looking at, and there is nothing to change on the server.

What to do on Monday

  1. Request the cart page twice with a session cookie and confirm there is no Age and no cache HIT on the response.
  2. Run the curl sequence and write down whether Set-Cookie appears on the add-item response, and whether the second call sees the item.
  3. Diff wp-config.php across every app server, salts included, rather than reading them.
  4. Confirm siteurl and home use the same host, and that the other host redirects instead of serving.
  5. Check the Secure and SameSite attributes on the session cookie from an HTTPS request. If Secure is missing, look at is_ssl() before you look at plugins.
  6. Add the cart, checkout and account URLs to the page cache exclusion in the caching layer itself.

Ana Ferreiro · @aferreiro

WooCommerce engineering

Covers the WooCommerce order lifecycle: checkout, HPOS, session handling, transactional email and the post-purchase layer where most storefront complaints actually originate.

Continue

Related in WooCommerce & Storefront