Redis Session Locking Pitfalls in Symfony: Why Your Users Get Random Logouts

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:

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:

  1. Use snc/redis-bundle with locking enabled for the session handler
  2. Move volatile data out of sessions (carts, preferences → database)
  3. Make API endpoints stateless where possible
  4. 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.