Or: The race condition that bites every team scaling to Redis
“Users are getting logged out randomly.”
The bug report came in on a Friday afternoon. Our monitoring showed normal traffic, no spikes, no errors. Yet users kept losing their sessions. Some lost flash messages. Others hit “Invalid CSRF token” errors on form submissions.
We were using Redis for sessions. It seemed like the obvious choice for our multi-server setup. What we didn’t know: Redis sessions have a fundamental problem that Symfony’s documentation warns about but doesn’t shout loudly enough.
The Problem: Redis Doesn’t Lock
Permalink to "The Problem: Redis Doesn’t Lock"PHP’s default file-based sessions use file locking. When one request reads a session, other requests wait. This prevents race conditions.
Redis has no such mechanism. Symfony’s RedisSessionHandler reads and writes
without locking.
From the Symfony documentation:
Redis does not perform session locking, so you can face race conditions when accessing sessions. For example, you may see an “Invalid CSRF token” error.
“Can face race conditions” is an understatement. If your app makes concurrent requests that touch the session, you will hit this problem.
How Race Conditions Corrupt Sessions
Permalink to "How Race Conditions Corrupt Sessions"Here’s what happens with concurrent AJAX requests:
Item C is lost!
Both requests read the same session state. Both modify it independently. The last write wins, and the first change is lost.
Real-World Symptoms
Permalink to "Real-World Symptoms"| Symptom | Cause |
|---|---|
| Random logouts | Auth token overwritten by concurrent request |
| Lost flash messages | Flash consumed by one request, re-saved without it by another |
| CSRF token failures | Token regenerated by one request, old token submitted by another |
| Cart items disappearing | Cart state overwritten by concurrent request |
| Inconsistent user preferences | Settings clobbered by parallel page loads |
Why This Hits AJAX-Heavy Apps Hardest
Permalink to "Why This Hits AJAX-Heavy Apps Hardest"Modern SPAs and AJAX-heavy applications make multiple concurrent requests:
// Dashboard loading - 5 concurrent requests
Promise.all([
fetch("/api/notifications"),
fetch("/api/messages"),
fetch("/api/stats"),
fetch("/api/recent-activity"),
fetch("/api/user-profile"),
]);
Each request reads the session at the start and writes it at the end. With file sessions, they’d queue up. With Redis, they all read the same state and overwrite each other.
Diagnosing the Problem
Permalink to "Diagnosing the Problem"1. Check for Concurrent Requests
Permalink to "1. Check for Concurrent Requests"Open your browser’s Network tab. Look for parallel requests to the same application. If you see multiple requests starting simultaneously, you’re at risk.
2. Log Session Writes
Permalink to "2. Log Session Writes"Temporarily add logging to see session overwrites:
// src/EventSubscriber/SessionDebugSubscriber.php
namespace App\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Psr\Log\LoggerInterface;
class SessionDebugSubscriber implements EventSubscriberInterface
{
public function __construct(
private LoggerInterface $logger
) {}
public static function getSubscribedEvents(): array
{
return [KernelEvents::RESPONSE => 'onResponse'];
}
public function onResponse(ResponseEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$request = $event->getRequest();
$session = $request->getSession();
$this->logger->debug('Session write', [
'request_id' => $request->headers->get('X-Request-ID', uniqid()),
'uri' => $request->getRequestUri(),
'session_id' => $session->getId(),
'session_data_keys' => array_keys($session->all()),
]);
}
}
Look for overlapping timestamps with the same session ID—those are your race conditions.
3. Reproduce with Artillery
Permalink to "3. Reproduce with Artillery"# artillery-session-test.yml
config:
target: "https://your-app.com"
phases:
- duration: 10
arrivalRate: 10
cookies:
sessionid: "test-session-123"
scenarios:
- name: "Concurrent session access"
flow:
- get:
url: "/api/endpoint-1"
- get:
url: "/api/endpoint-2"
- get:
url: "/api/endpoint-3"
Run it and check for lost data or CSRF errors.
Solutions
Permalink to "Solutions"Solution 1: Close Sessions Early
Permalink to "Solution 1: Close Sessions Early"If a request only reads session data, close the session immediately:
#[Route('/api/notifications')]
public function notifications(Request $request): JsonResponse
{
$session = $request->getSession();
$userId = $session->get('user_id');
// Close session - no more writes possible, but no lock held
$session->save();
// Now do the slow work
$notifications = $this->notificationService->getForUser($userId);
return $this->json($notifications);
}
This reduces the window for race conditions but doesn’t eliminate them.
Solution 2: Use a Locking Redis Handler
Permalink to "Solution 2: Use a Locking Redis Handler"The snc/redis-bundle provides a session handler with locking:
composer require snc/redis-bundle
# config/packages/snc_redis.yaml
snc_redis:
clients:
default:
type: predis
alias: default
dsn: "%env(REDIS_URL)%"
session:
client: default
locking: true # Enable locking!
spin_lock_wait: 150000 # Microseconds to wait between lock attempts
lock_max_wait: 10 # Seconds before giving up
# config/packages/framework.yaml
framework:
session:
handler_id: snc_redis.session.handler
The handler uses Redis SETNX to implement a spinlock. Requests wait for the
lock before accessing the session.
Trade-off: Locking adds latency. Concurrent requests now queue instead of running in parallel.
Solution 3: Minimize Session Usage
Permalink to "Solution 3: Minimize Session Usage"The best solution is often architectural: don’t store volatile data in sessions.
Before (problematic):
// Every cart change touches the session
$session->set('cart', $cart->toArray());
After (better):
// Cart stored in database, only cart ID in session
$session->set('cart_id', $cart->getId());
$this->cartRepository->save($cart);
Move frequently-changing data to the database. Keep only stable identifiers in the session.
Solution 4: Stateless Where Possible
Permalink to "Solution 4: Stateless Where Possible"For API endpoints, consider JWT or token-based auth that doesn’t need sessions:
# config/packages/security.yaml
security:
firewalls:
api:
pattern: ^/api/
stateless: true
jwt: ~
main:
pattern: ^/
# Keep sessions for web UI
API endpoints become immune to session race conditions.
The Nuclear Option: Database Sessions
Permalink to "The Nuclear Option: Database Sessions"If locking is critical and you can accept slower performance, database sessions solve the problem:
framework:
session:
handler_id: Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler
services:
Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler:
arguments:
- '%env(DATABASE_URL)%'
- lock_mode: !php/const Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler::LOCK_ADVISORY
Database sessions with LOCK_ADVISORY provide proper locking without the
complexity of implementing it yourself in Redis.
Comparison of Approaches
Permalink to "Comparison of Approaches"| Approach | Complexity | Performance | Solves Problem? |
|---|---|---|---|
| Close sessions early | Low | Best | Partially |
| snc/redis-bundle locking | Medium | Good | Yes |
| Minimize session usage | Medium | Best | Yes (architectural) |
| Stateless APIs | Medium | Best | Yes (for APIs) |
| Database sessions | Low | Slower | Yes |
My Recommendation
Permalink to "My Recommendation"For most applications:
- Use
snc/redis-bundlewith locking enabled for the session handler - Move volatile data out of sessions (carts, preferences → database)
- Make API endpoints stateless where possible
- Close sessions early in read-only endpoints
The performance hit from locking is usually acceptable. The alternative—random data loss—is not.
Detecting if You’re Affected
Permalink to "Detecting if You’re Affected"Quick checklist:
- [ ] Using Redis or Memcached for sessions?
- [ ] Application makes concurrent AJAX requests?
- [ ] Users report random logouts or lost data?
- [ ] CSRF token errors appear sporadically?
- [ ] Flash messages sometimes disappear?
If you checked multiple boxes, you likely have this problem.
Redis is fast and scales well—but its lack of session locking creates race conditions that corrupt data. The fix: use a locking session handler, minimize session usage, or switch to database sessions for critical applications.
