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
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.
Curl the cart endpoint and read Set-Cookie before you open a plugin file
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
| Symptom | Likely cause | First check |
|---|---|---|
| Cart empties on reload, guests only | Cached cart page with a stale nonce | Age or cache HIT on the cart URL |
| Cart empties at random, roughly half the time | Two app servers with different salts | wp-config.php on each node |
| Cart empties after crossing www and apex | Host-only cookie, empty COOKIE_DOMAIN | Domain attribute on Set-Cookie |
| Cart survives, checkout POST returns 403 | Nonce or SameSite mismatch | SameSite on the session cookie |
| Row exists in the table, cart still empty | Stale object cache on one node | Read 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
- Request the cart page twice with a session cookie and confirm there is no
Ageand no cache HIT on the response. - Run the curl sequence and write down whether
Set-Cookieappears on the add-item response, and whether the second call sees the item. - Diff
wp-config.phpacross every app server, salts included, rather than reading them. - Confirm
siteurlandhomeuse the same host, and that the other host redirects instead of serving. - Check the
SecureandSameSiteattributes on the session cookie from an HTTPS request. IfSecureis missing, look atis_ssl()before you look at plugins. - 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