Why I moved SSE to Node.js (and off PHP-FPM forever)
Kolawole
Mar 25, 2026
This is not a post about PHP being bad.
PHP-FPM is excellent at what it was designed for: handling short, discrete HTTP request-response cycles efficiently across a pool of workers. That is the job. It does it well.
The problem is Server-Sent Events are not that job.
I learned this the hard way on a production system, and the lesson reshaped how I think about the boundary between Laravel and Node.js in every real-time feature I have built since.
What SSE actually does to your worker pool
A normal PHP-FPM request lives for milliseconds to a few seconds at most. The worker picks it up, runs the code, sends the response, and becomes available again.
An SSE connection is different. The client connects and the connection stays open — sometimes for minutes — while the server pushes events down it. The worker handling that connection is not available for anything else during that time.
On a pool configured for normal web traffic, this is a problem. A few dozen open SSE connections can hold enough workers that regular page loads start queuing. Under moderate traffic, this tips into a visible degradation. Under heavier traffic, the app effectively locks up.
This is what I was seeing on MarketingBlocks. Real-time AI generation features were using SSE to stream responses to the client. It worked fine in development and in light usage. As soon as concurrent connections climbed, the worker pool started showing signs of exhaustion — slow responses, timeouts on unrelated routes, and queue workers fighting for the same process budget.
The root cause was clear once I looked at it properly. SSE connections were sitting in PHP-FPM workers that were never intended for long-lived connections.
The fix is not tuning, it is separation
The natural instinct is to tune your way out of the problem. Increase pm.max_children. Raise timeout limits. Add more memory.
I tried some of this. It helps at the margins. It does not fix the architectural mismatch.
The real fix is to stop asking PHP-FPM to do something it was not designed for. Long-lived stateful connections belong in an event-loop runtime, and Node.js is the obvious fit if you are already running it alongside Laravel.
The architecture that works:
- Laravel handles everything it is good at: authentication, database writes, business logic, queue dispatch
- Node.js runs the SSE service: it holds the open connections, listens on Redis pub/sub, and pushes events to connected clients
Laravel does not need to know anything about the open connections. When something happens that a client should hear about — a generation step completes, a job status changes, a process finishes — Laravel publishes a message to a Redis channel. The Node.js service is subscribed to those channels and forwards the event to the right client.
The two processes are decoupled. Laravel stays stateless and fast. Node.js handles the one thing it is genuinely built for: holding many connections open cheaply using its event loop.
What the Node.js SSE service looks like
The service itself is not complicated. The core is:
- an HTTP server that accepts SSE connections and registers them against a user or session identifier
- a Redis subscriber that listens on channels and routes messages to the right connected client
- connection cleanup on disconnect so you are not leaking memory
When a client connects, they send their session token. The service validates it against Laravel (a single internal HTTP call) and then parks the connection. From that point on, the client is just a registered listener waiting for events.
When Laravel publishes to Redis, the Node.js service receives it, looks up which connections are registered for that user or job, and writes the event to each of them. The client-side EventSource API handles the rest.
The PHP-FPM pool never sees a long-lived connection. Workers are available for actual web requests.
The boundary I now draw in every system
This experience gave me a clear rule I apply from the start now:
Anything that requires holding an open connection — SSE, WebSockets, long polling — does not run in PHP-FPM. It runs in Node.js, with Laravel as the source of truth for data and auth.
That boundary is not about preferring one technology over another. It is about matching the runtime to the workload. Event loops are cheap for connections. Process pools are not.
Lumio is built on this principle from day one. The SSE streaming service is a standalone Node.js process. Laravel handles the PDF processing, the AI calls, the user data. Node.js handles the open connection to the browser. They communicate through Redis. Neither service has to care about the internals of the other.
What I wish I had done earlier
Honestly, the MarketingBlocks architecture could have been cleaner from the start if I had drawn this line earlier.
The decision to put SSE into Laravel made sense in the moment — it was convenient, already authenticated, already connected to the right services. The cost only became visible under load, which is when it is hardest to refactor.
The lesson is that convenience and correctness are different things. SSE in PHP-FPM is convenient to set up and incorrect as an architecture. Node.js SSE with Redis pub/sub takes an extra afternoon and is something you never have to revisit.
That trade is always worth it.
Comments (0)
No comments yet
Be the first to share your thoughts!
Leave a Comment