SSE Advantages:
- Simpler protocol (HTTP-based)
- Auto-reconnect built-in
- Works through proxies/firewalls
- Lower overhead for unidirectional streaming
- Native browser support (EventSource API)
When to use WebSockets:
- Bidirectional communication needed
- Binary data transfer
- Lower latency requirements (<10ms)
Alternatives Considered:
| Solution | Pros | Cons | Verdict |
|---|---|---|---|
| asyncio.Queue | Native, non-blocking, bounded | In-memory only | ✅ Best for single-server |
| Redis Pub/Sub | Multi-server, persistent | Network overhead, no backpressure | Use for horizontal scaling |
| Kafka | High throughput, replay | Complex setup, overkill | Use for event sourcing |
| In-memory list | Simple | No backpressure, memory leak risk | ❌ Production unsafe |
Decision: asyncio.Queue for single-server deployments. Migrate to Redis Streams for multi-server.
Problem: Slow client can exhaust server memory.
Solutions Evaluated:
- Blocking put() - ❌ Blocks all clients
- Unbounded queue - ❌ Memory exhaustion
- Drop events - ❌ Data loss
- Disconnect slow clients - ✅ Isolates failure
Implementation:
try:
queue.put_nowait(event) # O(1), never blocks
except asyncio.QueueFull:
disconnect_client() # Fail fastNaive approach (O(N²)):
for client in clients:
await queue.put(event) # Blocks on slow clientOptimized approach (O(N)):
for client in clients:
queue.put_nowait(event) # Never blocksKey insight: Non-blocking operations enable true O(1) per-client broadcast.
Per-client memory:
- Queue: 8KB + (100 events × 1KB) = ~108KB
- Metadata: ~1KB
- Total: ~110KB per client
1000 clients = ~110MB
Protection mechanisms:
- Bounded queues (maxsize=100)
- Automatic client cleanup
- Event size limits (implicit via JSON)
- No global state accumulation
Lifecycle management:
@asynccontextmanager
async def lifespan(app):
# Startup
await start_producers()
yield
# Shutdown
await stop_producers() # Cancel tasks
# Clients auto-disconnect on server closeWhy this matters:
- No orphaned tasks
- Clean resource cleanup
- No data loss (in-flight events complete)
Single server:
- Events/sec: 10,000+
- Clients: 1,000+
- Latency: <10ms (p99)
Bottlenecks:
- Network bandwidth (1Gbps = ~125MB/s)
- CPU (JSON serialization)
- Memory (client count × queue size)
Vertical (single server):
Max clients = Available_Memory / (Queue_Size × Event_Size)
= 8GB / (100 × 1KB)
= ~80,000 clients
Horizontal (multi-server):
- Use Redis Pub/Sub or Streams
- Load balancer with sticky sessions
- Shared event bus
Critical metrics:
connected_clients- Current connectionsevents_per_second- Throughputqueue_depth_p99- Backpressure indicatordisconnect_rate- Client health
Alerting thresholds:
- Disconnect rate > 10% → Network issues
- Queue depth > 80% → Slow clients
- Memory > 80% → Scale up
Authentication:
from fastapi import Depends, HTTPException
from fastapi.security import HTTPBearer
security = HTTPBearer()
async def verify_token(token: str = Depends(security)):
if not validate_jwt(token):
raise HTTPException(401)
return token
@app.get("/stream")
async def stream(token: str = Depends(verify_token)):
# ... stream logicRate limiting:
from slowapi import Limiter
limiter = Limiter(key_func=get_remote_address)
@app.get("/stream")
@limiter.limit("10/minute")
async def stream(request: Request):
# ... stream logicUvicorn workers:
# CPU-bound: workers = CPU_cores
uvicorn main:app --workers 4
# I/O-bound: workers = CPU_cores × 2
uvicorn main:app --workers 8Nginx reverse proxy:
location /stream {
proxy_pass http://localhost:8000;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
}When to migrate:
- Multiple servers needed
- Event persistence required
- Replay functionality needed
Design:
class RedisStreamManager:
async def broadcast(self, event):
await redis.xadd('events', {'data': event.json()})
async def consume(self, client_id, last_id='$'):
async for message in redis.xread({'events': last_id}):
yield parse_event(message)Benefits:
- Multi-server support
- Event persistence
- Consumer groups
- Replay from any point
Trade-offs:
- Network latency (+1-5ms)
- Redis dependency
- More complex ops
Unit tests:
- StreamManager.broadcast()
- Event serialization
- Queue backpressure
Integration tests:
- Full SSE flow
- Client disconnect
- Producer lifecycle
Load tests:
# Simulate 1000 concurrent clients
async def load_test():
tasks = [connect_client() for _ in range(1000)]
await asyncio.gather(*tasks)This architecture provides:
- ✅ Production-ready reliability
- ✅ Predictable performance
- ✅ Memory safety
- ✅ Horizontal scalability path
- ✅ Simple operations
Trade-offs accepted:
- Single-server limitation (mitigated by Redis migration path)
- In-memory only (acceptable for real-time use case)
- Slow clients disconnected (correct behavior)
Next steps:
- Add authentication
- Implement monitoring
- Load test with realistic traffic
- Plan Redis migration if multi-server needed