DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our serverless half never boots and never shuts down, so every operational decision lives in the other half

PubTrivia is two deployments. The Next.js app serves the marketing site, the
dashboard and the API, and it runs as serverless functions. The WebSocket server
is a separate package in the same pnpm workspace, compiled with tsc and started
with node dist/index.js in a container.

The interesting thing about that split is not the architecture, it is how
lopsided the operational surface turned out to be. The serverless half has
almost no operational configuration at all, because it has no lifecycle to
configure. It never boots in a way I can observe, it is never asked whether it is
healthy, it does not choose a port, and it is never sent SIGTERM. Those four
concerns do not disappear when you go serverless. They move into whatever part
of your system is still a process, and that part is the one that needs the
careful code.

Here is all of it, from the container half.

It refuses to boot rather than boot half configured

The first thing ws-server/src/index.ts does is collect what is missing:

const missingVars: string[] = [];
if (!rawPort) missingVars.push('WS_PORT (or PORT, injected by the host)');
if (!DATABASE_URL) missingVars.push('DATABASE_URL');
if (!WS_ALLOWED_ORIGINS) missingVars.push('WS_ALLOWED_ORIGINS');
if (!WS_INTERNAL_SECRET) missingVars.push('WS_INTERNAL_SECRET');
if (!NEXTJS_INTERNAL_URL) missingVars.push('NEXTJS_INTERNAL_URL');

if (missingVars.length > 0) {
  console.error(
    `[ws-server] FATAL: missing required environment variable(s): ${missingVars.join(', ')}`,
  );
  console.error(
    '[ws-server] Set these in the host environment (Railway → service → Variables), then redeploy.',
  );
  process.exit(1);
}
Enter fullscreen mode Exit fullscreen mode

Two deliberate things there. It collects all five before exiting instead of
failing on the first one, so a fresh deploy with three missing variables takes one
round trip to fix rather than three. And the second log line names where to set
them, which reads as over-explaining until the day you are reading the log on a
phone having forgotten which platform this service is even on.

A process that boots without its database URL and discovers that on the first
request is strictly worse than one that refuses to boot, because the platform's
restart policy is the mechanism that is supposed to notice. Exiting non zero is
how you talk to it.

The port comes from two places and only one of them is mine

const portSource = process.env.WS_PORT ? 'WS_PORT' : 'PORT';
const rawPort = process.env.WS_PORT ?? process.env.PORT;
Enter fullscreen mode Exit fullscreen mode

Managed platforms inject the port the container must listen on as PORT. Local
development wants a fixed port, because the Next app's env file points at it and
I do not want that to move. So the explicit variable wins if it is set, and the
platform's wins otherwise. Then it is validated like any other input:

const port = Number.parseInt(rawPort!, 10);
if (Number.isNaN(port) || port <= 0 || port > 65535) { ... process.exit(1); }
Enter fullscreen mode Exit fullscreen mode

portSource exists only so the error message can say which of the two variables
was wrong. A one word difference in a fatal log is worth a variable.

A health check that does not ask the database anything

railway.json is nine lines and four of them are operational policy:

{
  "deploy": {
    "startCommand": "node dist/index.js",
    "healthcheckPath": "/health",
    "healthcheckTimeout": 30,
    "restartPolicyType": "ON_FAILURE",
    "restartPolicyMaxRetries": 10
  }
}
Enter fullscreen mode Exit fullscreen mode

And the endpoint it names is five lines:

export function handleHealth(_req: IncomingMessage, res: ServerResponse): void {
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({ status: 'ok', ...connectionStats(), redis: redisEnabled }));
}
Enter fullscreen mode Exit fullscreen mode

connectionStats() counts the sessions in memory and the sockets across them, so
the response is status, sessions, clients and a boolean saying whether
cross instance fan-out is active. That makes it two things at once: the liveness
probe the platform polls, and the fastest way for me to see how many phones are
connected on a quiz night.

What it deliberately does not do is query Postgres. The temptation with a health
endpoint is to make it prove the whole dependency graph is up, and on a service
that holds long lived sockets that is a trap: a five second database blip would
turn into a failed health check, which turns into the platform killing a process
that is currently holding every connected phone in a room. The database being
slow is a problem for one request. The health check failing is a problem for
everyone at once. So this endpoint answers a narrower question on purpose, which
is "is this process still able to serve", and the restart policy stays attached
to crashes rather than to dependency wobble.

ON_FAILURE with restartPolicyMaxRetries: 10 is the other half of that. Ten
restarts and then it stops trying, which pairs exactly with the process.exit(1)
above: a service missing an env var will not crash loop forever, it will try ten
times and sit there failed, where a failed deploy is visible and a restart loop
is just noise in a graph.

Shutdown, which I have already written about once

The fourth concern is the signal. I wrote the shutdown path up separately in
deploying in the middle of a question,
so I will not repeat the ordering argument here beyond the summary: the force
exit timer is armed before any of the graceful work, and it is unref'd so it
cannot itself become the reason the process lingers.

One line has changed since that post, and it is worth the paragraph. Clients used
to be hung up on with terminate(). They now get a close frame with a code:

// 1012 "service restart": the client reconnects straight away.
ws.close(1012, 'server_restart');
Enter fullscreen mode Exit fullscreen mode

1012 is the standardised "service restart" close code. terminate() is
indistinguishable from a network failure at the client, so the reconnect logic
had to treat a deploy the same way it treats a phone walking out of WiFi range,
which means backoff. A close frame saying 1012 tells the client this was
deliberate and brief, so reconnecting immediately is the correct response. Same
shutdown, same ten second ceiling, one argument's worth of difference in how fast
a room comes back.

The handlers are also registered with once rather than on, so a platform that
sends SIGTERM twice does not run the sequence twice.

One more line that is pure operations

const wss = new WebSocketServer({ noServer: true, maxPayload: 4 * 1024 });
Enter fullscreen mode Exit fullscreen mode

The ws default accepts frames up to 100 MB. Clients here send small, boring
messages, so the cap is 4 KB. One argument, no code anywhere else, and the
largest thing a connected socket can ask the server to allocate drops by four
orders of magnitude. The HTTP server answers a health check, one internal
endpoint the app calls, and a 404 for everything else, and upgrades from an
origin not on the allowlist get a 403 before any WebSocket code runs.

Where the two halves meet, from outside

The split is visible in the product if you know where to look. Everything on the
marketing site is prerendered HTML from the serverless half: the
features index and the
free tools never talk to the container at all.
Everything that updates while a room is playing comes from the container: the
live leaderboard and the
big screen display describe
what those sockets are for. The free tier on
the pricing page takes no card, so the quickest
way to see which half is which is to sign up and watch where the page stops being
static.

If you are moving from one runtime to two, this is the checklist I wish I had
written down first: boot refuses loudly and names everything missing, the port
has one owner and one fallback, the health check answers the narrowest useful
question, the restart policy has a ceiling, and the shutdown path is armed with a
timer before it does anything that can hang.

Top comments (0)