WebSocket Real-Time
Build a live-updating app with WebSockets backed by Redis pub/sub so updates push to every connected client instantly. Everything runs locally on kindling.
What you'll build
A real-time notification feed:
- API posts events (new order, status change, etc.)
- Redis pub/sub fans events out to all subscribers
- WebSocket server pushes events to connected browsers
┌──────────┐ WS ┌───────────────┐ pub/sub ┌───────────┐
│ Browser │◀─────▶│ FastAPI │◀─────────▶│ Redis │
│ │ │ (WS + HTTP) │ │ │
└──────────┘ └───────────────┘ └───────────┘
│ ▲
│ HTTP POST /events │
└────────────────────┘
Project structure
realtime-app/
├── Dockerfile
├── requirements.txt
├── main.py
└── static/
└── index.html
requirements.txt
fastapi==0.115.0
uvicorn[standard]==0.30.0
redis==5.1.0
websockets==13.0
main.py
import asyncio
import json
import os
import redis.asyncio as aioredis
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
app = FastAPI(title="Real-Time Feed")
REDIS_URL = os.environ["PUBSUB_CACHE_URL"]
CHANNEL = "events"
class ConnectionManager:
"""Track active WebSocket connections."""
def __init__(self):
self.active: list[WebSocket] = []
async def connect(self, ws: WebSocket):
await ws.accept()
self.active.append(ws)
def disconnect(self, ws: WebSocket):
self.active.remove(ws)
async def broadcast(self, message: str):
for ws in self.active[:]:
try:
await ws.send_text(message)
except Exception:
self.active.remove(ws)
manager = ConnectionManager()
@app.on_event("startup")
async def start_subscriber():
"""Background task that listens to Redis pub/sub and broadcasts."""
async def _listen():
r = aioredis.from_url(REDIS_URL)
pubsub = r.pubsub()
await pubsub.subscribe(CHANNEL)
async for msg in pubsub.listen():
if msg["type"] == "message":
await manager.broadcast(msg["data"].decode())
asyncio.create_task(_listen())
@app.websocket("/ws")
async def websocket_endpoint(ws: WebSocket):
await manager.connect(ws)
try:
while True:
# Keep connection alive; client can also send messages
await ws.receive_text()
except WebSocketDisconnect:
manager.disconnect(ws)
@app.post("/events")
async def publish_event(event_type: str, message: str = ""):
"""Publish an event to all connected clients via Redis pub/sub."""
r = aioredis.from_url(REDIS_URL)
payload = json.dumps({"type": event_type, "message": message})
await r.publish(CHANNEL, payload)
await r.close()
return {"published": True, "type": event_type}
@app.get("/", response_class=HTMLResponse)
async def index():
return open("static/index.html").read()
@app.get("/health")
async def health():
return {"status": "ok"}
static/index.html
<!DOCTYPE html>
<html>
<head>
<title>Live Feed</title>
<style>
body { font-family: system-ui; max-width: 600px; margin: 40px auto; }
#feed { list-style: none; padding: 0; }
#feed li { padding: 8px 12px; margin: 4px 0; background: #f0f4f8; border-radius: 6px; }
.type { font-weight: 600; color: #2563eb; }
</style>
</head>
<body>
<h1>Live Event Feed</h1>
<p id="status">Connecting...</p>
<ul id="feed"></ul>
<script>
const ws = new WebSocket(`ws://${location.host}/ws`);
const feed = document.getElementById("feed");
const status = document.getElementById("status");
ws.onopen = () => { status.textContent = "Connected"; };
ws.onclose = () => { status.textContent = "Disconnected"; };
ws.onmessage = (e) => {
const event = JSON.parse(e.data);
const li = document.createElement("li");
li.innerHTML = ``;
feed.prepend(li);
};
</script>
</body>
</html>
Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
kindling setup
Workflow
name: dev-deploy
on:
push:
branches: [main]
workflow_dispatch:
env:
REGISTRY: registry:5000
TAG: ${{ github.actor }}-${{ github.sha }}
jobs:
deploy:
runs-on: [self-hosted, "${{ github.actor }}"]
steps:
- uses: actions/checkout@v4
- run: rm -rf /builds/*
- name: Build
uses: kindling-sh/kindling/.github/actions/kindling-build@main
with:
name: realtime-app
context: ${{ github.workspace }}
image: "${{ env.REGISTRY }}/realtime-app:${{ env.TAG }}"
- name: Deploy
uses: kindling-sh/kindling/.github/actions/kindling-deploy@main
with:
name: ${{ github.actor }}-realtime-app
image: "${{ env.REGISTRY }}/realtime-app:${{ env.TAG }}"
port: "8000"
ingress-host: "${{ github.actor }}-realtime.localhost"
health-check-path: "/health"
dependencies:
- type: redis
name: pubsub-cache
Try it
- Open
http://<you>-realtime.localhostin a browser tab - Publish events from another terminal:
# Publish events
curl -X POST "http://<you>-realtime.localhost/events?event_type=order&message=Order+%23123+placed"
curl -X POST "http://<you>-realtime.localhost/events?event_type=deploy&message=v2.1+deployed+to+staging"
Events appear in the browser instantly — no refresh needed.
Iterate
kindling sync -n <you>-realtime-app -d .
# Edit the HTML, add event filtering, change the pub/sub channel
# structure — WebSocket connections reconnect automatically
Tips
- Multiple channels: use separate Redis channels per event type
(
events:orders,events:deploys) and let clients subscribe to specific ones - Scaling: with Redis pub/sub, you can run multiple replicas of the WebSocket server and every client still gets every event
- Open two browser tabs side by side to verify broadcast works