Long-lived connections in a request-scoped world

/5 min read

Why four tiny Bun sidecars hold the sockets my Next.js monolith never should.

The runtime mismatch

The CRM I built for a Slovenian tele-sales team is, on the surface, a single Next.js monolith. Agents open it in a browser, see who to call, take orders, log outcomes. Next.js is good at this because its whole model is request-scoped: a request arrives, a handler runs, a response leaves, and nothing lingers. The framework assumes that any state worth keeping lives in the database, and that an instance can be killed and replaced at any moment without anyone noticing. For a CRM full of forms and lists, that assumption is exactly right.

Telephony breaks that assumption immediately. The PBX does not answer polite HTTP questions; it pushes a continuous stream of call events over a socket that must stay open, authenticated, and consumed, or you miss things. The GSM gateway that handles SMS speaks a raw TCP protocol with its own framing and keepalives. The in-browser softphone needed live audio forwarded to a transcription service while the call was still happening. None of these fit inside a request handler. They are connections with a lifetime measured in days, hosted by a runtime that thinks in milliseconds.

The usual instinct is to fight the framework. People reach for global singletons in module scope, pray the connection survives hot reloads and multiple workers, and end up debugging duplicate socket consumers at two in the morning. I tried a version of this early on and the failure modes convinced me quickly. The honest reading is that these are two different kinds of program: one is request-scoped and stateless, the other is a daemon. Pretending a daemon is a web app does not make it one.

Sidecars as a pressure valve

So I split along the runtime boundary rather than the domain boundary. The monolith keeps everything that fits a request: pages, server actions, the database, the order flow. Next to it sit four small Bun services, each owning exactly one long-lived connection. One holds the PBX event socket and translates call events into things the CRM understands. One speaks the GSM gateway's TCP protocol and turns it into inbound and outbound SMS. One is a push channel that tells open browser sessions a new order has arrived. One bridges live call audio to transcription for the softphone.

Calling them sidecars rather than microservices is deliberate. There is no service mesh, no shared library of clients, no distributed transactions. Each one is a few hundred lines of Bun with a single responsibility: hold a connection, normalise what comes through it, and hand the result to the monolith over plain HTTP. Bun earned its place here because it starts instantly, handles raw TCP and WebSockets without ceremony, and a single process per concern is trivially cheap to run on the same box.

Every message that crosses a boundary is validated with Zod on the receiving side. That sounds like bureaucracy until the first time a PBX firmware update changes an event field, or the GSM gateway emits a frame you have never seen. With schemas at the seams, a surprise becomes a logged validation error in one sidecar instead of undefined creeping silently into the order screen. The schemas also double as the only documentation of these protocols I actually trust, because they are enforced rather than aspirational.

Crash isolation for free

The strongest argument for the split only became obvious in production. The transcription bridge is the riskiest code in the system: it juggles WebRTC audio, an external speech API, and buffers that grow with call length. Early on it had a slow memory leak. In a single-process design that leak would have been the CRM's leak, and the symptom would have been the whole team's order screen getting sluggish and eventually dying mid-call. Instead the bridge restarted itself, transcription blinked for a few seconds, and nobody taking an order noticed anything at all.

That is the property I now design for: the blast radius of a failure should match the importance of the feature. Live transcription is a nice assist. Taking orders is the business. The process boundary encodes that priority in a way no amount of careful try-catch inside one process can, because a leak or a busy event loop does not respect your error handling. Each sidecar runs under a supervisor that restarts it on exit, and reconnection with backoff is part of its normal start-up path, not an exceptional one.

Keeping the monolith boring

The discipline that makes this work is refusing to let the sidecars grow. Business logic gravitates towards wherever the data first arrives, and it takes constant effort to push it back. The SMS sidecar does not decide which agent a message belongs to; it validates the frame, normalises it, and posts it to the monolith, which owns that decision next to the rest of the customer logic. The sidecars stay dumb pipes with schemas. Everything a developer would actually want to change lives in one repository, one deployment, one mental model.

Keeping the monolith request-scoped also keeps it deployable. I can ship the CRM ten times a day without thinking about draining sockets or replaying missed PBX events, because the monolith holds no connection worth draining. The sidecars, in turn, almost never change; the PBX protocol from months ago is the PBX protocol today. The pieces that change daily are stateless and the pieces that hold state change rarely. That split of change frequency, as much as the crash isolation, is what the architecture buys.

I would not generalise this into a rule about always extracting services. The rule I would generalise is narrower: split where the runtime model genuinely differs, and nowhere else. A request-scoped framework given request-scoped work is boring in the best sense, and a daemon allowed to be a daemon is simple in a way a clever in-process workaround never is. Four small processes and some Zod schemas turned out to be the cheapest possible price for letting each part of the system be the kind of program it wanted to be.