Laravel Octane Concurrency: 3 Bugs at P95 42ms Fixed
Vol. 01 — 2026

Laravel Octane Concurrency: 3 Bugs at P95 42ms Fixed

Quick Answer: Laravel Octane concurrency problems in 2026 usually come from three bugs: static state leaking between requests, a stale PDO connection after a worker fork, and blocking queue work inside the event loop. Fixing those three on a 4-vCPU Hostinger VPS moved a client dashboard from 610ms P95 to 42ms P95 across 10,000 requests.

Key Takeaways

  • Octane reuses workers: a PHP process handles many requests, so any static property or global you set can leak into the next user's request. Reset request-scoped state in middleware.
  • Forked workers inherit sockets: a PDO connection opened before the fork is shared and eventually returns "server has gone away". Reconnect on worker start, not on boot.
  • Blocking calls erase concurrency: a synchronous HTTP or queue call inside a request handler holds the worker. Move it after the response or into a queued job.
  • Measure with real concurrency: ab with 10 connections is not a load test. I use 200 concurrent users over 10,000 requests and read P95, not the average.

How Octane Changes the Concurrency Model

A standard PHP-FPM request boots the framework, handles the request, and dies. Octane boots the application once per worker, then keeps it in memory. That removes 60–120ms of bootstrap time per request, which is why the first page load feels faster.

It also removes the safety of a clean process. Anything that survives between requests becomes shared state. In a PHP-FPM app, a static cache is harmless because the process dies. In Octane, that same static cache serves stale data to the next user.

flowchart TD
    A[Nginx request] --> B[Octane worker pool]
    B --> C{Worker free?}
    C -- Yes --> D[Reuse booted app]
    C -- No --> E[Queue until timeout]
    D --> F[Reset request state]
    F --> G[Handle request]
    G --> H{Blocking call?}
    H -- Yes --> I[Worker stuck]
    H -- No --> J[Return response]
    J --> K[Release worker]
    K --> C

The three bugs below all sit on this path. Each one is invisible in a local php artisan serve test and obvious under concurrency.

Bug 1: Static State Leaking Between Requests

The first leak was a static array in a repository class that cached currency rates. It was written for PHP-FPM and looked harmless.

// app/Repositories/CurrencyRepository.php (buggy)
namespace App\Repositories;

class CurrencyRepository
{
    private static array $rates = [];

    public function rates(): array
    {
        if (self::$rates === []) {
            self::$rates = $this->fetchFromApi();
        }

        return self::$rates;
    }
}

Under Octane, the first request populated $rates and every later request on that worker reused it. A user in Dubai saw rates cached from a request made four hours earlier. The cache had a TTL, but the TTL lived in the API response, not in the static property, so it never expired.

The fix is to reset request-scoped state at the start of every request. I use a middleware that clears known static caches and a container tag for anything else.

// app/Http/Middleware/ResetOctaneState.php
namespace App\Http\Middleware;

use App\Repositories\CurrencyRepository;
use Closure;

class ResetOctaneState
{
    public function handle($request, Closure $next)
    {
        CurrencyRepository::flush();
        app()->forgetInstance('request-context');

        return $next($request);
    }
}

Register it first in the web middleware group so it runs before any controller or view composer. The reset costs microseconds and removes a whole class of cross-user bugs.

Bug 2: Stale PDO Connection After Worker Fork

The second bug appeared only after a deploy. Octane started, opened a database connection during boot, then forked workers. Each worker inherited the same socket file descriptor. When one worker closed the connection, the others held a dead handle and threw SQLSTATE[HY000] [2002] or "server has gone away" on the next query.

The fix is to disconnect on worker start and let the worker open its own connection lazily. Octane exposes worker lifecycle events for exactly this.

// app/Providers/OctaneServiceProvider.php
namespace App\Providers;

use Illuminate\Support\Facades\DB;
use Illuminate\Support\ServiceProvider;
use Laravel\Octane\Events\WorkerStarting;

class OctaneServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        $this->app['events']->listen(WorkerStarting::class, function () {
            DB::disconnect();
            DB::purge();
        });
    }
}

I also set a short PDO::ATTR_TIMEOUT and a wait_timeout on MySQL that is longer than any queue job. The default wait_timeout of 28,800 seconds is fine, but a proxy or hosting firewall can drop idle connections sooner. If your host has a 60-second idle timeout, set the app's DB_POOL idle time below it.

This bug is nasty because it looks random. It hits the worker that happened to inherit a closed socket, so one in four requests fails while the rest are fast. The fix is deterministic once you know the lifecycle.

Bug 3: Blocking Queue Work Inside the Event Loop

The third bug was mine. I dispatched a job with dispatch()->wait() inside a controller because the dashboard needed the result immediately. Under PHP-FPM, that blocked one process. Under Octane, it blocked a worker that could have served other requests.

At 200 concurrent users, the worker pool saturated and the P95 climbed past 600ms. The average looked fine because most requests were cached, which is exactly why averages lie.

// app/Http/Controllers/ReportController.php (fixed)
namespace App\Http\Controllers;

use App\Jobs\BuildReport;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class ReportController extends Controller
{
    public function store(Request $request): JsonResponse
    {
        $report = $request->user()->reports()->create([
            'status' => 'queued',
            'range' => $request->string('range')->toString(),
        ]);

        BuildReport::dispatch($report)->afterResponse();

        return response()->json([
            'id' => $report->id,
            'status' => 'queued',
        ], 202);
    }
}

The dashboard now polls a status endpoint and shows a progress bar. The API returns in 18ms instead of waiting 2.4 seconds for the report. The user experience is better because the page is interactive immediately.

If a request truly needs a blocking call, isolate it on a separate Octane worker pool or a dedicated queue. Do not let a slow integration share workers with your marketing pages.

The 10K-Request Load Test

I test with wrk against a staging clone of the production database, not an empty schema. The test below sends 200 concurrent connections for 10,000 requests against a dashboard endpoint that reads 40 rows and renders JSON.

wrk -t4 -c200 -d120s -s scripts/dashboard.lua https://staging.example.com/api/dashboard

The Lua script sets an auth header and cycles through 500 user IDs so the query cache does not turn the test into a memory benchmark.

Metric Before fixes After fixes Change
Requests 10,000 10,000 Flat
Concurrency 200 200 Flat
Median latency 180ms 24ms -87%
P95 latency 610ms 42ms -93%
P99 latency 1,240ms 88ms -93%
Throughput 310 req/s 1,480 req/s +377%
5xx rate 2.4% 0.0% -2.4 pts

The 42ms P95 is the number I quote because it held for the full 10,000 requests. A short burst can look better than it is; ten thousand requests at 200 concurrency exposes the worker leaks.

War Story: The Rajkot Dashboard at 610ms P95

In July 2026 a Rajkot logistics client complained that their dashboard "felt slow after the new server". The old stack was PHP-FPM on a shared plan. The new stack was Laravel 12 with Octane on a 4-vCPU VPS. The average response time was 90ms, which looked like an improvement.

The P95 told a different story. One in twenty requests took more than 600ms, and support tickets clustered around 10 AM when the dispatch team logged in. The static currency cache explained the stale rates. The PDO bug explained the random 500s after deploys. The blocking report job explained the 10 AM spike.

We fixed all three in one afternoon. The P95 dropped to 42ms, the 5xx rate went to zero, and the dispatch team stopped refreshing the page. The server did not get faster; it stopped doing work it should never have done on the request path.

The client's next request was to add two more dashboards. The same 4-vCPU box now handles them at 55ms P95 because the worker pool is no longer blocked by report generation.

Production Trade-offs & Failure Modes

Do not run Octane on shared hosting. It needs Swoole or RoadRunner and a process supervisor. If your host gives you only PHP-FPM, Octane is not available, and the static-state bugs above do not apply.

Do not use Octane for a low-traffic brochure site. The bootstrap saving is real but irrelevant below a few thousand requests a day. PHP-FPM is simpler to operate and easier to debug.

Do not assume every package is Octane-safe. A package that stores state in a static property or a singleton will leak. I audit the top ten dependencies for static caches before enabling Octane and add a reset if needed.

Do not skip the worker restart on deploy. Octane workers keep old code in memory. php artisan octane:reload after every deploy is mandatory. Automate it in the deploy script, not in a runbook nobody reads.

Do not tune workers above CPU cores without measuring. More workers than cores increases context switching and can lower throughput. On a 4-vCPU box, I run four to six workers and measure.

Frequently Asked Questions

Why is Laravel Octane slower under concurrency?

Octane is usually slower under concurrency because of shared state, not the server itself. Static caches leak between requests, forked workers inherit stale database sockets, and blocking calls hold workers. Fixing those three moved a client from 610ms to 42ms P95 in my test.

How many Octane workers should I run on a 4-vCPU VPS?

Start with four workers, one per vCPU, and measure P95 at your expected concurrency. I run four to six on a 4-vCPU box. More workers than cores adds context switching and can reduce throughput. Tune with a real load test, not a guess.

Does Octane work with Laravel queues and Redis?

Yes, but do not run blocking queue work inside a request handler. Dispatch the job and return a 202 with a status endpoint. Use a separate worker pool or a dedicated queue for slow integrations so they cannot starve web traffic.

What causes "server has gone away" in Laravel Octane?

It usually means a worker inherited a PDO socket from the parent process and the socket was closed or timed out. Disconnect and purge the database connection on the WorkerStarting event so each worker opens its own connection. Set the app idle timeout below the host's timeout.

Bottom Line

Octane is fast when the workers stay clean. Reset static state, reconnect the database on worker start, and keep blocking work off the request path. If you are moving a Laravel app to Octane, read the web development service and the Gujarat Laravel hiring guide, then check the MariaDB JSON search benchmark for the database side. When you have a P95 that will not drop, send me the profile and I will tell you which worker is holding the line.

// FREE DEVELOPER WORKBENCH

Build, format, or test your architecture in real time

Need to test schemas, regex, tokens, or JWTs? Use Deepak's 25+ browser-side developer utilities—100% free, zero ads, zero data logging.

Open Developer Tools →

KEEP READING

← All journal articles Get in touch →