Don't fear SSE in Django / SSE /

Introduction

SSE is a long-lived HTTP response suitable for delivering message in realtime to the user. The browser makes a normal GET request, and instead of returning a response and closing the connection, the server keeps it open and streams events as they occur.

event: update
data: Hello, world!

event: update
data: Your report has finished generating.

event: update
data: 75%

Each event is separated by a blank line. You can consume these events in the browser via the EventSource API:

const es = new EventSource("/events/");

es.addEventListener("update", (e) => {
    console.log(e.data);
});

You can use SSE through ASGI and Django's StreamingHttpReponse.

Simple Example

Use your preferred method to spin up a new Django project. I'll use uv:

uv init demo
cd demo
uv add django gunicorn
uv run django-admin startproject demo .

The only two dependencies we need are Django and any ASGI-powered application server.

For an example this small, we'll place everything in demo/urls.py:

import asyncio
from datetime import datetime

from django.http import HttpResponse, StreamingHttpResponse
from django.urls import path


def index(request):
    return HttpResponse("""\
      <!doctype html>
      <title>SSE demo</title>

      <h1>Current time: <span id="message">...</span></h1>

      <script>
      const es = new EventSource("/events/");

      es.addEventListener("update", (e) => {
          document.getElementById("message").textContent = e.data;
      });
      </script>
    """)


async def events(request):
    async def stream():
        yield "retry: 3000\n\n"

        while True:
            now = datetime.now().strftime("%H:%M:%S")
            yield f"event: update\ndata: {now}\n\n"
            await asyncio.sleep(1)

    response = StreamingHttpResponse(
        stream(),
        content_type="text/event-stream",
    )
    response["Cache-Control"] = "no-cache"
    return response


urlpatterns = [
    path("", index),
    path("events/", events),
]

Now all we need to do is start the application:

uv run gunicorn --worker-class asgi demo.asgi:application

Open http://127.0.0.1:8000 and you'll see the clock update every second. Here's a quick explanation on important parts of that code block:

More Realistic Example

Our previous example generated an event every second using asyncio.sleep() which is only helpful for demonstrating SSE, but real applications send updates when something happens, usually from an external source (to the active process).

A common way to do this is to use Redis Pub/Sub. Instead of waiting for a timer, our SSE view waits for messages published to a Redis channel.

Install the Redis client then start Redis locally (I'll use docker):

uv add redis
docker run -d -p 6379:6379 redis:7-alpine

Now change the SSE view to:

import redis.asyncio as redis

redis_client = redis.from_url("redis://localhost:6379")


async def events(request):
    async def stream():
        pubsub = redis_client.pubsub()
        await pubsub.subscribe("updates")

        try:
            yield "retry: 3000\n\n"

            while True:
                message = await pubsub.get_message(
                    ignore_subscribe_messages=True,
                    timeout=None,
                )

                if message is None:
                    continue

                yield (
                    "event: update\n"
                    f"data: {message['data'].decode()}\n\n"
                )

        finally:
            await pubsub.aclose()

    response = StreamingHttpResponse(
        stream(),
        content_type="text/event-stream",
    )
    response["Cache-Control"] = "no-cache"
    return response

You can publish by pushing messages to the respective channel:

import redis

client = redis.Redis()

client.publish("updates", "25%")
client.publish("updates", "50%")
client.publish("updates", "75%")
client.publish("updates", "Done!")

Or directly from your terminal (assuming redis-cli is installed):

redis-cli PUBLISH updates "25%"
redis-cli PUBLISH updates "50%"
redis-cli PUBLISH updates "75%"
redis-cli PUBLISH updates "Done!"

Each published message is immediately received by the SSE view and streamed to every connected browser without refreshing the page.

   Publisher
      ↓
    Redis
      ↑
   /events/ (Subscriber)
      ↓ stream
   Browser

Notice how neither side knows about the other. The publisher has no idea browsers are connected, and the SSE view has no idea who published the message. Redis acts as the bridge between synchronous code that produces events and the asynchronous view that streams them.

You should also know that there's a limit of 6 open SSE connections per browser per domain, when not on HTTP/2.