AI News Hub Logo

AI News Hub

SSE and PHP-FPM don't play well together

DEV Community
Odilon HUGONNOT

I was building a chatbox on my dedicated server — a shared space where members could exchange data and communicate in real time. On the server side, I went with Server-Sent Events. Clean, simple, native HTTP. In local development, everything worked perfectly. I pushed to production. For two days, not a single issue. Then one evening, about ten people were using the chat at the same time. The site's homepage started responding slowly. At twenty simultaneous connected users, the server stopped responding to everything else entirely. 504 Gateway Timeout across the board. The server was running, PHP was running, but nothing was getting through. It took me an hour to understand what was happening. The SSE server-side code is short and standard. Here's what I had written: $m['id'] > $lastId)); if (!empty($news)) { echo json_encode($news); exit; } } usleep(300_000); // poll every 300ms } // Timeout: nothing new, release the worker, client will loop back echo json_encode([]); The principle is simple: we watch a JSON file every 300ms. As soon as new messages appear (identified by an id higher than the last one the client saw), we send them back and exit. If nothing arrives within 30 seconds, we return an empty array. Either way, the worker is freed. On the client side, the JavaScript loop: async function pollChat(lastId = 0) { try { const res = await fetch(`/chat-poll.php?last_id=${lastId}`); const messages = await res.json(); if (messages.length > 0) { messages.forEach(appendMessage); lastId = messages.at(-1).id; } } catch (e) { // Network error: wait before looping back await new Promise(r => setTimeout(r, 2000)); } // Loop back immediately — short connection, worker already freed pollChat(lastId); } pollChat(); The pollChat function is recursive but asynchronous: it waits for the server response, processes messages, then calls itself again. Since each call is inside a resolved Promise, there's no stack accumulation. This is the standard pattern for infinite polling in JS. The gain is immediate. With this approach, a worker is held for at most 30 seconds (when there are no messages), and often less than 300ms when messages arrive quickly. With a 20-worker pool and a 30-second timeout, you can theoretically handle hundreds of users in rotation: most of the time, workers are free. Polling every 300ms works fine, but it wastes a little CPU when there's nothing to read. The PHP inotify extension lets you replace this waiting loop with a system notification: the process goes to sleep and is woken up as soon as the messages file is modified. 0) { inotify_read($fd); // drain the event queue // read new messages... } fclose($fd); Result: near-zero latency for the user, zero CPU while waiting. That said, inotify isn't available everywhere and adds an extension dependency. For a chatbox use case, 300ms polling is more than enough — the difference is imperceptible to the user. SSE is a good protocol. Simple, native, well supported by browsers, no library needed. The problem isn't SSE — it's what PHP-FPM does with every active connection. One worker per connection is the PHP model. That model is perfectly suited to thousands of short pages. It's fundamentally ill-suited to persistent connections. Long-polling isn't as elegant as true push. There are round trips, timeouts, client-side reconnection to handle. But it respects the PHP model, it scales, and it requires no external dependencies — no ReactPHP, no Ratchet, no Swoole. One PHP file and a JavaScript loop. The lesson I take from this: knowing your stack's constraints is what separates "it works in dev" from "it holds in prod". The naive SSE code was correct. What was missing was an understanding of how PHP-FPM allocates its resources. Fifteen minutes reading the PHP-FPM documentation would have prevented the outage.