DEV Community

Cover image for PHP to TypeScript: The Sync-to-Async Shift That Trips Every Backend Dev
Gabriel Anhaia
Gabriel Anhaia

Posted on

PHP to TypeScript: The Sync-to-Async Shift That Trips Every Backend Dev


You write PHP. A request comes in. PHP-FPM hands it a worker,
the worker boots your framework, runs your controller, sends
the response, and throws the whole thing away. The next request
starts from zero. Globals reset. Static properties reset. Open
connections close. Whatever you scribbled on $_SESSION lives
in Redis or a file, not in the process.

That model is the safety rail you never noticed. It is also the
first thing Node takes away.

In Node, your TypeScript process starts once and stays up for
days. Every request runs inside the same long-lived process,
sharing the same heap, the same module-level variables, the
same event loop. Nothing resets between requests unless you
reset it. The mental model that kept your PHP code correct will
quietly write bugs in TypeScript, and they will be the kind
that only show up under load.

The per-request slate is gone

Here is the PHP habit. You declare something at the top of a
file or in a service, and you trust that it belongs to this
request.

<?php

class RequestContext
{
    public static ?int $userId = null;
}

// Somewhere in middleware:
RequestContext::$userId = $authedUser->id;
Enter fullscreen mode Exit fullscreen mode

This works in PHP because the static property dies with the
worker's request cycle. The next request gets a fresh class.

Port that pattern to Node and it breaks in a way that is hard
to reproduce locally.

// context.ts
export const requestContext = {
  userId: null as number | null,
};

// auth-middleware.ts
import { requestContext } from "./context";

export function setUser(id: number) {
  requestContext.userId = id;
}
Enter fullscreen mode Exit fullscreen mode

That module is evaluated once. requestContext is a single
object shared by every request the process ever handles. Two
users hitting your API within the same second now read and
write the same userId. Under low traffic you never see it.
Under real traffic, user A reads user B's id and you have a
data-leak incident.

The fix is not a global at all. It is per-request storage that
the runtime keeps isolated for you: AsyncLocalStorage.

import { AsyncLocalStorage } from "node:async_hooks";

type Ctx = { userId: number };

export const ctx = new AsyncLocalStorage<Ctx>();

// In middleware:
ctx.run({ userId: authedUser.id }, () => {
  next();
});

// Anywhere downstream, in the same async chain:
const userId = ctx.getStore()?.userId;
Enter fullscreen mode Exit fullscreen mode

AsyncLocalStorage is the closest thing Node has to PHP's
per-request slate. It scopes a value to one logical chain of
async calls, so two concurrent requests never see each other's
store.

await does not block. It yields.

This is the sentence that retrains a PHP brain: in TypeScript,
await does not stop the world.

In PHP, almost everything blocks. You call the database, the
worker waits, nothing else happens in that process until the
query returns. One worker, one request, one thing at a time.
That is why PHP scales by running many workers.

<?php

$user = $db->query('SELECT * FROM users WHERE id = ?', [$id]);
// The worker is parked here. It does nothing else.
$orders = $db->query('SELECT * FROM orders WHERE user_id = ?', [$id]);
Enter fullscreen mode Exit fullscreen mode

In Node, await tells the event loop "I am waiting on I/O,
go run something else and wake me when this resolves." The
process does not park. It picks up the next request, the next
timer, the next callback.

const user = await db.query(
  "SELECT * FROM users WHERE id = $1",
  [id],
);
// The event loop did NOT stop. It handled other
// requests while this query was in flight.
const orders = await db.query(
  "SELECT * FROM orders WHERE user_id = $1",
  [id],
);
Enter fullscreen mode Exit fullscreen mode

The two queries above still run one after the other, because
the second await depends on nothing from the first but you
wrote them in sequence. If they are independent, sequencing
them wastes the wait. PHP gives you no choice. Node does.

const [user, orders] = await Promise.all([
  db.query("SELECT * FROM users WHERE id = $1", [id]),
  db.query("SELECT * FROM orders WHERE user_id = $1", [id]),
]);
Enter fullscreen mode Exit fullscreen mode

Both queries leave at once. Total time is the slower of the
two, not the sum. There is no PHP equivalent without extensions
or extra processes. This is the upside of the model you are
adjusting to.

CPU work is the trap on the other side

The flip side: because everything runs on one event loop, a
chunk of synchronous CPU work freezes every request at once.

In PHP this is a non-issue. A slow json_encode over a huge
array hurts one worker on one request. The other workers keep
serving.

In Node, a synchronous loop that runs for 400ms blocks the
event loop for 400ms. Every other in-flight request waits.
Health checks time out. The load balancer thinks the box is
dead.

// This blocks the whole process for as long as it runs.
function hashAll(items: string[]) {
  return items.map((x) => expensiveSyncHash(x));
}
Enter fullscreen mode Exit fullscreen mode

The fixes are real ones, not workarounds. Break the work into
chunks and yield between them, move it to a worker thread, or
push it to a queue and a separate process. The rule that keeps
you out of trouble: never run unbounded synchronous CPU work on
the request path. In PHP you could get away with it. Here you
cannot.

Module-level state survives every request

One more PHP reflex worth unlearning. In PHP you sometimes cache
a computed value in a static and accept that it recomputes per
request, because the slate wipes it. In Node, module-level state
is process-wide and permanent until restart. That is great for a
real cache and dangerous for anything request-specific.

// Good: a genuine process-wide cache.
const configCache = new Map<string, Config>();

// Bad: this "current tenant" leaks across requests.
let currentTenant: string | null = null;
Enter fullscreen mode Exit fullscreen mode

The test is one question. Should this value be the same for
every user the process ever serves? If yes, a module-level
constant is correct. If it belongs to one request, it needs
AsyncLocalStorage or you pass it down explicitly. Mixing the
two is how the worst concurrency bugs get shipped.

A short translation table

The patterns map cleanly once you see the axis they share. PHP
isolates state by throwing the process away. Node keeps the
process and asks you to isolate state yourself.

PHP habit              TypeScript / Node equivalent
---------------------  -----------------------------
static per request     AsyncLocalStorage
$_SESSION              session store + per-request ctx
blocking DB call       await (yields the event loop)
many FPM workers       one loop + Promise.all + cluster
slow loop = 1 worker   slow loop = whole process frozen
fresh globals          module state persists, reset it
Enter fullscreen mode Exit fullscreen mode

None of this makes one model better than the other. PHP's
share-nothing design is the reason a memory leak in one request
cannot poison the next, and the reason most PHP apps never think
about concurrency at all. Node trades that automatic isolation
for a process that can hold connections, caches, and warm state
across requests, and answer thousands of them on one thread
while they wait on I/O.

The shift that trips backend devs is treating the always-on
process like the per-request one. Once you internalize that the
slate does not wipe and await does not block, most of the
surprising bugs stop being surprising.

If this was useful

The full sync-to-async path — the event loop, error handling
without try/finally cleanup you assumed PHP did for you,
discriminated unions in place of associative-array soup, and the
generics that replace PHP's docblock gymnastics — is what PHP to
TypeScript
walks through from a PHP 8+ developer's starting
point. If the per-request slate section made something click,
that is roughly the spine of the book.

The TypeScript Library — the 5-book collection. Books 1 and 2 are the core path; 3 and 4 substitute for 1 and 2 if you come from the JVM or PHP; book 5 is for anyone shipping TS at work.

  1. TypeScript Essentials — types, narrowing, modules, async, daily-driver tooling across Node, Bun, Deno, and the browser.
  2. The TypeScript Type System — generics, mapped/conditional types, infer, template literals, branded types.
  3. Kotlin and Java to TypeScript — variance, null safety, sealed classes to unions, coroutines to async/await.
  4. PHP to TypeScript — the sync-to-async shift, generics, discriminated unions for PHP 8+ developers.
  5. TypeScript in Production — tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR.

All five books ship in ebook, paperback, and hardcover.

The TypeScript Library — the 5-book collection

Top comments (0)