Originally published at hafiz.dev
I put a million expired sessions into the sessions table of a fresh Laravel 13.39 app and set the session lottery to always win. The next page view took 5.2 seconds. Every other page view took 6 milliseconds.
That's the lottery. By default, about 2 in every 100 requests that start a session also clean up expired sessions, and they do it before your route runs. Laravel 13.39 added session:garbage-collect so a scheduled job can do that work instead. I measured both on the default setup, the database session driver on SQLite. The command fixed the unlucky-visitor problem and created a new one. All output below is real.
How the lottery works
The StartSession middleware starts the session, then rolls the dice, then calls your route:
$request->setLaravelSession(
$this->startSession($request, $session)
);
$this->collectGarbage($session);
$response = $next($request);
collectGarbage() checks the lottery value in config/session.php, which ships as [2, 100]. On a win, the database handler runs one query:
return $this->getQuery()->where('last_activity', '<=', $this->currentTime() - $lifetime)->delete();
The skeleton's migration indexes last_activity, so finding the expired rows is cheap. Deleting them is not, once there are enough of them.
Where a million expired sessions come from
Any request to a web route without a session cookie gets a new session, and the database driver writes it as a row. I sent 50 requests with plain curl, then 50 more that kept their cookies:
rows before: 0
after 50 requests without cookies: 50
after 50 more with one cookie jar: 51
Crawlers, uptime monitors and most scripts don't keep cookies. Each hit is a row that sits there until it expires and something deletes it. With steady traffic and the lottery on, cleanup runs often enough that each win only has a few rows to delete. A backlog builds when cleanup stops, for example when the lottery is set to [0, 100] with nothing scheduled in its place, or on an old table that was never cleaned.
I seeded a backlog directly in SQLite, with rows shaped like real ones (same payload size, a crawler user agent, last_activity three days back):
seeded in 2.4s
1000051|1000000
db size: 404M
That's 1,000,000 expired rows and 51 live ones, in a 404 MB database file.
What the winning request pays
With the lottery off, page views took 6 to 10 milliseconds. Then I set it to [100, 100], so the next request would win:
lottery [0,100], 5 requests: 0.010s 0.006s 0.006s 0.006s 0.006s
lottery [100,100], request that runs the cleanup: 5.199s
rows after: 57
next requests (nothing left to delete): 0.007s 0.006s 0.006s
I reseeded and ran the winning request twice more, at 6.26 and 5.40 seconds. Then I tried smaller backlogs, each seeded fresh:
| Expired rows | Lottery-winning request |
|---|---|
| 1,000 | 8 ms |
| 10,000 | 27 ms |
| 100,000 | 223 ms |
| 1,000,000 | 5.2 to 6.3 s |
Up to a few thousand rows, nobody notices. At a million, one visitor stares at a blank page for five seconds and never knows why. If you've seen a random slow request in your logs that you couldn't reproduce, a lottery win is a candidate.
What session:garbage-collect changes
The new command runs the same handler method on your schedule instead of inside a request:
// routes/console.php
use Illuminate\Support\Facades\Schedule;
Schedule::command('session:garbage-collect')->hourly();
Then turn the lottery off in config/session.php, so requests stop doing it as well:
'lottery' => [0, 100],
On the same million-row backlog, the command did the work in about the same time, with no visitor attached to it:
$ php artisan session:garbage-collect
INFO Session garbage collector ran successfully.
real 5.84
It doesn't report how many rows it deleted. Count the table before and after if you want to know.
On SQLite, the scheduler doesn't save your visitors
I ran the command again on the full backlog and sent a request every 200 milliseconds while it worked:
request 1: 200 0.009059s
request 2: 200 5.231497s
request 3: 200 5.032121s
request 4: 200 4.831341s
request 5: 200 4.627754s
Request 1 got in before the delete started. The other four waited for it to finish. Every web request writes its session at the end, and SQLite allows one writer at a time, so a five-second DELETE holds up every page view that arrives during it. Moving the cleanup to the scheduler turned one unlucky visitor into everyone who showed up in those five seconds.
That only bites with a big backlog. Run the command often enough that each run deletes a few thousand rows, and each one takes milliseconds, as the table above shows. The problem is the first run on a table that already has a backlog.
I only measured SQLite here, which is what a new Laravel app uses out of the box. MySQL and PostgreSQL don't lock the whole database for a delete, so check your own driver before assuming either result.
Clear an existing backlog in chunks
For the first cleanup, delete in batches and pause between them, so other writes get a turn:
// routes/console.php
use Illuminate\Support\Facades\DB;
Artisan::command('sessions:prune-chunked {--chunk=5000}', function () {
$cutoff = now()->subMinutes(config('session.lifetime'))->getTimestamp();
$deleted = 0;
do {
$batch = DB::table('sessions')
->where('last_activity', '<=', $cutoff)
->limit((int) $this->option('chunk'))
->delete();
$deleted += $batch;
usleep(50_000);
} while ($batch > 0);
$this->info("Deleted {$deleted} expired sessions.");
});
Same million rows, same requests arriving every half second while it ran:
request 1: 200 0.017038s
request 2: 200 0.028787s
request 3: 200 0.027196s
request 4: 200 0.026492s
request 5: 200 0.029231s
request 6: 200 0.030707s
request 7: 200 0.028865s
request 8: 200 0.027779s
Deleted 1000000 expired sessions.
real 17.41
It took 17 seconds instead of 6, and no request went over 31 milliseconds.
Run it once, then let session:garbage-collect keep the table small from then on. If chunked deletes are new to you, the same idea shows up in my query optimization post, and the indexing guide covers why the last_activity index matters for the where half of it. Six Eloquent patterns that quietly skip an index shows how that half can go wrong.
The file driver pays even when nothing has expired
The file driver cleans up with Symfony Finder. It walks every file in storage/framework/sessions and checks each modification time. I created 100,000 session files and ran the command twice, first with all of them fresh, then with all of them three days old:
$ SESSION_DRIVER=file php artisan session:garbage-collect # 100,000 fresh files
real 0.98
files left: 100000
$ SESSION_DRIVER=file php artisan session:garbage-collect # 100,000 expired files
real 12.67
files left: 0
Almost a second to delete nothing. On the file driver, every lottery win pays for that scan, however few sessions have actually expired. Twelve seconds to delete them all. That's the strongest case for the new command on the file driver, because the scan cost never goes away.
What I'd set up
For the database and file drivers:
// config/session.php
'lottery' => [0, 100],
// routes/console.php
Schedule::command('session:garbage-collect')->everyFifteenMinutes();
Every fifteen minutes keeps each run small on a busy site. If you run several servers against one database, add ->onOneServer(), which needs a cache store that supports atomic locks. With the file driver, each server has its own sessions folder, so the command runs on each one. If the table already has a backlog, run the chunked version once first.
Or skip the question. The cache-backed drivers, Redis among them, write each session with a TTL, and their gc() method returns 0. Expired sessions disappear on their own. If you already run Redis for queues or cache, it's a one-line .env change.
FAQ
What does Laravel's session lottery do?
On each request that starts a session, Laravel rolls the lottery odds from config/session.php, [2, 100] by default. On a win, it deletes expired sessions before your route runs, so that request waits for the delete.
How do I turn off the session lottery?
Set 'lottery' => [0, 100] in config/session.php. Then schedule session:garbage-collect, which Laravel 13.39 added, or expired sessions will pile up forever with the database and file drivers.
Does session:garbage-collect work with Redis sessions?
It runs, but it has nothing to do. The cache-backed handlers store sessions with a TTL, so they expire on their own, and their gc() method returns 0.
Why is my sessions table so big?
Every request without a session cookie creates a new row, and crawlers, uptime checks and scripts rarely send cookies. Rows stay until cleanup deletes them, so if the lottery is off and nothing is scheduled, the table only grows.
Is the first run of session:garbage-collect safe on a big table?
On SQLite with a big backlog, not during traffic. A single delete of a million rows held up every request for about five seconds in my test. Clear the backlog in chunks first, then let the scheduled command keep up.
Conclusion
On SQLite, moving the cleanup to the scheduler is only half the fix. Check how many expired rows you have before the first scheduled run. If the answer is in the hundreds of thousands, clear them in chunks once, then let a small, frequent session:garbage-collect keep it from happening again.
Top comments (0)