DEV Community

Sir Max
Sir Max

Posted on

How to Add WebSocket Support to Your REST API Without Starting Over

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)