skip to content

Long-Running Workers

FrankenPHP worker mode, RoadRunner and Swoole boot the app once and serve many requests from memory. Interviewers probe state leaking between requests and when share-nothing is simpler.

part ofPHPoverview, primer and where to startread it →
on this pageshow

explore

questions

5

After moving a PHP API to worker mode, some responses show another user's data; what state is leaking between requests, and how do you find and fix it?

level: seniorimportance: must knowfreq 35%

answer

  1. the previous request's user
  2. static property or memoised singleton
  3. static variable inside a function
  4. reset per request, or never store it
  5. two requests, two users, one worker

basics

~20 s

Request data, typically the authenticated user, was stored in something that outlives the request: a static property, a static function variable or a memoised singleton. The next request on that worker reuses it. Make such state request-scoped or reset it after each request.

solid answer

~40 s

In worker mode one PHP process serves many requests, so any request-specific value kept in long-lived memory leaks. The classic case: a `CurrentUser` service or helper resolves the user once and memoises it in a static property, a `static` variable inside a function, or a field of a shared singleton; the next request on that worker, from a different user, gets the cached user. Finding it: reproduce with one worker and two sequential requests from different users, then audit statics, `??=` memoisation, singletons holding per-request data and closures that captured the request. Fixing it: pass request data explicitly or keep it in request-scoped services, and give every stateful service a reset method that the worker loop calls in a `finally` after each request. Frameworks with worker-mode support offer reset hooks for exactly this.

code

php · 13 lines
php
<?php
declare(strict_types=1);

// Leaks in worker mode: set by the first request, reused by all later ones.
final class CurrentUser
{
    private static ?User $user = null;

    public static function get(Request $request, UserRepository $users): User
    {
        return self::$user ??= $users->byToken($request->bearerToken());
    }
}

go deeper

for a junior

Recall that in worker mode a value stored in a static property or singleton survives into the next request, which may belong to another user.

for a middle

Explain where request data hides (statics, static variables, singleton fields, captured closures) and why resetting in finally matters.

for a senior

Reproduce with one worker and two users, turn it into a regression test, and make services stateless or resettable across the code base.

for a principal

Set the rule that governs every future service: explicit lifetimes, a reset contract and an analysis rule, so worker safety does not rest on reviewers' memory.

## Why the bug appears only after the move Under PHP-FPM each request starts with empty memory, so code can cache anything for "the rest of the request" without thinking about the next one. In **worker mode** (FrankenPHP, RoadRunner, Swoole) one PHP worker handles request after request and the engine does not wipe its memory in between. Code that was correct for years suddenly shares data across users. The reported symptom, users occasionally seeing someone else's name, cart or permissions, has a specific shape: it only happens when two users hit **the same worker** one after the other, which is why it looks random and disappears when you debug with a single request. ## Where the logged-in user hides - **Static properties**: `self::$user ??= $this->loadUser()` memoises on the first request and returns the same user forever. - **`static` variables in functions or methods**: `static $user = null;` survives across calls, and across requests in a worker. - **Shared singleton services**: a container service built once at boot that stores `$this->user` during a request. - **Closures that captured the request**: an event listener or callback registered during a request, holding `$request` or the user, and kept in a long-lived registry. - **Caches keyed without the user**: an in-memory array cache of "my permissions" or "my dashboard" keyed by route only. ## Finding it 1. **Reproduce deterministically**: run the runtime with a single worker, send a request as user A, then as user B, and compare. With one worker the leak becomes reliable. 2. **Write that as a test**: boot the application once, handle two requests with different credentials through the same kernel instance, and assert that the second response belongs to B. 3. **Audit the code** for `static` properties and variables, `??=` memoisation on long-lived objects, and services that store request data in fields. 4. **Let static analysis help**: a rule forbidding writes to static properties outside boot code catches many cases. ## Fixing it | Pattern | Fix | |---|---| | User memoised in a static property | inject a request-scoped value or pass the user explicitly | | Singleton service storing request data | make it stateless, or give it a `reset()` called after each request | | Listener registered per request | register once at boot, or remove it after the request | | In-memory cache of per-user data | key by user, or keep it only for the request | The safest design keeps long-lived services **stateless**: they receive the request or the user as arguments and store nothing about them. Where state is unavoidable, the worker loop must reset it **in a `finally` block**, so an exception thrown by one request cannot skip the cleanup and leak state into the next. Frameworks that support worker mode ship reset mechanisms for their own services; your own services must opt in. ## Why session settings are not the fix The session cookie and the session data are usually correct for every request; the leak happens **after** authentication, when the resolved user object is cached in process memory. Changing session storage, lifetimes or cookie flags therefore changes nothing. The fix lives in how the application stores the user once it has been resolved, which is why the audit targets statics and long-lived services, not the session layer. ## Beyond the user object The same audit catches other leaks with the same cause: an open database transaction left behind by a failed request, an ORM's identity map still holding entities loaded for the previous user, a logger's buffered context carrying the last request ID, or a feature-flag service cached for one tenant. Each is request data living in process memory. ## The principle to state in the interview In worker mode **lifetime is explicit**: anything you want to live for one request must either be created per request or reset after it. Everything else lives as long as the worker, and the worker serves many users.

  • Why must the reset run in a finally block rather than after handle() returns?
    If a request throws, code placed after `handle()` never runs, so the failed request's user or open transaction stays in memory and the next request on that worker inherits it. A `finally` block runs on both the normal and the exception path, so every request is followed by a reset.
  • Is a static property always a leak in worker mode?
    No. A static holding data that is the same for every request, such as compiled configuration or a lookup table built at boot, is exactly the kind of state worker mode is meant to reuse. It becomes a leak when it holds something that depends on the request: the user, the tenant, the locale or request-derived permissions.
  • How would you stop this class of bug from coming back?
    Add a regression test that handles two requests as different users through one booted kernel, forbid writes to static properties outside boot code with a static-analysis rule, and require every stateful service to implement a reset contract that the worker loop calls.

saying these in an interview costs you the question

  • Worker-mode runtimes isolate statics per request automatically
  • Only global variables can leak, not static properties
  • Restarting workers every hour fixes the cross-user leak
  • Resetting services after handle() returns is enough
  • The bug is a session problem, so session settings fix it
open as a page

In PHP, what changes when an application runs in worker mode under FrankenPHP, RoadRunner or Swoole instead of one fresh request per script under PHP-FPM?

level: middleimportance: should knowfreq 32%

basics

~20 s

Worker mode boots the application once per worker and then loops, handling many requests in the same PHP process. Bootstrap cost disappears from each request, but the engine's end-of-request cleanup no longer runs between them, so statics, singletons and connections carry over.

open as a page

In a PHP worker-mode runtime, why do exit(), register_shutdown_function(), ini_set() and setlocale() behave differently than they do under PHP-FPM?

level: seniorimportance: should knowfreq 18%

basics

~20 s

These built-ins are tied to the end of a PHP request, and in worker mode the PHP request lasts the whole worker lifetime. exit ends the worker, shutdown functions run only when it exits, and ini_set() or setlocale() changes persist into later requests.

open as a page

A PHP service in worker mode grows memory with every request until workers die at memory_limit; how do you find the growth and contain it with worker recycling?

level: seniorimportance: should knowfreq 24%

basics

~20 s

Log memory_get_usage() after each request: a plateau is warm caches, a steady climb is a leak, usually data appended to long-lived arrays. Fix the source, and recycle workers after N requests or above a memory threshold so growth never reaches memory_limit mid-request.

open as a page

Would you move a mature PHP-FPM application to a worker-mode runtime such as FrankenPHP, RoadRunner or Swoole, and how would you decide and de-risk the rollout?

level: principalimportance: nice to knowfreq 14%

basics

~20 s

Only when bootstrap is measurably a large share of request time and the code can be made reset-safe. Audit statics, singletons, exit and global settings, add two-user tests, then canary with conservative recycling and keep PHP-FPM as the fallback.

open as a page