In 30 seconds
Realtime features push data from the server to the client without the user refreshing: order status, chat, kitchen screens, live dashboards. You can poll, stream with Server-Sent Events, or keep a two-way WebSocket open. The hard parts are not the connection itself but scaling across servers, authorising channels and handling reconnections.
Why I use it
- I use realtime because customers expect to see their order move from 'accepted' to 'on the way' without refreshing.
- I use socket rooms so that a kitchen screen, a till and a manager's dashboard each receive only the events meant for them.
- I use Redis pub/sub because realtime must keep working when the API runs on several processes.
- I choose between hosted (Pusher) and self-hosted sockets depending on budget, scale and control.
How it works
01Polling, long-polling, SSE and WebSockets
Polling is asking 'anything new?' every few seconds. It is simple and works everywhere, but wastes requests and adds delay. Long-polling keeps each request open until the server has news or a timeout hits, then the client reconnects; less waste, more complexity. Server-Sent Events (SSE) keep one HTTP response open and the server streams text events down it; it is one-way (server to client), reconnects automatically and works well through proxies and HTTP/2. WebSockets open a full two-way channel over a single TCP connection, ideal when the client also sends frequent messages, as in chat, collaborative editing or games. Rule of thumb: polling for rare updates, SSE for server-to-client feeds such as notifications or AI token streaming, WebSockets for interactive two-way traffic.
02How a WebSocket connection works
A WebSocket starts as a normal HTTP request asking to upgrade the protocol. If the server agrees, it replies 101 Switching Protocols, and from then on the same TCP connection carries WebSocket frames instead of HTTP. Frames are small packets: text or binary data frames, plus control frames for ping, pong and close. Client-to-server frames are masked. Ping and pong keep the connection alive and detect dead peers. Because the upgrade is HTTP, the connection passes through load balancers and reverse proxies, but they must be configured to allow the upgrade and long-lived connections. Use wss:// (TLS) in production, always.
GET /ws HTTP/1.1
Host: api.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
03socket.io: namespaces, rooms and auth
socket.io is a library on top of WebSockets that adds automatic reconnection, acknowledgements, fallback to long-polling, and a simple model for grouping connections. A namespace (like /staff or /customers) is a separate channel with its own middleware, useful for different audiences. A room is a group inside a namespace; the server adds sockets to rooms and emits to the whole room at once. Typical rooms: one per tenant, per branch, per terminal and per user. Authenticate in the handshake middleware, before the connection is accepted, and decide room membership on the server from the verified identity. Never let a client join a room just because it asked.
io.of('/staff').use(async (socket, next) => {
try {
socket.data.user = await verifyAccessToken(socket.handshake.auth.token);
next();
} catch {
next(new Error('unauthorized'));
}
});
io.of('/staff').on('connection', (socket) => {
const { companyId, branchId, sub } = socket.data.user;
socket.join(['company:' + companyId, 'branch:' + branchId, 'user:' + sub]);
});
// elsewhere, after an order changes:
io.of('/staff').to('branch:' + order.branchId).emit('order.updated', { id: order.id, status: order.status, version: order.version });
04Scaling across servers with Redis
One process only knows its own connected sockets. When you run four API instances, a user connected to instance A will not see an event emitted on instance B. The fix is a shared message bus. The socket.io Redis adapter publishes every room emit to Redis pub/sub; each instance subscribes and delivers to its local sockets. If you use socket.io's long-polling fallback behind a load balancer, enable sticky sessions so a client's requests reach the same instance; with WebSocket-only transport this is not needed. For very large fan-out or guaranteed delivery, Redis Streams or a sharded adapter are options. Also emit from background workers through the same adapter (the socket.io emitter package), so a queue job can notify users directly.
import { createClient } from 'redis';
import { createAdapter } from '@socket.io/redis-adapter';
const pub = createClient({ url: process.env.REDIS_URL });
const sub = pub.duplicate();
await Promise.all([pub.connect(), sub.connect()]);
io.adapter(createAdapter(pub, sub));
05Laravel: broadcasting with Pusher, Reverb and Echo
Laravel has broadcasting built in. You mark an event with ShouldBroadcast, define which channel it goes to, and Laravel sends it through a driver. Pusher is a hosted service: no servers to run, you pay per connection and message. Reverb is Laravel's own first-party WebSocket server speaking the Pusher protocol, so you can self-host with the same client code. php artisan install:broadcasting sets things up. On the frontend, Laravel Echo subscribes to channels. Private and presence channels are authorised in routes/channels.php: Echo calls the /broadcasting/auth endpoint and your callback decides whether this user may listen. Queue your broadcasts so a slow socket server never slows the HTTP response.
// routes/channels.php
Broadcast::channel('orders.{orderId}', function (User $user, int $orderId) {
return Order::whereKey($orderId)->where('customer_id', $user->id)->exists();
});
// Frontend (Echo)
echo.private('orders.' + orderId)
.listen('OrderStatusChanged', (e) => updateStatus(e.status));
06Reconnection, ordering and missed messages
Mobile networks drop constantly, so design for disconnection. Clients reconnect with exponential backoff and jitter, so a server restart does not cause a stampede of thousands of simultaneous reconnects. Events sent while a client was offline are lost in plain pub/sub. Treat the socket as a 'something changed' signal and the REST API as the source of truth: on reconnect, refetch current state. Include a version number or timestamp in each event so the client can ignore stale or out-of-order updates. If order matters for one entity, route its events through a single channel and process them sequentially. For critical data, use acknowledgements or a persisted event log with a 'since' cursor.
- Keep payloads small: ids and status, not whole objects.
- Send heartbeats; close and clean up dead connections.
- Configure proxy timeouts (nginx proxy_read_timeout) above your ping interval.
Use it when
- Live order tracking, kitchen and queue screens, delivery status.
- Chat, support conversations and collaborative tools.
- Live dashboards and admin notifications where seconds matter.
- Streaming progress of long tasks or AI responses (often SSE).
Skip it when
- Data that changes every few minutes; polling or a refresh button is simpler.
- One-way notifications to a closed app; use push notifications (FCM/APNs) instead.
- When a hosted service would cost less than the time to run and scale your own socket servers.
Alternatives
- Server-Sent Events
One-way streaming over HTTP; auto-reconnect, very simple.
- Pusher / Ably
Hosted realtime; no infrastructure, pay per usage.
- Laravel Reverb
First-party self-hosted WebSocket server for Laravel using the Pusher protocol.
- Push notifications
FCM and APNs reach users even when the app is closed.
Best practices
- Authenticate at the handshake and assign rooms on the server.
- Use a Redis adapter (or hosted service) as soon as you run more than one instance.
- Treat events as hints and refetch state from the API on reconnect.
- Version events so clients can drop stale or duplicate updates.
- Reconnect with exponential backoff and jitter.
- Broadcast from queues so slow sockets never block HTTP requests.
Common mistakes
- Letting clients subscribe to any room or channel name they send.
- Scaling to several instances and wondering why half the users miss events.
- Assuming every message arrives, in order, exactly once.
- Sending full database records, including private fields, in broadcast payloads.
- Proxy timeouts shorter than the heartbeat interval, causing constant reconnects.
Deep dive
07Capacity: file descriptors, proxies and heartbeats
Every open WebSocket is an open file descriptor on the socket server and on every proxy in front of it. Linux limits these per process, often to 1,024 by default, so the first scaling wall is usually 'too many open files', not CPU. Raise the limit for the service (ulimit -n, LimitNOFILE in systemd, minfds in supervisor), and in nginx raise worker_connections and worker_rlimit_nofile, remembering that a proxied socket uses two connections in nginx: client side and upstream side. Memory per connection is the next limit, so measure it under load. Proxies close idle connections: nginx's proxy_read_timeout defaults to 60 seconds, and socket.io pings every 25 seconds by default, so the timeout must stay above the ping interval. Laravel Reverb has a specific trap: its default stream_select loop is limited to about 1,024 connections, and you need the ext-uv extension beyond that.
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
location /socket.io/ {
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_read_timeout 75s; # above the 25 s ping interval plus timeout
proxy_send_timeout 75s;
}
}
08Delivery guarantees: acknowledgements and state recovery
A plain emit is fire-and-forget: if the socket dropped a moment ago, the message is gone. socket.io offers two tools. Acknowledgements let the receiver confirm processing, and emitWithAck with a timeout turns that into a Promise you can await, so the server knows whether a till actually printed a receipt and can retry through a queue if not. Connection state recovery lets a client that reconnects within a window, two minutes by default, get back its id, rooms and the packets it missed. It has limits: it only covers short disconnects, it is not supported by every adapter (the classic Redis pub/sub adapter does not support it; the Redis Streams adapter does), and by default it skips your auth middleware on recovery, which you may want to turn off. For anything that must never be lost, a persisted event log with a cursor remains the reliable answer.
const io = new Server(httpServer, {
connectionStateRecovery: {
maxDisconnectionDuration: 2 * 60 * 1000,
skipMiddlewares: false, // re-run auth on recovered connections
},
});
try {
const replies = await io.to('terminal:' + terminalId)
.timeout(5_000)
.emitWithAck('receipt.print', { orderId });
logger.info({ replies }, 'terminal confirmed');
} catch {
await printQueue.add('retry', { orderId, terminalId }); // no ack in time
}
09Fan-out, hot rooms and throttled updates
Fan-out is one event delivered to many sockets. A room with 50,000 viewers of a live dashboard turns one emit into 50,000 writes, multiplied across instances by the adapter. Three techniques keep this sane. Coalesce: instead of sending every change, keep the latest state per entity and flush at most every few hundred milliseconds, so 40 stock updates per second become two messages. Use volatile emits for data where only the newest value matters, such as cursor positions or live metrics: socket.volatile.emit drops the packet if the client is not ready rather than buffering it. Keep payloads small, ids and changed fields only, and consider a binary parser such as msgpack for high volume. For many instances, socket.io's sharded Redis adapter uses Redis 7 sharded pub/sub so traffic is spread across a Redis Cluster instead of every node receiving every message.
- Measure messages per second per room and per instance, not just connection count.
- perMessageDeflate compression is off by default in socket.io; it costs memory per socket, so enable it only when payloads are large.
- Very hot rooms can be split by region or shard, with clients joining one shard.
10Security of long-lived connections
WebSockets bypass some protections you rely on for HTTP. Browsers send cookies with the WebSocket handshake, but CORS does not apply to it, so a malicious page can open a socket to your server with the user's cookies: cross-site WebSocket hijacking. Always check the Origin header against an allow-list when using cookie auth. A token verified at connect time may expire hours before the connection closes, so schedule a disconnect at token expiry or require the client to refresh and re-authenticate over the socket. Treat every incoming message like an HTTP request body: validate it with a schema, check permission for the specific action, and rate limit messages per socket. Cap message size, since socket.io accepts up to 1 MB per message by default, which is far more than most apps need.
const io = new Server(httpServer, {
maxHttpBufferSize: 64 * 1024, // default is 1 MB
allowRequest: (req, cb) => cb(null, ALLOWED_ORIGINS.has(req.headers.origin ?? '')),
});
io.on('connection', (socket) => {
const expiresInMs = socket.data.user.exp * 1000 - Date.now();
const timer = setTimeout(() => socket.disconnect(true), expiresInMs);
socket.on('disconnect', () => clearTimeout(timer));
socket.on('cart.update', (raw, ack) => {
const msg = CartUpdateSchema.safeParse(raw);
if (!msg.success) return ack?.({ error: 'invalid_payload' });
// permission check, then handle msg.data
});
});
Real-world scenarios
The kitchen screen misses orders after Wi-Fi drops
Problem
Restaurant staff report that new orders sometimes never appear on the kitchen screen. It always happens after the tablet's Wi-Fi briefly drops.
How to solve it
Events emitted while the tablet was offline were delivered to nobody. Fix it on both sides. On reconnect, the client must refetch the list of open orders from the REST API, which is the source of truth, and merge by order id and version. Add a periodic background refresh, for example every 60 seconds, as a safety net. On the server, keep payloads small and versioned so stale updates are ignored. If the delay matters a lot, enable connection state recovery with an adapter that supports it, or have the screen acknowledge new orders and alert staff on the till when an order stays unacknowledged for a minute.
Half the users stopped receiving events after scaling
Problem
The team scaled the socket server from one to three instances. Now some users get live updates and others do not, and logs show occasional 'Session ID unknown' errors.
How to solve it
Two separate problems. Missing events mean the instances do not share emits: add the socket.io Redis adapter, or the sharded adapter for Redis 7 clusters, so an emit on one instance reaches sockets on the others, and make background workers emit through the Redis emitter too. The 'Session ID unknown' errors come from the HTTP long-polling transport, whose requests must hit the same instance: enable sticky sessions on the load balancer, for example by cookie or IP hash, or configure clients with transports: ['websocket'] if you do not need the polling fallback. Then load test with several instances before the next release.
Reverb falls over at around a thousand connections
Problem
A Laravel app moved from Pusher to self-hosted Reverb. Everything worked in testing, but in production connections start failing once roughly a thousand users are online.
How to solve it
That number is the default event loop's limit: Reverb uses stream_select unless ext-uv is installed, and stream_select handles about 1,024 open files. Install the uv extension with PECL so Reverb switches loops automatically. Then raise every file limit in the chain: the user's nofile limit in limits.conf or the systemd unit, minfds in supervisord.conf, and worker_connections plus worker_rlimit_nofile in nginx. Check the local port range on the proxy host. Monitor connections with the Pulse integration. When one server is not enough, set REVERB_SCALING_ENABLED=true, point all nodes at a shared Redis and put them behind a load balancer.
Cheat sheet
- Connect to a WebSocket from the terminal
npx wscat -c wss://api.example.com/ws- Verbose socket.io server logs
DEBUG=socket.io* node dist/main.js- Client without the long-polling fallback
io('https://api.example.com', { transports: ['websocket'] })- Send only if the client is ready
socket.volatile.emit('metrics', snapshot)- Check the open-file limit
ulimit -n- Start Reverb
php artisan reverb:start --host=0.0.0.0 --port=8080- Restart Reverb gracefully on deploy
php artisan reverb:restart- Run Reverb on several servers
REVERB_SCALING_ENABLED=true- SSE response headers
Content-Type: text/event-stream; Cache-Control: no-cache; X-Accel-Buffering: no- Resume an SSE stream
Server sends 'id: 42' per event; the browser reconnects with Last-Event-ID: 42
In my real projects
- In a multi-tenant POS SaaS, socket.io rooms are scoped per company, branch, terminal and user, with Redis pub/sub fan-out across instances.
- On a restaurant-chain platform, live order updates reach customers through Pusher and Laravel Echo.
- A beauty e-commerce admin receives real-time updates through Pusher.
Questions & answers
Q1WebSockets or Server-Sent Events: how do you decide?
I look at the direction of traffic. If the server mostly pushes to the client, such as notifications, live status or streaming AI output, SSE is simpler: it is plain HTTP, reconnects automatically, works well with HTTP/2 and proxies, and the client still sends actions through normal API calls. If the client sends frequent messages too, as in chat, collaborative editing, games or a POS terminal reporting state, WebSockets fit better because one connection carries both directions with low overhead. SSE only carries text, so binary data favours WebSockets. Both need care with proxies and timeouts.
Q2How do you scale WebSockets across multiple servers?
Each server only knows its own connections, so you need a shared bus. With socket.io, the Redis adapter publishes room emits to Redis pub/sub and every instance delivers to its local members. Background workers emit through the same bus. If the long-polling fallback is enabled, the load balancer needs sticky sessions; with WebSocket-only transport it does not. I also watch connection counts per instance, file descriptor limits and memory per socket, and spread reconnects with jitter. For very large scale, a hosted service like Pusher or Ably, or a dedicated gateway tier, removes this work from the API servers.
Q3How do you authorise private channels?
Two steps. First, authenticate the connection itself: verify a token or session during the handshake and reject unauthenticated sockets. Second, authorise each channel or room on the server. In socket.io I compute room membership from the verified user, for example their tenant, branch and user id, instead of trusting a room name the client sends. In Laravel, private and presence channels go through routes/channels.php, where a callback checks that the user owns or may see the resource. Payloads should still contain only what that audience may see, and tokens should be re-validated on reconnect.
Q4What happens to messages sent while a client is disconnected?
With plain pub/sub they are gone, because the server delivers only to sockets connected at that moment. So I design the socket as a notification channel, not a database. On reconnect the client refetches the current state from the REST API, which is the source of truth. Events carry a version or updated-at value so the client can discard stale updates. Where missing a message really matters, I use acknowledgements with retries or a persisted event log the client can read from its last cursor. socket.io's connection state recovery helps for short disconnects only.
Q5Pusher, Reverb or your own socket.io server?
Pusher is fastest to ship: no servers, global infrastructure, good client libraries. The trade-off is cost growing with connections and messages, and data passing through a third party. Reverb is a good middle ground for Laravel apps: self-hosted, Pusher protocol compatible, so Echo works unchanged, and it can scale horizontally with Redis. A custom socket.io server makes sense in a Node backend that needs rich two-way logic, custom rooms and acknowledgements, as in a POS with terminals. I choose by team stack, expected connections, budget and whether operations staff can run another service reliably.
Senior-level questions
S1How would you design realtime for 100,000 concurrent connections?
Separate the socket tier from the API. A dedicated fleet of gateway instances holds connections and does almost no business logic; it authenticates at the handshake, joins rooms, and forwards client actions to the API or a queue. Instances share emits through a sharded Redis adapter or a message bus, and background workers publish events onto that bus. Each gateway node gets raised file limits and kernel tuning, and I load test to find realistic connections per node, often tens of thousands for light traffic. Reconnects use exponential backoff with jitter so a deploy does not create a thundering herd, and deploys drain nodes gradually. Payloads are small and coalesced. The alternative is a hosted service such as Ably or Pusher, trading cost per connection for zero operations.
S2How do you guarantee the order of realtime events?
Across a distributed system you can only guarantee order where there is a single sequence. A single WebSocket connection is ordered, but events may come from several API instances and workers, so they can be emitted out of order. I give each entity a monotonically increasing version, set by the database inside the same transaction as the change, and include it in every event. Clients keep the highest version per entity and ignore anything older, and if they detect a gap they refetch that entity from the API. When strict processing order matters, as with commands to a terminal, I route all messages for that entity through one queue partition or one worker. The trade-off is throughput: per-entity ordering is cheap, global ordering is expensive and rarely needed.
S3How do you handle authentication on connections that stay open for hours?
Verify the token at the handshake, but do not trust it forever. Short-lived access tokens expire long before a kitchen screen disconnects. I schedule a disconnect at token expiry, and the client refreshes its token through the normal API and reconnects; alternatively the client sends a fresh token over the socket and the server re-validates it and extends the deadline. Revocation must also reach live connections: when a user is disabled or their sessions are revoked, publish an event that makes every gateway disconnect that user's sockets. With cookie auth I check Origin against an allow-list at the handshake. Every message still gets authorization for its specific action, because a valid connection does not mean every action is allowed.
S4Should business actions go over the WebSocket or through the REST API?
My default is REST for commands and the socket for notifications. REST gives you status codes, idempotency keys, middleware, rate limiting, logging and tests for free, and the same endpoint serves clients that have no socket. The socket then announces 'order 42 changed, version 7'. Sending actions over the socket makes sense when latency and frequency are high and the messages are small, such as collaborative cursors, games, chat typing indicators or a POS terminal streaming status, because it avoids an HTTP request per message. In that case I rebuild the HTTP discipline explicitly: schema validation, permission checks, acknowledgements with result codes, and idempotency ids for retries. The trade-off is lower latency versus losing well-understood HTTP tooling.
Glossary
- Frame
- The unit of data on a WebSocket: text, binary, or control frames such as ping, pong and close.
- Heartbeat
- Regular ping and pong messages that keep a connection alive and reveal peers that silently disappeared.
- Room
- A server-side group of sockets in socket.io; emitting to a room reaches every member.
- Adapter
- The socket.io component that tracks rooms and, with Redis, forwards emits between server instances.
- Sticky session
- Load balancer routing that keeps one client's requests on the same backend instance.
- Fan-out
- Delivering one event to many recipients, such as every socket in a room.
- Presence channel
- A channel that also tracks who is subscribed, used for 'who is online' features.
- Volatile emit
- A message that may be dropped if the client is not ready, fine for constantly refreshed data.
- CSWSH
- Cross-site WebSocket hijacking: a malicious page opening a socket with the victim's cookies; prevented by checking Origin.
Keep learning
Have a product in mind?Let's build it.
Tell me about your idea, your product or your team. I usually reply within one working day.