Skip to content

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:

  1. A Celery task writes progress to a cache key (Redis) or a database status field.
  2. The API exposes a status endpoint: GET /api/v1/jobs/{id}/status/.
  3. The client polls this endpoint at a fixed interval (e.g. setInterval(fetchStatus, 3000)).
  4. 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:

  1. A Django view returns a StreamingHttpResponse with content_type="text/event-stream".
  2. The view generator yields data: {...}\n\n lines, reading from Redis Pub/Sub or polling a cache key.
  3. The client opens an EventSource and 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:

  • channels and channels-redis packages.
  • An ASGI server (daphne or uvicorn) — 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.py configuration alongside urls.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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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