- Book: PHP to TypeScript
- Also by me: The TypeScript Library — the 5-book collection
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Picture a Laravel team rewriting a service in Node. They use TypeScript, port the domain model across, register UserService as a singleton the way they did in Laravel, and ship. Tests pass. Staging green.
Two days later a support ticket lands. A user opens their account page and sees somebody else's name. The tenant is wrong. The organization is wrong. The data is real, the request is real, and the only variable is which request landed on the same Node process first.
The cause is one line. UserService was instantiated once, stored in a module-level container, and asked to remember the current user. In PHP that worked because the process was thrown away at the end of every request. In Node the process never goes away.
This post is about why that happens, what PHP-FPM was doing for you, and the patterns that get the isolation back without giving up the long-running process you came to Node for.
The PHP-FPM model: the universe ends at </php>
A typical PHP-FPM pool runs with pm.max_children somewhere between 16 and 64. The 32 in the title is illustrative (your pm.conf says what yours is). The point is that PHP-FPM has a fixed-size pool of worker processes and each request gets handed to one.
Here is what happens inside one of those workers, every request:
- FPM picks an idle worker.
- The worker parses the request and executes your script.
- Globals reset.
$_SERVER,$_GET, your DI container, your singletons, your static caches — all back to their declared state. - Your code runs.
- The script ends.
register_shutdown_functioncallbacks fire, connections close, memory is freed. The worker either dies and respawns (pm.max_requestsreached) or recycles its userland state for the next request.
You did not write that lifecycle. You inherited it. Every PHP request starts from a clean room.
<?php
// app.php — runs from a fresh process state every request
class UserService
{
private ?User $currentUser = null;
public function setCurrent(User $user): void
{
$this->currentUser = $user;
}
public function current(): ?User
{
return $this->currentUser;
}
}
$container = new Container();
$container->singleton(UserService::class);
$svc = $container->make(UserService::class);
$svc->setCurrent(loadUserFromSession());
echo $svc->current()->name;
// process exits. $svc, $container, $currentUser — all gone.
That singleton is a singleton for the duration of one request. The next request gets a fresh container, a fresh UserService, and a currentUser that starts as null. This is what people mean by "shared-nothing": memory leaks die with the request, connection pooling is the database's problem, and stale state is impossible because there is no state to go stale.
You got isolation as a side effect of process death. Sixteen, thirty-two, sixty-four times a second, a small universe was being created and torn down, and you never had to clean up after it.
The Node model: the universe is one long afternoon
A Node process is a single JavaScript runtime. You boot it once. It runs until you SIGTERM it. Every HTTP request runs as a callback inside that one process, on the same V8 heap, sharing every variable that lives at module scope.
// app.ts — module scope. Lives for the whole process.
import express from "express";
class UserService {
private currentUser: User | null = null;
setCurrent(user: User): void {
this.currentUser = user;
}
current(): User | null {
return this.currentUser;
}
}
// One instance. Created once when the module loads. Shared by every request.
const userService = new UserService();
const app = express();
app.get("/me", async (req, res) => {
const user = await loadUserFromSession(req);
userService.setCurrent(user);
res.json({ name: userService.current()?.name });
});
app.listen(3000);
Read that handler twice. Two requests arrive concurrently. Both call setCurrent. The second wins. The first request's res.json runs after an await and reads the second request's user.
This is the opening bug. Not a TypeScript bug, not an Express bug. The consequence of taking a class written for a process that dies and putting it inside a process that doesn't.
The Laravel singleton binding produces a class that resets every request. The Node singleton produces a class that resets when the deploy happens. That covers const at module scope, DI container instances, and static fields. The two look identical and behave nothing alike.
The general rule worth memorizing:
In Node, anything you write at module scope is shared by every request the process ever handles. There is no automatic per-request reset. If you want isolation, you opt in.
These break under the rule, in the order teams hit them:
Mutable singletons that hold request data. A CurrentUser, a RequestContext, a Tenant. They worked in PHP because the next request got a new instance.
Caches keyed by nothing that changes. A Map<tenantId, Config> is fine. A let cache: Config | null set once per request is a tenant-data leak.
Clients that carry credentials in mutable fields. A Redis client whose select(db) is called per request. An HTTP client whose setAuth() is called per request. The mutation now lives across requests.
The first prod incident is almost always one or two; three usually surfaces in tests.
Pattern one: AsyncLocalStorage, the per-request "globals" you actually need
Node's standard library has the answer. AsyncLocalStorage lives in node:async_hooks. The API has carried stability-2 ("stable") status since Node 16.4 and is the same primitive OpenTelemetry, Pino, and most production tracing libraries use under the hood. Node 23 introduced a V8-backed implementation (AsyncContextFrame), and Node 24 completed the switch. The API surface did not change.
The model is simple. Wrap a request handler in a run() call. Anything inside that callback can read a per-call store, including code reached through await, timers, or further async hops. Code outside cannot. It is the closest thing Node has to a request-scoped global, and it is built into the runtime.
import { AsyncLocalStorage } from "node:async_hooks";
import express from "express";
interface RequestContext {
requestId: string;
tenantId: string;
user: User | null;
}
const requestContext = new AsyncLocalStorage<RequestContext>();
// Helper to read the current request's context from anywhere.
export function currentRequest(): RequestContext {
const ctx = requestContext.getStore();
if (!ctx) {
throw new Error("currentRequest() called outside a request scope");
}
return ctx;
}
const app = express();
app.use(async (req, res, next) => {
const ctx: RequestContext = {
requestId: req.header("x-request-id") ?? crypto.randomUUID(),
tenantId: resolveTenant(req),
user: null,
};
requestContext.run(ctx, () => next());
});
app.get("/me", async (req, res) => {
const ctx = currentRequest();
ctx.user = await loadUser(ctx.tenantId);
res.json({ name: ctx.user.name });
});
app.listen(3000);
Two concurrent requests each get their own ctx, because each lives inside its own run() call. The handler can await anything and the next line still reads the right context. Loggers and metrics emitters pick up requestId and tenantId without any of them being passed as arguments.
This is what PHP gave you free with $_SERVER. You are paying for it now by writing one middleware. That is the whole tax.
(Historical note: the broader async_hooks module still carries a stability-1 tag. AsyncLocalStorage itself is stable and recommended. Advice from 2020 telling you to avoid async_hooks is out of date for this specific class.)
Pattern two: factories for tenant-scoped services
AsyncLocalStorage covers per-request reads. It does not cover services that need per-request configuration: a database client pointed at a tenant's database, an API client carrying a tenant's API key, a feature-flag evaluator built around the current user.
The PHP habit is to register them as singletons and let the container reset them per request. Do not bring that habit. The Node version is a factory.
// services/tenant-db.ts
import { Pool } from "pg";
const pools = new Map<string, Pool>();
// One pool per tenant, cached at module scope.
// The pool itself is safe to share — it does the per-request work internally.
export function tenantPool(tenantId: string): Pool {
let pool = pools.get(tenantId);
if (!pool) {
pool = new Pool({ connectionString: dsnFor(tenantId), max: 10 });
pools.set(tenantId, pool);
}
return pool;
}
// services/user-service.ts
export class UserService {
constructor(private readonly pool: Pool) {}
async load(id: string): Promise<User | null> {
const { rows } = await this.pool.query(
"select * from users where id = $1",
[id],
);
return rows[0] ?? null;
}
}
// Per-request factory. Cheap. Disposable.
export function userServiceForTenant(tenantId: string): UserService {
return new UserService(tenantPool(tenantId));
}
The pattern: long-lived resources (pools, HTTP keep-alive agents, compiled regexes) live at module scope. Short-lived services that bind tenant or user data are constructed per request. Construction is cheap. You allocate an object with three fields, and no connection is opened.
app.get("/me", async (req, res) => {
const ctx = currentRequest();
const users = userServiceForTenant(ctx.tenantId);
const user = await users.load(req.params.id);
res.json(user);
});
If you want a Laravel-style "container" feel, write one that takes the request context and returns a fresh service graph. NestJS's request-scoped providers bake this into the framework. The rule is the same either way: services that hold request data are not singletons.
Pattern three: no module-level mutable state, ever
The strongest rule is a discipline, not an API: at module scope, write only constants and pure factories. Anything that changes lives inside a request scope or a clearly-named cache.
// fine — constant
export const MAX_RETRIES = 3;
// fine — pure factory using request context
export function logger() {
const ctx = requestContext.getStore();
return baseLogger.child({ requestId: ctx?.requestId });
}
// fine — long-lived shared resource, no request data
export const redis = new Redis(process.env.REDIS_URL!);
// trap — module-scope mutable singleton holding request state
export const userService = new UserService();
// trap — module-scope cache keyed by nothing
let cachedConfig: Config | null = null;
Most TypeScript linters do not catch the trap cases by default. A code-review checklist works better than tooling. The question to ask of every let and every class field: does this hold data that varies per request? If yes, it does not belong at module scope.
The FrankenPHP detour, briefly
FrankenPHP and RoadRunner change PHP's lifecycle. In worker mode, FrankenPHP runs a small pool of long-lived PHP threads (typically two per CPU) and each thread serves many requests without restart. The bootstrap cost is paid once. Performance gains are real: a Symfony 7.4 benchmark on FrankenPHP shows roughly 3.1x RPS over nginx + PHP-FPM, with broader numbers on the FrankenPHP performance page.
The catch is the one this post has been about, in reverse. FrankenPHP worker mode keeps state alive between requests, which means PHP code that quietly relied on the </php> reset now leaks tenant data the same way Node does. Laravel Octane and Symfony's runtime component exist specifically to reset the container between requests and stop that leak.
If your migration target is FrankenPHP, you have already learned half of this lesson. Most teams reading this are not on FrankenPHP yet. They are moving to Node for a different runtime, not the same runtime with longer lifecycles. If you went to Node for the language, the ecosystem, or the fact that the rest of your stack is JavaScript, the cost of admission is the patterns above.
A migration checklist
Run this against any service you port from PHP to TypeScript:
- Find every singleton. Grep your container bindings. Does it hold request data? If yes, it becomes a per-request factory. If no, leave it.
-
Find every static field. A static
Cachefield is module-scope state in disguise. Same question, same rule. -
Wire
AsyncLocalStorageearly. Add it as the first middleware. Even if you only putrequestIdin it on day one, you will want it the day you add tenancy or per-user logging. -
Audit module-level
letdeclarations. Anything mutable at module scope needs a comment explaining why it is safe to share, or it needs to move. - Test for the leak directly. Write an integration test that fires two concurrent requests with different tenants and asserts the responses do not contaminate each other. Catches the Singleton-with-request-state bug in CI.
-
Configure DI scopes explicitly. With
tsyringe,awilix,inversify, or NestJS, the default is usually "singleton". That is exactly what you do not want for request-bound services.
Closing
Once the patterns above are wired, the long-lived process stops being a hazard and starts being the reason you came. Connection pools that actually pool. Caches that survive across requests. Background timers, websockets, and SSE that FPM could not host. The work is one afternoon, and what you get back is a runtime that earns its keep on the second request and every one after.
If this was useful
PHP to TypeScript is the bridge book in The TypeScript Library — written for PHP 8+ developers who already ship and want the mental-model translation, not a "rewrite your Laravel app" tour. Sync to async, container to factory, throw to Result, dynamic types to discriminated unions.
| # | Book | Best fit |
|---|---|---|
| 1 | TypeScript Essentials | Entry point. Types, narrowing, modules, async, daily-driver tooling |
| 2 | The TypeScript Type System | Deep dive on generics, mapped/conditional types, branded types |
| 3 | Kotlin and Java to TypeScript | Bridge for JVM developers — variance, null safety, sealed types |
| 4 | PHP to TypeScript | Bridge for PHP 8+ developers — sync→async, generics, unions |
| 5 | TypeScript in Production | tsconfig, build tools, monorepos, dual ESM/CJS, library authoring |
Migrating from PHP: books 4 and 5 fit best — the bridge plus the production layer. For the canonical core path, books 1 and 2 substitute for 4.
- The TypeScript Library: the 5-book collection
- Hermes IDE: hermes-ide.com — an IDE for developers shipping with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub

Top comments (0)