Technology·13 min read

How to Implement Real-Time Features Without Overengineering

Not every feature needs WebSockets. Here is how to pick the right real-time approach for your use case and avoid building infrastructure you do not need.

N

Nate Laquis

Founder & CEO ·

Do You Actually Need Real-Time?

Most teams reach for WebSockets the moment a product manager says the word "live." That instinct leads to unnecessary complexity, higher infrastructure costs, and maintenance overhead that outlasts the feature itself. Before you write a single line of socket code, answer one question: how stale can this data be?

Real-time analytics dashboard showing live data updates

If the answer is "a few seconds is fine," polling is probably the right tool. A simple setInterval call that hits your REST API every 5 seconds is trivially easy to build, debug, and scale. It works with standard HTTP infrastructure, plays nicely with CDN caching on some endpoints, and requires zero changes to your server architecture. For features like "last seen" timestamps, notification counts, or infrequently updated dashboards, polling is not a compromise. It is the correct choice.

If the answer is "under a second," you need a push-based approach. The decision framework from there is straightforward. Ask whether the server pushes data to the client only, or whether the client also sends data back in real time. Server-only push covers a wide range of use cases: live dashboards, feed updates, progress tracking, and notifications. For these, Server-Sent Events (SSE) are often the right tool and far simpler to implement than WebSockets. Bidirectional real-time communication, where the client sends events back to the server continuously and latency matters in both directions, is where WebSockets belong: chat, collaborative document editing, multiplayer games, and live cursor tracking.

The Decision Framework

  • Data can be a few seconds stale: Use polling. Simple, cheap, no new infrastructure.
  • Server pushes updates to clients, under 1 second: Use Server-Sent Events.
  • Bidirectional, low-latency, high-frequency events: Use WebSockets.
  • Occasional real-time bursts (notifications, alerts): Use a managed push service.

The mistake teams make is jumping straight to WebSockets without evaluating whether SSE or polling solves the problem at a fraction of the cost. Get the decision right upfront and you will save weeks of engineering work.

Server-Sent Events: The Underrated Option

Server-Sent Events are the most underused tool in the real-time toolkit. They solve a large class of real-time problems with a simple API, native browser support, and no additional infrastructure. If your use case is server-to-client updates only, SSE should be your first choice, not WebSockets.

SSE works over a standard HTTP connection. The server holds the connection open and pushes text-based event streams to the client. The browser's built-in EventSource API handles reconnection automatically when the connection drops, which eliminates a category of reliability work you would otherwise have to build yourself. Browser support is universal across modern browsers, including Chrome, Firefox, Safari, and Edge.

What SSE Handles Well

  • Live dashboard data: stock prices, analytics metrics, sensor readings
  • Progress updates: file uploads, background job status, report generation
  • News feeds and activity streams: social timelines, audit logs, event feeds
  • Notification delivery: in-app alerts that appear without a page refresh
  • AI streaming responses: token-by-token output from LLM APIs (this is exactly how ChatGPT and most AI interfaces stream text)

Basic Implementation

On the server, set the Content-Type header to text/event-stream, disable response buffering, and write formatted event strings to the response. In Node.js with Express, this is about 15 lines of code. On the client, instantiate new EventSource('/api/stream') and attach an onmessage handler. That is the entire API surface.

The one real limitation of SSE is that it uses HTTP/1.1 connections, and browsers limit the number of concurrent connections per domain to 6. If a user opens multiple tabs, each tab consumes a connection. HTTP/2 solves this with multiplexing, but not all SSE server implementations support it cleanly. For most applications, this limit is not a practical constraint. If you need hundreds of concurrent streams per user, you are in WebSocket territory.

SSE is also not suitable when you need the client to send high-frequency data back to the server. Standard fetch or XHR calls work fine for occasional client messages on top of an SSE stream, but if client-to-server events are frequent and latency-sensitive, you need a full duplex channel.

WebSockets: When You Need Bidirectional Communication

WebSockets establish a persistent, full-duplex TCP connection between client and server. Both sides can send messages at any time without the overhead of HTTP headers on each message. That efficiency makes WebSockets the right choice for applications where high-frequency, low-latency bidirectional communication is a core requirement, not a nice-to-have.

Use Cases Where WebSockets Are Worth the Complexity

Chat applications are the canonical use case. Messages travel from sender to server to recipients in under 100ms. Presence indicators (typing, online status) update continuously. The client sends messages and receives them simultaneously without polling. A chat feature built on polling or SSE plus HTTP requests feels sluggish compared to a WebSocket implementation.

Collaborative editing tools like Figma, Notion, and Google Docs use WebSockets to synchronize document state across multiple users in real time. Operational transforms or CRDTs handle conflict resolution, but the transport layer is WebSocket. The Y.js library, which implements CRDTs for JavaScript, has WebSocket provider implementations that handle most of the synchronization complexity for you.

Multiplayer games require low-latency bidirectional communication for player positions, game state, and events. Even a 200ms delay is noticeable in a fast-paced game. WebSockets reduce that latency to 10 to 50ms for most users on decent connections.

Live cursor tracking and whiteboard tools send cursor position events dozens of times per second. HTTP requests at that frequency would generate significant overhead. WebSockets handle this efficiently.

What WebSockets Add to Your Stack

Persistent connections mean your server holds state for every connected client. Load balancers need sticky sessions or shared state to route reconnections correctly. You need connection lifecycle management: tracking who is connected, cleaning up on disconnect, and handling reconnections gracefully. None of this is hard, but all of it is additional surface area compared to stateless HTTP. If your real-time requirements do not clearly justify this complexity, use SSE.

Managed Services vs Self-Hosted

Once you have decided WebSockets or SSE are the right choice, the next decision is whether to run the infrastructure yourself or pay for a managed service. The answer depends on your team size, scale, and how much of your engineering budget you want to spend on infrastructure versus product.

Developer implementing real-time WebSocket features on laptop

Managed Services Worth Knowing

Pusher Channels is the most widely used managed WebSocket service. It handles connection management, scaling, and presence out of the box. The free tier covers 100 concurrent connections and 200,000 messages per day, which is enough for development and early production. The paid Startup plan at $49/month covers 500 concurrent connections. At scale, Pusher gets expensive: 10,000 concurrent connections runs roughly $500 to $800/month. Pusher has client libraries for every major platform and a clean API that takes an afternoon to integrate.

Ably is a more capable and more expensive alternative to Pusher. It offers stronger delivery guarantees, history and message persistence, and better support for complex pub/sub patterns. The free tier is generous at 6 million messages per month. Paid plans start at $29/month. Ably is worth the premium if you need message history, ordering guarantees, or are building something where dropped messages are costly.

Supabase Realtime is compelling if you are already using Supabase for your database. It broadcasts database changes to subscribed clients over WebSockets with minimal configuration. You subscribe to a table and receive inserts, updates, and deletes in real time. For data-driven dashboards and activity feeds, this is a genuinely excellent developer experience. Included in Supabase's free tier with generous limits on paid plans.

Self-Hosted Options

Socket.io is the most popular self-hosted WebSocket library for Node.js. It adds rooms, namespaces, automatic reconnection, and fallback to long-polling for environments where WebSockets are blocked. The tradeoff: you own the infrastructure. Socket.io running on a single server handles roughly 10,000 concurrent connections. Scaling beyond that requires Redis pub/sub for cross-server message delivery and sticky sessions on your load balancer.

For most teams shipping their first real-time feature, a managed service is the right call. You trade a monthly fee for months of engineering work on connection management, reliability, and scaling.

Implementation Patterns That Scale

Regardless of whether you use WebSockets or SSE, managed or self-hosted, a few implementation patterns consistently produce more reliable and scalable real-time features. Getting these right from the start saves painful refactoring later.

Pub/Sub as the Core Pattern

Publish-subscribe is the foundation of scalable real-time architectures. Publishers emit events to named channels or topics without knowing who is listening. Subscribers receive events from channels they care about without knowing who published them. This decoupling means you can add subscribers (new clients, new features) without touching publisher code, and you can scale publishers and subscribers independently.

Whether you are using Pusher channels, Ably topics, Socket.io rooms, or raw Redis pub/sub, you are implementing this pattern. Name your channels deliberately. A channel per resource (e.g., order:123, user:456:notifications) scales better than a single global channel, reduces unnecessary message delivery, and makes access control straightforward.

Rooms and Channels for Scoping

Rooms (Socket.io terminology) and channels (Pusher/Ably terminology) let you scope message delivery. When a user joins a chat room, subscribe their connection to that room's channel. Broadcast messages only to the relevant room, not to every connected client. This pattern is essential for any multi-tenant application. Without scoping, every client receives every message, which is both a performance problem and a data privacy issue.

Presence: Knowing Who Is Connected

Presence lets clients know which other users are currently connected to a channel. This is how "3 people are viewing this document" or online/offline status works. Both Pusher and Ably have built-in presence APIs. With Socket.io, you track connected sockets per room manually using a Map or Redis sorted set. Presence data must be cleaned up on disconnect, including unexpected disconnects where the client disappears without sending a close event. Use heartbeat timeouts to detect and clean up stale presence entries.

Connection Management

Connections drop. Mobile users switch networks. Laptops sleep. Design your client-side connection management to handle this gracefully. Implement exponential backoff on reconnection attempts: wait 1 second, then 2, then 4, up to a maximum of 30 seconds. On reconnect, re-subscribe to channels and request any missed events since the last known event ID. Most managed services provide event history for exactly this purpose.

Handling Disconnections and State Sync

The hardest part of real-time systems is not the happy path. It is what happens when connections drop, events are missed, and clients reconnect with stale state. Getting this right separates production-grade real-time features from demos that break under real conditions.

Reconnection Strategies

Your client must assume it will disconnect. The EventSource API for SSE handles reconnection automatically using the Last-Event-ID header: the server sends an ID with each event, and on reconnect the browser resends that ID so the server can replay missed events. For WebSockets, you implement reconnection logic in your client code. Libraries like reconnecting-websocket handle the boilerplate. The pattern is: on close, schedule a reconnect with exponential backoff, then re-authenticate and re-subscribe on successful reconnection.

Optimistic Updates

For user-initiated actions (sending a message, editing a record, placing an order), apply the change to your local UI state immediately without waiting for server confirmation. When the server confirms the action, reconcile the confirmed state. If the server rejects it, roll back. This pattern makes your UI feel instant even on high-latency connections. React Query and SWR both have built-in optimistic update APIs. For more complex cases, libraries like Zustand with immer make the rollback logic manageable.

Conflict Resolution

When multiple users edit the same resource simultaneously, you get conflicts. The simplest strategy is last-write-wins: the most recent update replaces earlier ones. This works for most cases but loses data when two users edit simultaneously. Operational transforms (OT) and CRDTs (Conflict-free Replicated Data Types) are the production solutions for collaborative editing. Y.js implements CRDTs and integrates with ProseMirror, CodeMirror, Quill, and other editors. For most applications outside of document editing, last-write-wins with optimistic locking (using a version number or timestamp to detect conflicts before writing) is sufficient and far simpler to implement.

Event Sourcing for Auditability

If your real-time feature involves state that must be reconstructed after reconnection, consider persisting events rather than just the current state. An append-only event log lets any client catch up by replaying events since their last known position. This is more infrastructure to manage, but it eliminates an entire class of synchronization bugs and gives you a complete audit trail as a side effect. Services like Ably and Pusher offer message history APIs that implement this pattern without requiring you to build the storage layer.

Real-Time at Scale: What Changes

The real-time patterns that work on a single server stop working when you add a second server. A WebSocket connection is stateful and lives on one machine. A message published on server A does not automatically reach clients connected to server B. This is the core scaling challenge for self-hosted real-time infrastructure.

Dashboard displaying real-time metrics and live data streams

Redis Pub/Sub for Cross-Server Messaging

The standard solution for Socket.io at scale is the socket.io-redis adapter (now @socket.io/redis-adapter). When a message is published to a room, the adapter publishes it to a Redis pub/sub channel. Every server instance subscribes to that channel and delivers the message to locally connected clients in that room. This architecture scales horizontally: add more application servers behind a load balancer and Redis ensures message delivery across all of them. A managed Redis instance (AWS ElastiCache, Redis Cloud, Upstash) runs $20 to $100/month at typical real-time application scales.

Sticky Sessions

Load balancers must route each client's connections to the same server instance, otherwise WebSocket upgrade handshakes fail and reconnection logic breaks. AWS ALB, NGINX, and HAProxy all support sticky sessions via cookie-based affinity. Configure your load balancer to set a session cookie on the first request and route subsequent requests from the same client to the same backend. This is a simple configuration change but a required one. Sticky sessions mean uneven load distribution when a server has many long-lived connections. Monitor per-instance connection counts and tune your balancing accordingly.

Horizontal Scaling Limits

Redis pub/sub has its own throughput ceiling. At very high message volumes (tens of thousands of messages per second), a single Redis instance becomes the bottleneck. Redis Cluster shards the keyspace across multiple nodes but complicates pub/sub routing. At this scale, dedicated message brokers like Apache Kafka or AWS Kinesis provide better throughput and durability guarantees, though they add significant operational complexity.

If you are using a managed service like Pusher or Ably, these scaling challenges are handled for you. That is a large part of what you are paying for. At high message volumes, the economics of managed services versus self-hosted infrastructure shift, and the build-vs-buy decision deserves a fresh look at each scale tier.

Cost Analysis and When to Upgrade

Real-time features have a pricing model that surprises teams who do not plan for it. Unlike REST APIs where you pay per request at modest rates, WebSocket-based services charge for concurrent connections and message volume, both of which grow faster than user count as your product becomes more engaging.

Managed Service Pricing at Scale

Pusher's pricing is straightforward. The free tier covers 100 concurrent connections and 200,000 messages per day. The Startup plan at $49/month covers 500 connections and 5 million messages per day. The Business plan at $99/month covers 2,000 connections. Above that, you move to custom enterprise pricing, which typically runs $0.40 to $0.70 per 1,000 connections per month. At 50,000 concurrent connections, you are looking at $2,000 to $5,000/month just for Pusher.

Ably prices on message volume rather than connections. The free tier includes 6 million messages per month. Paid plans start at $29/month for 20 million messages. At 1 billion messages per month, costs are in the range of $1,500 to $3,000/month depending on your plan. Ably is generally more cost-effective than Pusher at high message volumes.

Self-Hosted Cost Comparison

Running Socket.io with a Redis adapter on AWS: two application servers at $100 to $200/month each, an ElastiCache Redis instance at $60 to $150/month, a load balancer at $25/month. Total: roughly $300 to $600/month for infrastructure that handles 20,000 to 50,000 concurrent connections, assuming your servers are sized correctly. The engineering cost to build, maintain, monitor, and debug this infrastructure is typically 0.5 to 1 week of engineering time upfront and ongoing maintenance. For a team billing at $150/hour, the break-even against Pusher pricing is at roughly 5,000 to 10,000 concurrent connections.

The Build vs. Buy Inflection Point

Use a managed service until the monthly cost exceeds what it would cost to build and maintain the equivalent infrastructure. For most teams, that inflection point is $500 to $1,000/month in managed service fees. Below that, the engineering time saved easily justifies the cost. Above it, evaluate whether your team has the expertise to operate WebSocket infrastructure reliably at your scale. Many teams that cross the inflection point still choose to stay on managed services because reliability and reduced operational load are worth the premium.

Real-time features are one of the areas where the technology choices you make early have lasting cost implications. If you are designing a real-time architecture and want to get the build-vs-buy decision right the first time, Book a free strategy call and we will help you model the costs and pick the right approach for your scale and team.

Need help building this?

Our team has launched 50+ products for startups and ambitious brands. Let's talk about your project.

real-time featuresWebSocketsserver-sent eventsPusherreal-time architecture

Ready to build your product?

Book a free 15-minute strategy call. No pitch, just clarity on your next steps.

Get Started