Site icon NexGismo

PHP Session Locking: Fix Parallel AJAX Requests That Queue

PHP Session Locking: Fix Parallel AJAX Requests That Queue
TL;DR
  • What: PHP session locking makes parallel requests from one browser run one after another.
  • Why it matters: A dashboard firing four AJAX calls waits for the slowest chain, not the slowest call.
  • What to do: Use read_and_close for reads and call session_write_close() right after the last write.
  • Key stat: Per the PHP manual, only one script may operate on a session at any time.

PHP session locking happens because the default files handler holds an exclusive lock on the session from session_start() until the script ends. Parallel requests with the same session ID wait in line. Fix it by opening read-only sessions with read_and_close and calling session_write_close() immediately after your last session write.

PHP session locking is the mechanism PHP’s session module uses to prevent two requests from writing the same session at once. When a request calls session_start(), the save handler locks that session ID, and any other request using the same ID blocks until the lock is released by session_write_close() or the end of the script.

PHP session locking is the reason a page that fires several AJAX calls at once can feel slower than one that fires them in sequence. The browser sends the requests in parallel, PHP-FPM has free workers, and yet the network tab shows a staircase: each response starts only when the previous one ends. Nothing in your code looks wrong, and the profiler blames session_start().

The cause is documented, though tutorials often skip it. The session_write_close() manual page says session data “is locked to prevent concurrent writes” and that “only one script may operate on a session at any time.” The APM vendor Tideways describes the same symptom: a blocked request waits at session_start() until previous requests release the lock, which “leads to linear AJAX-requests execution instead of in parallel.”

This guide shows the broken pattern, the exact fixes, and the trade-off most articles leave out: removing the lock can silently lose session data. It also covers Laravel, Symfony, Redis-backed sessions and Node.js, because the same problem shows up in every stack with a different default.

Why does PHP session locking make parallel requests run one at a time?

PHP session locking serialises requests because the session handler takes an exclusive lock when the session opens and keeps it until the request finishes. With the default files handler, that lock sits on the sess_<id> file. Every request carrying the same session cookie must wait its turn.

The lock exists for a good reason: without it, two requests could overwrite each other’s changes. The cost is that every request holds the lock for its whole lifetime, including SQL queries, HTTP calls or sleep() in a polling loop.

Here is the client side that triggers it. Nothing here is unusual:

// The browser fires these in parallel, but PHP serves them one at a time
const [stats, alerts, feed, profile] = await Promise.all([
  fetch('/dashboard/stats.php'),
  fetch('/dashboard/alerts.php'),
  fetch('/dashboard/feed.php'),
  fetch('/dashboard/profile.php'),
]);

If each endpoint takes two seconds and all four call session_start() at the top, the last response arrives after roughly eight seconds instead of two. A user-contributed note on the PHP manual page demonstrates the effect with a sleep(3) script: two tabs sharing a session return in 3 and 6 seconds when the sleep runs inside the session, and both return in 3 seconds when session_write_close() runs before the sleep.

My recommendation: treat any endpoint that holds the session during slow work as a latency bug, even if it is fast in isolation.

How do you spot PHP session locking in production?

You spot it by looking for requests whose wall time is high but whose CPU and I/O time inside your code is low, with the gap sitting in session_start(). Three signals are reliable.

  1. Browser waterfall: parallel XHR/fetch calls from one tab finish in a staircase pattern instead of overlapping.
  2. Profiler traces: a large self-time on session_start(). Tideways added automatic detection for exactly this bottleneck.
  3. Different session, different result: the same calls run in parallel from a private window or with no cookie, because each has its own session ID.

Long-lived connections make it far worse. A Server-Sent Events stream or long-polling endpoint that keeps the session open blocks every other request from that user for as long as the stream lives. The PHP manual’s top-voted user note on session_write_close() describes exactly this: an AJAX poll without a close makes the outer page appear to hang and stalls new tabs.

What is the broken code pattern behind PHP session locking?

The broken pattern is calling session_start() at the top of a script and doing slow work before the script ends. Most PHP code is written this way, often via a shared bootstrap file that starts the session for every request.

<?php
// dashboard/stats.php — called 4x in parallel by the dashboard
session_start();                       // acquires the lock on sess_<id>

if (empty($_SESSION['user_id'])) {
    http_response_code(401);
    exit;
}
$userId = $_SESSION['user_id'];

// Slow work while the session lock is still held
$stats = $reportService->buildStats($userId);   // e.g. 2-3 s of SQL/API calls

header('Content-Type: application/json');
echo json_encode($stats);
// lock is only released here, when the script ends

The script only reads user_id, yet it keeps the lock for the full duration of buildStats().

Why session.lazy_write does not fix it

Developers sometimes assume session.lazy_write helps. It defaults to on, and the runtime configuration docs say it means “session data is only rewritten if it changes.” That saves a disk write at the end. It does not release the lock any earlier, so the blocking stays exactly the same.

How do you fix PHP session locking with read_and_close and session_write_close()?

You fix it by holding the lock only while you actually read or write session data. There are two tools, and which one you use depends on whether the endpoint writes.

Read-only endpoints: read_and_close

Since PHP 7.0, session_start() accepts an options array, including read_and_close, which reads the session and closes it immediately, unchanged. The session_start() manual recommends it when you don’t need to change session data, to avoid unnecessary locking.

<?php
// dashboard/stats.php — read-only endpoint
session_start(['read_and_close' => true]); // read, then release the lock immediately

if (empty($_SESSION['user_id'])) {
    http_response_code(401);
    exit;
}
$userId = $_SESSION['user_id'];

$stats = $reportService->buildStats($userId);   // runs without blocking siblings

header('Content-Type: application/json');
echo json_encode($stats);

Endpoints that write: close right after the last write

When the request must write, do the writes first and call session_write_close() before any slow work.

<?php
// cart/add.php — needs to write, then do slow work
session_start();

$_SESSION['cart'][] = (int) $_POST['product_id'];
$_SESSION['cart_updated_at'] = time();

session_write_close();                 // persist + release the lock NOW

// Everything below runs unlocked
$recommendations = $recoClient->fetchFor($_SESSION['cart']); // reading is fine
// $_SESSION['last_reco'] = ...;       // ⚠ silently NOT saved after close

echo json_encode(['ok' => true, 'recommendations' => $recommendations]);

Need to write again later? Reopen briefly

You can re-acquire the lock for a short final write. Keep the reopen window as small as possible.

<?php
session_start();
$userId = $_SESSION['user_id'];
session_write_close();                       // release during slow work

$result = $paymentGateway->charge($userId);  // slow, unlocked

session_start();                             // re-acquire only for the write
$_SESSION['last_charge_id'] = $result->id;
session_write_close();

One practical rule I’d apply: never start the session in a global bootstrap for API routes. Start it inside the controller that needs it, with the narrowest mode that works.

What can go wrong after you remove the session lock?

Removing or shortening the lock trades blocking for the risk of lost updates and a few quiet failures. Most tutorials stop at “add session_write_close()”, but these gotchas are documented and worth checking before you ship.

  1. Writes after close are dropped silently. $_SESSION still exists after session_write_close(), but changes are not saved. A user note on the manual page also warns that session_regenerate_id() fails after the close. That matters for login flows.
  2. Last write wins between overlapping writers. If two unlocked requests read the session, each changes a different key, and both write back, one change is lost. Tideways calls this out for lock-free custom handlers.
  3. Read-only sessions may expire early. A user note on the session_start() page reports that read_and_close does not update the session file’s modification time. With the files handler, garbage collection is based on age, and session.gc_maxlifetime defaults to 1440 seconds (24 minutes). A user who only hits read-only endpoints could be logged out while active. Test this on your setup.
  4. PHP 8.5 is stricter. The session_start() changelog says PHP 8.5.0 throws a TypeError when read_and_close is not int-compatible, and the session_write_close() changelog says 8.5.0 emits an E_WARNING for session keys containing a pipe character, which previously failed silently.

The lost-update risk is the same family of problem as duplicate writes in APIs. If concurrent session writes carry business meaning, such as a cart or a multi-step checkout, they probably belong in the database with proper constraints. See our guide to idempotency keys in PHP for the pattern.

How does session locking work in Laravel, Symfony and Redis-backed sessions?

Session locking in frameworks often works the opposite way to native PHP: many handlers do not lock at all, so you get lost writes instead of slow requests. Know which side of that trade-off your stack sits on.

Laravel: no lock unless you call ->block()

The Laravel 12 session docs state that Laravel allows requests using the same session to execute concurrently by default, and that session data loss can occur when concurrent requests to two endpoints both write to the session. The default driver is database. You opt in per route with ->block(), which needs a cache driver with atomic locks, cannot be used with the cookie driver, and defaults to a 10-second lock and a 10-second wait before throwing LockTimeoutException.

// routes/web.php — Laravel 12
use Illuminate\Support\Facades\Route;

// Endpoints that both write to the session: serialise them per session
Route::post('/profile', [ProfileController::class, 'update'])
    ->block($lockSeconds = 10, $waitSeconds = 10);

Route::post('/order', [OrderController::class, 'store'])
    ->block($lockSeconds = 10, $waitSeconds = 10);

// Read-only JSON endpoints: leave them unblocked so they stay parallel
Route::get('/dashboard/stats', [DashboardController::class, 'stats']);

Symfony: depends on the handler

The Symfony session docs warn that RedisSessionHandler does not perform session locking, and that a typical symptom is an “Invalid CSRF token” error when two requests run in parallel and only one stores the token. PdoSessionHandler defaults to LOCK_TRANSACTIONAL row-level locking. That restores PHP-style blocking, so the early-close advice above applies again. Our Laravel vs Symfony comparison covers other framework defaults that differ like this.

Native phpredis handler: locking is off by default

The phpredis README documents redis.session.locking_enabled defaulting to 0, lock_wait_time defaulting to 20,000 microseconds, and lock_retries defaulting to 100. It also says locking is only supported with a single-master setup and may not work with RedisArray or RedisCluster.

; php.ini — native phpredis session handler
session.save_handler = redis
session.save_path    = "tcp://127.0.0.1:6379"

; Locking is OFF by default in phpredis
redis.session.locking_enabled = 1
redis.session.lock_expire     = 60      ; seconds (default: max_execution_time)
redis.session.lock_wait_time  = 50000   ; microseconds between attempts (default 20000)
redis.session.lock_retries    = 2000    ; -1 = infinite (default 100)

Does the same problem happen in Node.js and other stacks?

Yes, but usually as the lost-update half rather than the blocking half. Popular Node.js session middleware does not lock, so parallel requests run freely and can overwrite each other.

The express-session README warns that resave “can also create race conditions where a client makes two parallel requests,” and that changes from one request may be overwritten when the other ends, “even if it made no changes.” It notes that saveUninitialized: false helps with races for clients without a session yet.

// Node.js / Express — express-session has no per-session lock
import session from 'express-session';
import { RedisStore } from 'connect-redis';

app.use(session({
  store: new RedisStore({ client: redisClient }),
  secret: process.env.SESSION_SECRET,
  resave: false,            // only if your store implements touch()
  saveUninitialized: false, // reduces races for clients without a session yet
}));

// Keep frequently-changing state (cart, counters) out of the session blob.
// Write it to its own key or table with an atomic operation instead:
await redisClient.hIncrBy(`cart:${req.session.userId}`, productId, 1);

The portable fix is the same in every stack: keep the session small and mostly read-only, such as user ID, CSRF token and locale. Put fast-changing state in its own storage with atomic operations. The lock-or-lose trade-off is the same one behind cache stampede prevention. You either serialise access per key or design the data so concurrent writers cannot clobber each other.

Session locking defaults by handler (from official documentation)
Stack / handler Locks by default? Failure mode Fix / opt-in Source
PHP native files handler Yes, whole request Parallel requests queue read_and_close, session_write_close() PHP manual
phpredis session.save_handler = redis No (locking_enabled = 0) Lost concurrent writes redis.session.locking_enabled = 1 (single master only) phpredis README
Laravel 12 (default database driver) No Lost concurrent writes ->block() per route, 10 s / 10 s defaults Laravel docs
Symfony RedisSessionHandler No “Invalid CSRF token” under parallel requests Use the php.ini native handler with locking Symfony docs
Symfony PdoSessionHandler Yes (LOCK_TRANSACTIONAL) Parallel requests queue Close early, or LOCK_ADVISORY / LOCK_NONE Symfony docs
Node.js express-session No Overwrites between parallel requests resave: false if store supports touch; move state out of session express-session README

The NexGismo verdict: Don’t “fix” PHP session locking by switching to a lock-free handler. That swaps a visible latency bug for an invisible data-loss bug. I’d keep the lock, hold it for milliseconds instead of seconds with read_and_close and early session_write_close(), and move anything that changes often out of the session entirely.

Frequently Asked Questions

Why is session_start() so slow in PHP?

session_start() is usually slow because another request with the same session ID still holds the session lock. PHP's default files handler locks the session file until that request ends or calls session_write_close(). The waiting request does no work; it simply blocks until the lock is released.

What does session_write_close() do in PHP?

session_write_close() saves the current session data and ends the session for that request, which releases the session lock. Other requests sharing the same session ID can then proceed. After calling it, changes to $_SESSION in the same request are no longer saved unless you call session_start() again.

Does PHP lock sessions stored in Redis?

Not by default. The phpredis session handler ships with redis.session.locking_enabled set to 0, so concurrent requests are not serialised. You can enable it in php.ini, but the phpredis README says locking is only supported on a single-master setup and may not work with RedisArray or RedisCluster.

How do I stop parallel AJAX requests from blocking each other in PHP?

Release the session as early as possible. Use session_start(['read_and_close' => true]) on endpoints that only read session data, and call session_write_close() right after the last session write on endpoints that write. Move slow work such as API calls, report generation or polling loops after the close.

Does Laravel lock the session for concurrent requests?

No. Laravel allows requests that share a session to run concurrently by default, which can lose session data when two endpoints write at the same time. You opt in per route with ->block(), which needs a cache driver that supports atomic locks and cannot be used with the cookie session driver.

What should you change in your PHP app today?

Start with the endpoints your front end calls in parallel: dashboards, autocomplete, polling and SSE streams. Search the codebase for session_start() calls in shared bootstrap files and move them closer to the code that needs session data. Switch read-only endpoints to read_and_close, add session_write_close() after the last write everywhere else, and test the early-expiry and regenerate-after-close cases before release.

If you run Laravel, Symfony with Redis, or Node.js, check the opposite problem: find the endpoints that write to the session concurrently and decide whether they need a lock or a better home for their data. If you have hit a session-locking edge case not covered here, send it our way and we’ll test it for a follow-up.

Sources

Exit mobile version