PHP AJAX Requests Queuing? Check the Session Lock
A tiny PHP AJAX request can wait behind a slow export before its query starts. Find the session lock, close it earlier, and keep later writes correct.
Your dashboard starts an export, then its tiny notification request waits behind it. The notification query isn't expensive. It may not have reached the query yet. Somewhere, a session file is running a very exclusive club.
Check the PHP session boundary first: PHP's default file-based session handler locks the file for a particular session while that session is open. Concurrent requests carrying the same session ID can therefore wait inside session_start(). Finish the required session changes and call session_write_close() before slow unrelated work. For a genuinely read-only session, session_start(['read_and_close' => true]) closes it after reading. Neither option makes an already-held lock disappear before the read can complete.
This is an AI-prepared, documentation-based explainer, not a personally reproduced incident. The dashboard scenario, code and verification steps are illustrative and have not been executed. See Debugly's editorial policy.
The request that hasn't reached your handler
Let's trace an illustrative application with an export endpoint and a notification-count endpoint. Both run a shared bootstrap that starts a PHP session. The export reads the signed-in user's ID, prepares a report and waits for an external service. The count endpoint needs that same user's ID and performs a small query.
If the export keeps its session open throughout that work, the second request can be admitted by the web server and still wait for the session file. A timer around the count query tells you almost nothing about this wait: the request hasn't got there. A browser waterfall shows a slow response, while the database insists everything is fine. Both can be telling the truth.
This behavior is explicitly documented in PHP's session basics. The important scope is the same session file, not all PHP requests everywhere. Requests from independent sessions don't contend for that particular lock. Different users can still compete for other resources, of course; a session lock doesn't repeal queueing theory.
That scope gives you a better diagnostic than immediately adding workers. More workers may help a saturated pool, but they don't let two workers hold the same exclusive session-file lock simultaneously.
Put a timer on both sides of session startup
Instrument the code that actually starts the session, before a framework or bootstrap has already done it. The following PHP 8 example measures elapsed time around startup. It does not measure a pure lock-wait metric: file access, reading, decoding and other startup work can contribute too.
<?php
// Illustrative diagnostic; run before output and before session startup.
$beforeSession = hrtime(true);
if (!session_start()) {
throw new RuntimeException('Session startup failed');
}
$afterSession = hrtime(true);
error_log(sprintf(
'session_start_ms=%.3f handler=%s',
($afterSession - $beforeSession) / 1_000_000,
session_module_name()
));
// Continue normal application handling and close at its intended boundary.
Correlate that duration with a request ID and your normal endpoint timings. Don't put raw session IDs, cookies or session contents in logs. If automatic session startup is enabled, or middleware opens the session before this code, move the measurement to the real boundary; a second session_start() isn't a substitute.
Now compare two controlled cases with equivalent permissions and workload: simultaneous requests sharing one test session, and requests using independent test sessions. Check the actual cookie sent on each request. Two tabs normally sharing a cookie do not give you two independent sessions.
| Observation | What it suggests | Next check |
|---|---|---|
| Same-session requests wait at startup; independent sessions overlap. | A per-session serialization boundary is plausible. | Identify the save handler and how long the other request holds the session open. |
| Both cases wait before application execution begins. | The session measurement hasn't explained the delay. | Inspect server admission, PHP worker availability and upstream queues. |
| Session startup is quick; a later span is slow. | The bottleneck is elsewhere in that request. | Measure the database, remote call or application lock at that later boundary. |
These are hypotheses to separate, not invented results from this article. If the lock explanation doesn't fit the measurements, leave it behind. A plausible cause is still allowed to be the wrong cause.
Make the session lifetime smaller than the request lifetime
For an endpoint that only needs an existing session identity, the useful shape is: read the session, copy the needed value, close the session, then do the work. PHP's session_start reference documents the read_and_close option for this purpose.
<?php
// Illustrative plain-PHP endpoint. Assumes no session is already active.
if (!session_start(['read_and_close' => true])) {
throw new RuntimeException('Session startup failed');
}
$userId = $_SESSION['user_id'] ?? null;
if ($userId === null) {
http_response_code(401);
exit;
}
// The session is closed here. Use $userId for the endpoint's work.
// Still apply your normal authorization checks to the requested resource.
This is a snapshot of identity at that point, not a live reference to future session changes. It doesn't skip authentication or authorize every resource owned by the application. And read-only must really mean read-only: middleware that refreshes a session timestamp, sets a flash message or changes a token has a write requirement that needs an explicit place to live.
When a session change is required, make it while the session is active, then close it before the unrelated slow operation:
<?php
// Illustrative plain-PHP boundary; integrate validation/CSRF checks
// into the actual application session lifecycle.
if (!session_start()) {
throw new RuntimeException('Session startup failed');
}
$userId = $_SESSION['user_id'] ?? null;
if ($userId === null) {
session_write_close();
http_response_code(401);
exit;
}
$_SESSION['export_requested_at'] = time();
session_write_close();
// Perform or enqueue the export using the copied $userId.
// Do not assign more session state here and expect it to be persisted.
The close function ends the active session; it doesn't end the HTTP request. That's the separation we need. An export may still occupy a PHP worker after closing, so putting substantial work on a job queue can address a different capacity problem. Closing the session and freeing a worker are two different events.
There's also a small but useful trap in the current reference: session_write_close() can return true even if the save handler failed to write. Don't turn that boolean into proof that an important update is durable. Monitor session-write warnings and storage failures, and keep critical export/job state in an appropriately reliable application store rather than treating a session timestamp as a job ledger.
The quiet regression: changing an inactive session
After closing, the in-memory $_SESSION array can still look perfectly ordinary. That makes this sequence particularly misleading:
session_write_close();
$_SESSION['export_finished'] = true; // Inactive session: don't expect a save.
PHP's session management guidance warns that changes made while a session is inactive aren't detected. Seeing the value later in the same request isn't evidence that the next request will read it from storage.
If work genuinely needs a later session update, design a short reopen/read/modify/close section through the application's session lifecycle. Don't restore the whole old snapshot after reopening: another request may have changed it while you were working. Better still, ask whether completion status belongs in a job record that clients can poll without rewriting a shared session blob.
This is the same design question behind reviewing lock scope under contention: which operations require the protected state to remain unchanged? Keep those together, and move independent waiting outside. Removing consistency protection entirely is a different change with different failure modes.
Keep the diagnosis tied to the handler you actually use
The file-lock explanation applies to the default files save handler. A Redis, database or custom session handler has its own concurrency and locking behavior. Replacing a file with a network service doesn't automatically give the application safe parallel session updates. Check the configured handler, extension settings and framework behavior rather than importing assumptions from another deployment.
In a framework, a session component may own startup, saving and close timing. Use its supported API and verify that later middleware doesn't reopen the session around the expensive work. Don't scatter native PHP session calls through an existing lifecycle and hope they negotiate custody.
For a local verification, use a server setup capable of processing concurrent requests. PHP's built-in development server is single-threaded by default, so it can serialize requests even after you fix the session boundary. A one-at-a-time test server is an impressively convincing imitation of a one-at-a-time application.
Verify the boundary, then keep it visible
- Record the session handler and session-start location. Confirm the controlled requests actually share a session and can run concurrently at the server.
- Measure before startup, after startup, at session close and around the slow operation. Look for time moving out of the startup wait when the competing request closes earlier.
- Repeat the same-session and independent-session cases. Check that the notification request can progress while the export's unrelated work continues; don't demand identical timings or a promised speedup.
- Read intended session changes from a subsequent request. Verify that authentication, flash messages, CSRF behavior and session expiry still follow the application's policy. Also test simultaneous state updates so that the performance fix hasn't introduced lost writes.
- If delay remains, follow the span where it remains. For Linux file-handler investigations, system-call timing can provide supporting evidence; it isn't necessary to attach a tracer to start with.
Before adding concurrency to a PHP dashboard, write down the last line that must hold the session open. Put the close boundary there and make later session writes conspicuous in review. Otherwise “we sent the requests in parallel” just means the queue has a JavaScript frontend.
Documentation checked 5 November 2026. More PHP explainers. Author and site background: About Debugly.