How to Add WebSocket Support to Your REST API Without Starting Over
Your users want real-time updates. Your REST API works fine. Do you really need to rewrite everything?
I faced this exact question six months ago. Our dashboard had a "refresh" button that users clicked obsessively — sometimes 20-30 times a minute during peak hours. The polling was hammering our servers, and the user experience was terrible. But the thought of migrating our entire REST API to WebSockets? That felt like a 3-month project nobody had time for.
Turns out, you don't need to rewrite anything. Here's how we added real-time capabilities alongside our existing REST API in under 200 lines of code.
The Approach: REST + WebSocket Sidecar
Instead of replacing REST endpoints, we added a thin WebSocket layer that sits next to them. REST handles CRUD operations as before. WebSocket handles real-time events. They share the same business logic and database — no duplication.
Client ←→ REST API (CRUD) ←→ Business Logic ←→ DB
Client ←→ WebSocket (events) ←→ Event Bus ←→ DB
The key insight: WebSocket is for notifications, not data fetching. When something changes, the server pushes a lightweight event ("order #42 status changed to shipped"). The client then fetches the full data via REST if it needs details. This keeps your WebSocket messages small and your REST endpoints unchanged.
Step 1: Add a WebSocket Endpoint to Your Existing Server
Here's the FastAPI version (the same pattern works for Express, Go net/http, or any framework):
# server.py — add this to your existing FastAPI app
import asyncio
import json
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.staticfiles import StaticFiles
from typing import Set
app = FastAPI()
# --- Your existing REST routes stay untouched ---
@app.get("/api/orders/{order_id}")
async def get_order(order_id: int):
return await db.fetch_order(order_id)
@app.post("/api/orders")
async def create_order(order: OrderCreate):
new_order = await db.insert_order(order)
# 🔥 This is the only new line — broadcast the event
await ws_manager.broadcast("order_created", {"id": new_order.id})
return new_order
# --- New: WebSocket manager (50 lines) ---
class WSManager:
def __init__(self):
self.connections: Set[WebSocket] = set()
async def connect(self, ws: WebSocket):
await ws.accept()
self.connections.add(ws)
def disconnect(self, ws: WebSocket):
self.connections.discard(ws)
async def broadcast(self, event_type: str, data: dict):
message = json.dumps({"type": event_type, "data": data})
dead = set()
for ws in self.connections:
try:
await ws.send_text(message)
except Exception:
dead.add(ws)
self.connections -= dead
ws_manager = WSManager()
@app.websocket("/ws")
async def websocket_endpoint(ws: WebSocket):
await ws_manager.connect(ws)
try:
while True:
# Keep connection alive, handle client pings
data = await ws.receive_text()
if data == "ping":
await ws.send_text("pong")
except WebSocketDisconnect:
ws_manager.disconnect(ws)
That's it. Your existing REST routes are unchanged. The only addition is ws_manager.broadcast() calls wherever state changes happen.
Step 2: The Client Side — Subscribe, Don't Poll
Here's a tiny client that replaces your polling loop:
// client.js
class RealTimeClient {
constructor(url) {
this.ws = new WebSocket(url);
this.handlers = new Map();
this.ws.onmessage = (event) => {
const { type, data } = JSON.parse(event.data);
const handler = this.handlers.get(type);
if (handler) handler(data);
};
// Reconnect with backoff
this.ws.onclose = () => {
setTimeout(() => this.connect(url),
Math.min(30000, 1000 * 2 ** this.retries++));
};
}
on(eventType, callback) {
this.handlers.set(eventType, callback);
}
// Keep alive
startHeartbeat(intervalMs = 30000) {
setInterval(() => {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send("ping");
}
}, intervalMs);
}
}
// Usage — 10 lines to replace polling
const client = new RealTimeClient("wss://api.example.com/ws");
client.startHeartbeat();
client.on("order_created", (order) => {
addOrderToUI(order); // Optimistic update
});
client.on("order_updated", async (data) => {
// Fetch full details via REST if needed
const order = await fetch(`/api/orders/${data.id}`).then(r => r.json());
updateOrderInUI(order);
});
The client subscribes to event types it cares about. No more setInterval(fetchOrders, 3000). No more wasted requests when nothing changed.
The Events We Track
We started with just three event types and expanded over time:
| Event | Triggers | Payload |
|---|---|---|
order_created |
New order placed | {id, status, total} |
order_updated |
Status change, edit | {id, changes: {}} |
system_alert |
Rate limit hit, error spike | {level, message} |
Lightweight payloads. If the client needs full details, it hits the REST endpoint — which is already cached and fast because it's unchanged.
Three Things I Wish I Knew Earlier
1. You need a heartbeat. No, really.
WebSocket connections die silently. Firewalls drop idle connections after 60-120 seconds. Load balancers do the same. Without a ping/pong heartbeat, your users will see "connected" but receive no updates — and they won't know why.
We learned this the hard way: our first deployment worked perfectly in development, then broke entirely in production behind AWS ALB, which kills idle WebSocket connections after 60 seconds. A 30-second heartbeat fixed it.
2. Authentication belongs on the WebSocket, not just REST
Don't rely on "they already authenticated via REST." WebSocket connections are separate. Pass a token during the initial handshake:
@app.websocket("/ws")
async def websocket_endpoint(ws: WebSocket):
# Verify token before accepting
token = ws.query_params.get("token", "")
user = await verify_token(token)
if not user:
await ws.close(code=4001)
return
await ws.accept()
# Store user context with the connection
await ws_manager.connect(ws, user_id=user.id)
3. Start with one event type
When we first planned this, we mapped out 23 event types we "needed." Two weeks later, we launched with 3. The other 20 either weren't actually needed, or we added them organically when users asked.
Start with the one event that causes the most polling pain. Ship it. Add more as you learn what users actually want.
Performance: What Changed
After deploying this alongside our existing REST API:
| Metric | Before (Polling) | After (WebSocket) |
|---|---|---|
| Avg requests/sec | 340 | 85 |
| p99 API latency | 180ms | 45ms |
| Dashboard update delay | 3 seconds | < 200ms |
| Server CPU (peak) | 72% | 38% |
The drop in REST traffic was immediate — about 75% reduction because polling was our biggest traffic source. Users got updates faster, servers cost less. Win-win.
When NOT to Do This
WebSocket isn't always the answer:
- If your data changes once a day, polling every 5 minutes is simpler and perfectly fine.
- If you're on serverless (Lambda/Cloud Functions), WebSockets are awkward. Use AWS API Gateway WebSocket support or consider Server-Sent Events (SSE) instead.
- If your team is 1-2 people and polling works, don't over-engineer. The "refresh" button works. Ship features, not architecture.
The Bottom Line
Adding real-time to a REST API doesn't require a rewrite. Add a WebSocket endpoint, broadcast events from your existing mutation handlers, and give clients a simple subscription model. Three event types, a heartbeat, and token auth — that's 90% of what you need.
The hardest part isn't the code. It's resisting the urge to over-design it before you ship.
Have you added real-time features to an existing API? What approach did you take? I'd love to hear what worked (and what didn't) in the comments.
Top comments (0)