Skip to content
Server & hosting

The Session Lock: When the Cart Slows the Whole Shop

Two concurrent requests from the same visitor run one after the other once both open the session. Where the wait comes from and how to remove it.

14 min read PHPSessionsBackendE-Commerce

A shop that slows down under load is a familiar picture. The other case is harder to explain: server load sits close to idle, the database answers in a few milliseconds, and yet the request for the cart counter takes half a second - but only while another page for the same visitor is loading. The cause is not in the application profiler, it is in the runtime manual: as long as one script has a session open, that session is locked for every other script. This article shows how that queue forms, where it surfaces in a shop, how to measure the wait per request and which three changes make it go away.

Key takeaways

  • Session data is locked while a script has it open; the documentation states that only one script may operate on a session at any one time (PHP manual). Two concurrent requests from the same visitor therefore run one after the other.
  • The finding shows up per visitor, not in server load. Only when several requests of the same session are placed side by side in time does an unremarkable average turn into a wait with a clear start and end.
  • What gets measured is the gap between the request that wants to open the session and the moment it gets it. A timing value in the response header makes that share visible per request without rebuilding the application.
  • The most effective change is the early close: a request that only reads hands the session straight back. The start call offers an option that closes the session immediately after reading and avoids unnecessary locking (PHP manual).
  • A different store shortens the wait but does not remove the queue. To dissolve it you have to close the session early - or not start it at all for requests that do not need one.
  • The defaults deserve a review: 1,440 seconds of lifetime for session data and a 1 percent chance of the cleanup run per request (PHP manual) are defaults, not decisions.

Why two requests run one after the other

When a session starts, the runtime reads the stored data and locks it for other requests in the same move. The documentation gives the reason without ambiguity: session data is locked to prevent concurrent writes, and therefore only one script may operate on a session at any one time (PHP manual). The lock is released when the script ends or hands it back explicitly. For a single page view this has no consequences. But as soon as a visitor issues several requests at once - and every contemporary interface does - parallel work turns into a queue. This has nothing to do with the number of worker processes: the free processes are standing by, they simply cannot reach the data.

What matters is that the lock applies per visitor, not per server. Two different customers do not disturb each other; two browser tabs belonging to the same customer do. That is exactly why the effect stays invisible in a load test with a thousand synthetic visitors: there, every test user has their own session and issues requests neatly one at a time. On the server side everything looks healthy while the real visitor waits. The finding only appears once the requests of a single visitor are laid side by side in time - with timestamps for start and end, not just a duration.

How this differs from a capacity problem

With too few worker processes, every visitor waits and the wait grows with the number of visitors. With the session lock, one visitor waits for themselves and the wait grows with the number of their concurrent requests. Both look identical in an overview - slow responses - but they call for opposite measures. More worker processes do not help in the second case, they only move the wait somewhere else.

Where the wait shows up in the shop

It hits the small background requests hardest, the ones running alongside the page itself. The cart counter in the header, the availability check on the product page, reloaded filter results, the comparison marker, the note about recently viewed items: every one of these opens the session because it needs the cart identifier - or because a framework opens it for every request without asking. While the page itself is still being produced, all of them stand still. In the waterfall of the browser tools this looks like a slow endpoint, and that is usually how it gets treated. The queue is most expensive in the order path, where several steps write to the session in quick succession.

A second pattern involves long-running requests. An export, an image conversion, a call to a payment provider - anything that takes seconds rather than milliseconds - blocks every further request from the same visitor for its whole duration, including plain navigation. In Shopware shops this often surfaces during imports in the customer account, in other systems when an invoice is rendered as a PDF. The visitor clicks, sees nothing, clicks again - and creates another request that joins the back of the queue. One slow action becomes three.

Whether a request really needs the session can almost always be answered before the first line of code is written. The comparison below covers the cases that make up most of the requests in a shop.

Type of requestNeeds the sessionCorrect handling
Catalogue page without personal componentsnot included do not start a session at all
Cart counter, recently viewed itemsread onlyread and close immediately
Reloading filter and search resultsusually notidentifier from the request, not the session
Adding an item to the cartwritingopen, write, close immediately
Submitting a checkout stepwritingkeep it short, move long work out
Generating an export or invoicenot included queue it, poll progress without a session

The third row is the least conspicuous and at the same time the most common. Many background requests open the session only because the shared bootstrap of the application starts one. The same habit costs you the cache hit rate elsewhere: where a session starts, a cookie appears, and where a cookie appears, the response becomes personal. Taking the session start out of the bootstrap and placing it where it is genuinely needed wins back both.

Measure the lock instead of guessing

The measurement is simpler than it sounds, because the wait has a clear start and a clear end: it sits between the request that wants to open the session and the moment it receives it. Measure that gap, attach it to the response as a timing value, and you will see it in the browser tools next to every other segment. The response header for server timings is the shortest route because it needs no evaluation of its own. In parallel, the same value belongs in the log so it can be analysed over days.

measure-session-lock.php
<?php
// Measure the wait for the session lock on every request
$start  = hrtime(true);
session_start();                       // blocks while another request is writing
$lock   = (hrtime(true) - $start) / 1e6;   // milliseconds

// Requests that write nothing to the session hand it back at once
if (!$needsWriteAccess) {
    session_write_close();
}

// Visible in the browser tools next to every other segment
header('Server-Timing: sesslock;desc="Session lock";dur=' . round($lock, 1));

register_shutdown_function(static function () use ($start, $lock): void {
    $total = (hrtime(true) - $start) / 1e6;
    error_log(json_encode([
        'path'     => $_SERVER['REQUEST_URI'] ?? '',
        'session'  => substr(session_id() ?: '-', 0, 8),   // for grouping only
        'lock_ms'  => round($lock, 1),
        'total_ms' => round($total, 1),
        'share'    => $total > 0 ? round($lock / $total * 100) : 0,
    ], JSON_UNESCAPED_SLASHES));
});

Three numbers are enough for a verdict: the wait for the lock, the total duration of the request, and the share of the first in the second. If that share exceeds half on individual requests, the queue is the main problem of this endpoint - regardless of how good the server response time looks on average. The series only becomes meaningful over several days, because the lock only appears when two requests genuinely overlap.

Wait per request

The gap between opening the session and receiving the lock. Everything else is derived from this single number.

Session identifier in the log

Record only the first few characters of the identifier. That is enough to group requests from the same visitor and keeps the personal reference low.

Make the overlap visible

Two requests with the same identifier and overlapping time windows are the evidence. Without timestamps for start and end it stays a guess.

Reading or writing

Record per endpoint whether it changed the session. Requests that only read are the first group to get the early close.

Store and storage time

How long reading and writing the session data itself takes belongs in a separate measurement - otherwise waiting time blurs into storage time.

Share of the total duration

A ratio rather than an absolute value makes endpoints comparable that are inherently different in length.

The order that has proven itself

Measure first, then close the reading requests early, then take the session start out of the shared bootstrap, then decide about the store. Anyone who begins with the last step shortens the wait instead of removing it - and does not yet have the measurement that would prove the gain.

Close the session early

The early close is a one-liner and still the most effective change. Once the session data has been read, session_write_close() hands the lock back; the data remains available in memory, only changes no longer reach the store. For requests that write nothing from the outset there is a shorter route at the start: the documentation describes an option that closes the session immediately after reading, which avoids unnecessary locking (PHP manual). Together, the two take the wait out of background requests and act directly on interaction responsiveness, because those requests hang off a click.

The danger is not in closing, it is in writing afterwards. Anything put into the session after the close is lost silently - a class of defect that rarely surfaces in development, because two requests rarely collide there. Two habits keep it under control: move the close as far up as possible, directly behind the read, and route every write to the session through a single place in the code that checks whether the session is still open. Where a framework manages the session itself, the same switch belongs in its configuration rather than in application code.

  • Let read-only requests close through the start option instead of a later call - that saves the detour of acquiring the lock in the first place.
  • Put the close in front of every call to an external system; a timeout there must not block the visitor's entire session.
  • Move long work - export, PDF, image conversion - behind a queue and poll the progress without a session.
  • Take the session start out of the shared bootstrap and place it in the components that display personal data.
  • Pass identifiers that only serve recognition in the request instead of reading them from the session.
  • After every change, verify that login, cart and checkout still work - the session is the most sensitive part of a shop.

What never happens in development

On a development machine one person works in one browser tab. Two requests almost never collide there, so the wait disappears from view before anyone has seen it. It becomes reproducible only when two requests of the same session are made to overlap deliberately: an artificially delayed page view and the cart counter next to it. Whatever wait appears then is not a laboratory curiosity, it is the everyday experience of any visitor who uses the interface briskly.

The store and the starting point

By default, session data lives in the file system; the documentation names the file handler as the default store (PHP manual). On a server with slow storage or a shared network drive, every open and close costs extra time, and that time extends the lock for every waiting request. A store in memory shortens that share noticeably. It does not remove the queue: the session locks there too, just for less time. The same holds for the remaining caching layers - they speed up work that still happens one item at a time.

Three configuration values are often adopted without review. The lifetime of session data defaults to 1,440 seconds, that is 24 minutes (PHP manual); anyone expecting longer-lived carts has to raise it deliberately instead of wondering about lost baskets. The cleanup run starts with a probability of 1 percent per request by default (PHP manual) - on a server with many small session files this is an occasional outlier in response time that tends to get blamed on database queries. And the third default already works for you: session data is only rewritten when it has changed (PHP manual). That saves writing time, but not the lock.

session.ini
; Store: memory instead of the file system shortens the lock,
; it does not remove the queue.
session.save_handler = redis
session.save_path    = "tcp://127.0.0.1:6379?database=2"

; Set lifetime and cleanup deliberately instead of inheriting them
session.gc_maxlifetime = 1440          ; default: 1440 seconds
session.gc_probability = 1             ; 1/100 = a 1 percent chance per request (PHP manual)
session.gc_divisor     = 100

; Only writes back when something has changed
session.lazy_write = 1

; No session without a reason: start it in the component, not in the bootstrap
session.auto_start = 0

From finding to acceptance

A finding is fit for acceptance when it is documented with the same measurement before and after the change: the same endpoints, the same time window, the same analysis by template rather than single address. The published thresholds work as targets. Interaction responsiveness counts as good up to 200 milliseconds (web.dev), for server response time 0.8 seconds or less is given as a guide (web.dev), and the measurement is taken at the 75th percentile of page loads, segmented by mobile and desktop devices (web.dev). A background request that spends half its runtime waiting for a lock misses both values without a single line of application code being slow.

  1. Record the wait for the lock per request and let it run for two weeks before changing anything.
  2. Sort endpoints by how often they wait - not by how long they take on average.
  3. Switch read-only requests to the early close and leave the measurement running unchanged.
  4. Take the session start out of the shared bootstrap; move one component at a time rather than all at once.
  5. Only then decide about the store, with the measurement as evidence for the gain.
  6. Record the before-and-after comparison per endpoint; that is the part an acceptance test needs.

The most common finding in such measurements is not a single slow endpoint but a small background request that runs on every page and waits in a share of the cases. For online shops that is typically the cart counter: technically trivial, yet in aggregate the most expensive request in the whole site. Switch it first and the most visible part of the work is done in a morning.

The session lock is the rare case where more hardware changes nothing. There is nothing to speed up - there is only something to release.

Project experience from performance work on shops and customer portals

The effort stays modest: add a measurement, switch the read-only requests, sort out the session start. What costs time is not the change itself but finding the places where the session is opened unnoticed - in custom extensions, in templates, in middleware. That is exactly what the measurement is for, and exactly why it belongs at the beginning rather than at the end of a technical analysis.

This article is based on data from: the PHP manual (session_write_close, session configuration, session_start) and web.dev (thresholds for interaction responsiveness and server response time, evaluation at the 75th percentile).

Related Articles

Server & hosting

PHP Runtime Tuning: OPcache, JIT and FPM Workers

Between web server and database sits the PHP runtime. How OPcache, preloading, JIT and FPM workers shape your response time under load, and how to measure it.

12 min read
Shops & CMS

Checkout Speed: Winning the Final Seconds of a Sale

A checkout cannot be cached away: why the five steps before an order need their own speed budgets, and where in your shop you should be measuring them.

15 min read
Server & hosting

Server-Timing: Where the Backend Time Actually Goes

TTFB only gives you the sum. The Server-Timing header breaks server time down in the browser into database, cache, application logic and template work.

13 min read