Hosting Real-Time Apps: WebSockets on Your Own Server, Step by Step

A WebSocket opens a persistent channel between browser and server carrying byte-sized messages instead of repeated full HTTP requests, cutting latency and load sharply. Setup needs a reverse proxy passing wss, and the concurrent-connection ceiling is set by RAM.
To host a real-time app (chat, live notifications, a live dashboard, document collaboration) you need WebSocket, a protocol that opens a persistent, two-way channel between browser and server over a single TCP connection that stays open. Instead of the browser asking the server every few seconds (polling), the server pushes data the moment it is available. The difference in numbers is decisive: polling once per second across 1000 users means 1000 full HTTP requests per second with headers exceeding 500 bytes each, while WebSocket keeps 1000 connections open and sends a frame of 2 to 6 bytes over the payload only when there is something new.
Setup on your server is three steps: run a WebSocket server (such as Laravel Reverb or Soketi), configure a reverse proxy (Nginx) to pass the connection upgrade to encrypted wss, then open the port in the firewall. The concurrent-connection ceiling is set mainly by RAM: each open connection uses tens to hundreds of kilobytes, so a 4 GB plan holds tens of thousands of idle connections easily. A latency of about 8 ms from Riyadh on a Saudi cloud server makes the sense of immediacy real for your local user, unlike a distant server at 120 ms. We break down the numbers and the full setup.
WebSocket vs polling, by the numbers
The difference is not just elegance, it is load and latency. Compare a scenario of 1000 connected users expecting instant updates:
| Criterion | Polling every second | WebSocket |
|---|---|---|
| Requests per second | 1000 full HTTP requests | 0 (open channel) |
| Size of each message | 500+ byte headers per request | 2 to 6 byte frame over payload |
| Delay before an update appears | Up to a full second (poll interval) | Instant when the event happens |
| CPU load | High (start and handle each request) | Low (standing connection) |
| RAM per user | None between requests | Tens to hundreds of KB, always |
Polling wastes CPU and bandwidth on empty requests most of the time, and adds a delay equal to the poll interval. WebSocket flips the equation: low CPU load and near-zero latency, in exchange for memory reserved for open connections. That trade favors WebSocket for any app with frequent updates.
Step one: the WebSocket server
Traditional PHP-FPM does not serve WebSocket because it handles a request and ends, while WebSocket needs a long-lived process that keeps connections alive. The two practical options in a Laravel stack:
- Laravel Reverb: an official server written in PHP, integrating directly with Laravel event broadcasting, easy to run with a single artisan command.
- Soketi: an open-source server compatible with the Pusher protocol, built on Node, light and fast.
Both are long-running processes you run under a process supervisor like supervisor or systemd so they restart automatically on any crash. The process listens on an internal port (such as 8080), which you do not expose directly to the internet, passing it through the reverse proxy instead.
Step two: configuring Nginx to pass wss
WebSocket starts as a normal HTTP request carrying an Upgrade header, so Nginx must pass that upgrade rather than closing it. The key is three headers in the location block: passing Upgrade and Connection and raising the protocol to HTTP/1.1. In addition, raise proxy_read_timeout to a high value (such as 3600 seconds) because the connection stays open for a long time, otherwise Nginx cuts it after the short default. With an SSL certificate the path becomes encrypted wss, which browsers require on secure sites, and a free Let's Encrypt certificate is enough.
A common mistake: a short read timeout
If you forget to raise proxy_read_timeout, connections drop every 60 seconds and the client keeps reconnecting, so the app feels choppy. Set the timeout high, and enable periodic ping/pong messages from the server to keep the connection alive through any intermediary.
The concurrent-connection ceiling by RAM
The practical question: how many connections does my server hold? The answer depends mainly on RAM, plus the system's file-descriptor limit. Each idle WebSocket connection uses 30 to 250 KB depending on the server and buffer sizes. A rough estimate on Cloud Server plans, assuming half the RAM is available for connections at an average of 100 KB each:
| Plan | RAM | Concurrent idle connections (approx.) |
|---|---|---|
| Asas | 2 GB | ~10,000 |
| Numuw | 4 GB | ~20,000 |
| Aamal | 8 GB | ~40,000 |
| Muassasa | 16 GB | ~80,000 |
These are idle-connection figures, and active connections exchanging many messages use more due to buffers and CPU processing. Raise the system file-descriptor limit (ulimit and nofile) because the default is often 1024, far below what you need. If you pass thousands of active connections, add RAM: 2 GB for 600 SAR per year lifts the ceiling by about 10,000 more idle connections.
Scaling out: more than one WebSocket process
A single WebSocket process is usually bound to one CPU core, so as traffic grows you run several processes and spread connections across them. The problem is that connections are split across different processes, so a message arriving at one process must reach subscribers on another. The solution is a shared broadcast channel through Redis Pub/Sub: each process publishes its events to Redis and all processes listen, so a message reaches every subscriber regardless of which process they connect to. This pattern lets one strong server serve tens of thousands of active connections spread across several cores, then scale to several servers later through the same Redis mechanism.
Why local latency matters
In a real-time app, latency is the experience itself. A chat message or a dashboard update must appear instantly. A distant server at 120 ms round trip means a quarter second of felt delay in every interaction, while 8 ms from Riyadh on a Saudi server makes the update feel instant. With WebSocket this latency recurs with every message, so the gap compounds in busy chats, games, and live boards. Hosting close to your user is not a luxury in a live app, it is a condition of the experience quality. On top of that, Saudi data stays inside the Kingdom, a requirement for entities that demand data sovereignty on their live channels, not just their pages.
A fallback for dropped connections
Live connections sometimes drop for reasons outside your control (a mobile network, an intermediary on the path, a server restart). Design your client to reconnect automatically with an increasing interval (exponential backoff) rather than immediate rapid attempts that flood the server when it returns. On reconnect, refetch the latest state through a normal HTTP request so the user misses no event that occurred during the gap, since combining WebSocket for immediacy and HTTP for recovery gives a robust experience that does not break at the first network hiccup.
Launch steps
- Run Laravel Reverb or Soketi as a long-lived process under supervisor or systemd.
- Configure Nginx to pass the upgrade headers and raise proxy_read_timeout to 3600 seconds.
- Enable SSL so encrypted wss works, with a free Let's Encrypt certificate.
- Open the internal port in the firewall to the reverse proxy only, not the internet.
- Raise the file-descriptor limit (nofile) well above 1024 based on your expected connections.
- Enable periodic ping/pong to keep connections alive and watch RAM usage.
With this setup a single server serves thousands of concurrent users at low local latency. Start with a نمو plan for medium apps, watch RAM and file descriptors, and upgrade as active connections grow. Real immediacy starts with a correctly configured WebSocket on a server close to your user.


