Realtime Strategy
Overview¶
SpeedPy is a synchronous Django application deployed behind WSGI (Gunicorn). It does not ship realtime infrastructure out of the box — and for most SaaS products built on SpeedPy, that is the right default.
This document helps fork owners choose the right server-push pattern when a feature genuinely needs it. The goal is to delay infrastructure complexity until the use case demands it, and to pick the simplest option that satisfies the requirement.
Decision matrix¶
| Use case | Recommended pattern | Why |
|---|---|---|
| Background job progress (imports, exports, AI generation) | Polling or SSE | Unidirectional, short-lived, no ASGI required for long polling |
| Simple notifications (toast, badge count) | SSE or polling | Server-to-client only; SSE is a single persistent connection |
| AI/LLM token streaming | SSE | Matches the streaming response pattern used by OpenAI, Anthropic, etc. |
| Collaborative editing / cursors | WebSockets (Django Channels) | Requires low-latency bidirectional state sync |
| Real-time dashboards (live charts, metrics) | SSE | Server pushes periodic updates; client never sends data |
| Chat / messaging | WebSockets (Django Channels) | Bidirectional, high-frequency, persistent connection |
| Webhook/event fanout to many consumers | External event bus (Redis Pub/Sub, NATS, Kafka) | Decouples producers from consumers; scales independently |
The default recommendation¶
For the majority of SpeedPy forks — job progress, notifications, and AI streaming — start with interval polling or SSE. Move to WebSockets only when you have a concrete bidirectional, low-latency requirement.
Pattern details¶
Polling (interval polling)¶
The client sends a request at regular intervals (e.g. every 2–5 seconds). The server responds immediately with the current state and closes the connection. This is the simplest form of "polling" and the recommended default for SpeedPy.
How it works in SpeedPy:
- A Celery task writes progress to a cache key (Redis) or a database status field.
- The API exposes a status endpoint:
GET /api/v1/jobs/{id}/status/. - The client polls this endpoint at a fixed interval (e.g.
setInterval(fetchStatus, 3000)). - When the task completes, the response includes the final result or a redirect to it.
POST /api/v1/imports/ → 202 Accepted, Location: /api/v1/jobs/{id}/status/
GET /api/v1/jobs/{id}/status/ → 200 {"state": "running", "progress": 45}
GET /api/v1/jobs/{id}/status/ → 200 {"state": "complete", "result_url": "/api/v1/imports/{id}/"}
Advantages:
- Works with standard WSGI (Gunicorn) — no ASGI migration needed.
- Every request is a normal, short-lived HTTP request — standard auth, logging, caching, and proxy behavior apply. No workers are tied up waiting.
- Trivial to implement; no new dependencies.
- Easy to test with APITestCase.
Disadvantages: - Higher latency (bounded by poll interval, typically 2–5 seconds). - More HTTP overhead than a persistent connection. - Not suitable for high-frequency updates (sub-second).
When to use: Job/task progress, import/export status, any feature where a few seconds of latency are acceptable. This is the recommended starting point for SpeedPy forks (see G2: Long-polling/SSE for job status for the reference implementation).
Long polling vs interval polling
True long polling holds the server connection open until data is available, which ties up a WSGI worker per waiting client — the same problem as SSE. For SpeedPy's WSGI default, interval polling (short requests at regular intervals) is the safer choice. The latency difference (poll interval vs near-instant) rarely matters for background job status.
Server-Sent Events (SSE)¶
The server holds open a single HTTP connection and pushes text/event-stream data frames to the client. The client uses the EventSource API (built into all browsers).
How it works in SpeedPy:
- A Django view returns a
StreamingHttpResponsewithcontent_type="text/event-stream". - The view generator yields
data: {...}\n\nlines, reading from Redis Pub/Sub or polling a cache key. - The client opens an
EventSourceand listens for messages.
from django.http import StreamingHttpResponse
def job_stream(request, job_id):
def event_generator():
while True:
status = cache.get(f"job:{job_id}:status")
yield f"data: {json.dumps(status)}\n\n"
if status and status.get("state") == "complete":
return
time.sleep(1)
return StreamingHttpResponse(
event_generator(),
content_type="text/event-stream",
)
Advantages:
- Native browser API (EventSource) with automatic reconnection.
- Unidirectional — simpler than WebSockets.
- Works over standard HTTP/2.
Disadvantages:
- Ties up a worker thread/process for each open connection. Under WSGI (Gunicorn sync workers), this limits concurrent SSE connections to the number of workers. Requires either async workers (uvicorn, gunicorn with uvicorn.workers.UvicornWorker) or a dedicated SSE service.
- Some reverse proxies (nginx, AWS ALB) buffer responses or enforce idle timeouts — requires configuration (X-Accel-Buffering: no, proxy read timeouts).
- Maximum 6 concurrent connections per domain in HTTP/1.1 (browsers). HTTP/2 multiplexing removes this limit.
- No built-in client-to-server channel (use regular POST requests for that).
When to use: Notifications, AI token streaming, live dashboards — anywhere the server pushes and the client only listens. Prefer SSE over WebSockets when the communication is strictly unidirectional.
Deployment consideration: If you adopt SSE, plan to run the SSE endpoint on async workers or a separate service. Do not run SSE on the same sync Gunicorn workers that serve your regular Django views — you will exhaust workers quickly.
WebSockets (Django Channels)¶
Full-duplex, persistent connections. Django Channels adds an ASGI layer with channel-layer routing and consumer classes.
What it requires:
channelsandchannels-redispackages.- An ASGI server (
daphneoruvicorn) — either replacing Gunicorn entirely or running alongside it for WebSocket routes only. - Redis (or another backend) as the channel layer for cross-process messaging.
routing.pyconfiguration alongsideurls.py.
Advantages:
- True bidirectional, low-latency communication.
- Django Channels provides auth integration (request.user available in consumers).
- Channel layer enables broadcasting across workers/processes.
- Mature ecosystem with good Django integration.
Disadvantages:
- Significant deployment complexity. You must run an ASGI server, configure it alongside or instead of WSGI, and manage the channel layer (Redis).
- Different programming model — async consumers instead of views. Testing requires channels.testing.WebsocketCommunicator.
- Middleware, auth, and error handling work differently than in standard Django views.
- Memory footprint scales with concurrent connections.
- Many hosting platforms (Heroku, some PaaS) have limited or no WebSocket support.
When to use: Collaborative editing, real-time chat, multiplayer features, or any scenario requiring sub-100ms bidirectional updates. Do not adopt Django Channels for server-to-client notifications alone — SSE or long polling is simpler and sufficient.
External event bus¶
A dedicated message broker (Redis Pub/Sub, NATS, Kafka, RabbitMQ Streams) sits between producers and consumers. Producers publish events; consumers subscribe independently.
When it makes sense: - Multiple services need to react to the same event (fan-out). - Event consumers are separate applications, not just browser clients. - You need durable event streams with replay capability (Kafka, NATS JetStream). - The system processes thousands of events per second.
When it does not make sense:
- A single Django application with a handful of background tasks — Celery already provides task-level pub/sub.
- You have fewer than 3 consumers per event type.
- The "event bus" would just be a wrapper around Celery .delay().
SpeedPy already has Celery + Redis. For most forks, Celery tasks are the event bus. An external bus becomes warranted only when you outgrow Celery's task-based model or need cross-service fan-out.
Operational tradeoffs summary¶
| Concern | Interval polling | SSE | WebSockets | External bus |
|---|---|---|---|---|
| WSGI compatible | Yes | Partial (needs async workers) | No (needs ASGI) | N/A (backend only) |
| New dependencies | None | None (or uvicorn) |
channels, channels-redis, ASGI server |
Broker-specific client |
| Auth model | Standard Django | Standard Django | Channels auth (different) | Application-level |
| Proxy/CDN friendly | Yes | Needs config (X-Accel-Buffering: no) |
Needs Upgrade support |
N/A |
| Idle timeout risk | None (short-lived requests) | Medium (proxy may kill idle connections) | Medium (ping/pong frames needed) | N/A |
| Worker consumption | Low (request completes immediately) | High (one worker per connection) | High (one connection per client) | N/A |
| Scaling complexity | Low | Medium (async workers or separate service) | High (ASGI + channel layer + Redis) | High (broker operations) |
| Testing complexity | Low (APITestCase) |
Medium (async test client) | High (WebsocketCommunicator) |
High (broker integration tests) |
| Latency | Seconds (poll interval) | Sub-second | Sub-100ms | Depends on consumer |
Recommendations for SpeedPy forks¶
-
Start with interval polling for job/task status. It requires zero infrastructure changes and works with the existing WSGI + Celery setup. Use
202 Accepted+ status URL as the API pattern. See G2 for the reference implementation. -
Graduate to SSE when you need lower latency or streaming responses (AI token streaming, live notifications). Plan the deployment change (async workers or a dedicated SSE service) before adopting.
-
Adopt WebSockets only with a concrete bidirectional use case. "It would be nice to have real-time updates" is not sufficient justification for the deployment and testing complexity of Django Channels.
-
Use an external event bus when you have multiple consuming services, not just a browser client. If you only have one Django app, Celery is your event bus.
-
Do not introduce Django Channels as default SpeedPy boilerplate. It changes the deployment model (ASGI), the programming model (async consumers), and the testing model. Forks that need it can add it; forks that don't should not carry the complexity.
Further reading¶
- Background Tasks — Celery setup and task patterns already in SpeedPy
- Webhooks — Outbound event delivery for server-to-server integrations
- Django Channels documentation
- MDN: Server-Sent Events
- G2: Long-polling/SSE for job status — Reference implementation ticket