{
"$type": "site.standard.document",
"canonicalUrl": "https://rednafi.com/python/server-sent-events/",
"description": "Stream real-time server updates to web clients using Server-Sent Events (SSE) as a simpler alternative to WebSockets for unidirectional data flow.",
"path": "/python/server-sent-events/",
"publishedAt": "2023-04-08T00:00:00.000Z",
"site": "at://did:plc:fgtm2c26vfcj74rfmeggbyqj/site.standard.publication/3mnl6f7ob462z",
"tags": [
"Python",
"Networking",
"Web"
],
"textContent": "In multi-page web applications, a common workflow is where a user:\n\n- Loads a specific page or clicks on some button that triggers a long-running task.\n- On the server side, a background worker picks up the task and starts processing it\n asynchronously.\n- The page shouldn't reload while the task is running.\n- The backend then communicates the status of the long-running task in real-time.\n- Once the task is finished, the client needs to display a success or an error message\n depending on the final status of the finished task.\n\nThe de facto tool for handling situations where real-time bidirectional communication is\nnecessary is [WebSocket]. However, in the case above, you can see that the communication is\nmostly unidirectional where the client initiates some action in the server and then the\nserver continuously pushes data to the client during the lifespan of the background job.\n\nIn Django, I usually go for the [channels] library whenever I need to do any real-time\ncommunication over WebSockets. It's a fantastic tool if you need real-time full duplex\ncommunication between the client and the server. But it can be quite cumbersome to set up,\nespecially if you're not taking full advantage of it or not working with Django. Moreover,\nWebSockets can be quite flaky and usually have quite a bit of overhead. So, I was looking\nfor a simpler alternative and found out that Server-Sent Events (SSEs) work quite nicely\nwhen all I needed was to stream some data from the server to the client in a unidirectional\nmanner.\n\nServer-Sent Events (SSEs)\n\n[Server-Sent Events (SSE)][SSE] is a way for a web server to send real-time updates to a web\npage without the need for the page to repeatedly ask for updates. Instead of the page asking\nthe server for new data every few seconds, the server can just send updates as they happen,\nlike a live stream. This is useful for things like live chat, news feeds, and stock tickers\nbut won't work in situations where you also need to send real-time updates from the client\nto the server. In the latter scenarios, WebSockets are kind of your only option.\n\nSSEs are sent over traditional HTTP. That means they don't need any special protocol or\nserver implementation to get working. WebSockets on the other hand, need full-duplex\nconnections and new WebSocket servers like Daphne to handle the protocol. In addition, SSEs\nhave a variety of features that WebSockets lack by design such as automatic reconnection,\nevent IDs, and the ability to send arbitrary events. This is quite nice since on the\nbrowser, you won't have to write additional logic to handle reconnections and stuff.\n\nThe biggest reason why I wanted to explore SSE is because of its simplicity and the fact\nthat it plays in the HTTP realm. If you want to learn more about how SSEs stack up against\nWebSockets, I recommend this [SSE vs WebSockets post] by Germano Gabbianelli.\n\nThe wire protocol\n\nThe wire protocol works on top of HTTP and is quite simple. The server needs to send the\ndata maintaining the following structure:\n\nHere, the server header needs to set the MIME type to text/event-stream and ask the client\nnot to cache the response by setting the cache-control header to no-cache. Next, in the\nmessage payload, only the data field is required, everything else is optional. Let's break\ndown the message structure:\n\n- event: This is an optional field that specifies the name of the event. If present, it\n must be preceded by the string 'event:'. If not present, the event is considered to have\n the default name 'message'.\n\n- id: This is an optional field that assigns an ID to the event. If present, it must be\n preceded by the string 'id:'. Clients can use this ID to resume an interrupted connection\n and receive only events that they have not yet seen.\n\n- data: This field is required and contains the actual message data that the server wants\n to send to the client. It must be preceded by the string 'data:' and can contain any\n string of characters.\n\n- retry: This is an optional field that specifies the number of milliseconds that the\n client should wait before attempting to reconnect to the server in case the connection is\n lost. If present, it must be preceded by the string 'retry:'.\n\nEach message must end with double newline characters (\"\\n\\n\"). Yep, this is part of the\nprotocol. The server can send multiple messages in a single HTTP response, and each message\nwill be treated as a separate event by the client.\n\nA simple example\n\nIn this section, I'll prop up a simple HTTP streaming server with [Starlette] and collect\nthe events from the browser. Here's the complete server implementation:\n\nThe server exposes a /stream endpoint that will just continuously send data to any\nconnected client. The stream function returns a StreamingResponse object that the\nframework uses to send SSE messages to the client. Internally, it defines an asynchronous\ngenerator function _stream which produces a sequence of messages that follows the SSE wire\nprotocol and yields them line by line.\n\nThe index / page is there so that you can head over to it in your browser and paste the\nclient-side code.\n\nYou can run this server with uvicorn via the following command:\n\nThis will expose the server to the localhost's port 5000. Now you can head over to your\nbrowser, go to the localhost:5000 URL and paste this following snippet to the dev console\nto catch the streamed data from the client side:\n\nNotice, how the client API is quite similar to the WebSocket API but simpler. Once you've\npasted the code snippet to the browser console, you'll be able to see the streamed data from\nthe server that looks like this:\n\nA more practical example\n\nThis section will demonstrate the scenario that was mentioned at the beginning of this post\nwhere loading a particular page in your browser will trigger a long-running asynchronous\n[Celery] task in the background. While the task runs, the server will communicate the\nprogress with the client.\n\nOnce the task is finished, the server will send a specific message to the client and it'll\nupdate the DOM to let the user know that the task has been finished. The workflow only\nrequires unidirectional communication and SSE is a perfect candidate for this situation.\n\nTo test it out, you'll need to install a few dependencies. You can pip install them as\nsuch:\n\nYou'll also need to set up a Redis server that Celery will use for broker communication. If\nyou have Docker installed in your system, you can run the following command to start a Redis\nserver:\n\nThe application will live in a directory called sse with the following structure:\n\nThe view.py contains the server implementation that looks like this:\n\nHere, first, we're setting up celery and connecting it to the local Redis instance. Next up,\nthe background function simulates some async work where it just waits for a while and\nreturns a message. The index view calls the asynchronous background task and sets the id\nof the task as a session cookie with response.set_cookie(\"task_id\", task_id). The frontend\nJavaScript will look for this task_id cookie to identify a running background task.\n\nThen we expose a task_status endpoint that takes in the value of a task_id and streams\nthe status of the running task to the frontend as SSE messages. To avoid dangling\nconnections, we stream the task status for 10 seconds before giving up.\n\nNow on the client side, the index.html looks like this:\n\nWhen the index page is loaded, the server starts a background task and sets the\ntask_id=<task_id> session cookie. The HTML above then defines a paragraph element to show\nthe message streamed from the server:\n\nThe JavaScript code defines a function named waitForResult() that listens for updates on\nthe status of a long-running task that is being executed on the server. The function first\nwaits for the task_id to be set in a cookie by calling waitForTaskIdCookie(). Once the\ntask_id is obtained, the function creates a new EventSource object that connects to the\nstreaming endpoint on the server using the ID to get updates on the status of the task.\n\nThe EventSource object is set up with four event listeners: onmessage, onerror,\nonopen, and onclose. The onmessage listener is triggered when the server sends an\nupdate on the task status. The listener first logs the updated task status and then checks\nif the state of the task is SUCCESS or UNFINISHED. In either case, the client fetches\nthe message element on the DOM and updates it with the result of the background task\nstreamed by the server.\n\nThe client-side SSE API will automatically keep reconnecting if the connection fails for\nsome reason. This is handy since you don't have to write any additional logic to make the\nconnection more robust. However, you do need to be mindful about closing the connection from\nthe client side once you've received the final task status. The onmessage event listener\nexplicitly closes the connection with eventSource.close() once the final message about a\nspecific task has reached the client from the server.\n\nThe onerror listener handles errors that occur with the connection. The onopen callback\nis called when the connection is successfully opened, and onclose gets called when the\nconnection is closed.\n\nThe waitForTaskIdCookie() function that is called by the entrypoint waits for the\ntask_id to be set in a cookie by repeatedly calling getCookie() until the ID is\nobtained. The function waits for 300ms between each iteration so that it doesn't overwhelm\nthe client.\n\nThe getCookie() function is a utility function that returns the value of a cookie given\nits name.\n\nFinally, the code sets the window.onload event listener to call the waitForResult()\nfunction when the page has finished loading.\n\nNow, go to the sse directory and start the server with the following command:\n\nOn another terminal, start the celery workers:\n\nFinally, head over to your browser and go to http://localhost:5000/index page and see that\nthe server has triggered a background job. Once the job finishes after 5 seconds, the client\nshows a message:\n\n<video\n src=\"https://user-images.githubusercontent.com/30027932/229604497-0a0b058f-32dd-4219-a68f-9cd35b250334.mov\"\n controls=\"controls\"\n style=\"max-width: 730px\"\n alt=\"server sent events demo\"> </video>\n\nNotice, how the server pushes the result of the task automatically once it finishes.\n\nLimitations\n\nWhile SSE-driven pages are much easier to bootstrap than their WebSocket counterparts -\napart from only supporting unidirectional communication, they suffer from a few other\nlimitations:\n\n- SSE is limited to sending text data only. If an application needs to send binary data, it\n must encode the data as text before sending it over SSE.\n- SSE connections are subject to the same connection limitations as HTTP connections. In\n some cases, a large number of SSE connections can overload the server, leading to\n performance issues. However, this can be mitigated by taking advantage of connection\n multiplexing in HTTP/2.\n\nFurther reading\n\n- [Using server-sent events]\n\n\n\n\n[websocket]:\n https://en.wikipedia.org/wiki/WebSocket\n\n[channels]:\n https://channels.readthedocs.io/en/stable/\n\n[sse]:\n https://en.wikipedia.org/wiki/Server-sent_events\n\n[sse vs websockets post]:\n https://germano.dev/sse-websockets/\n\n[starlette]:\n https://www.starlette.io/\n\n[celery]:\n https://docs.celeryq.dev/en/stable/getting-started/introduction.html\n\n[using server-sent events]:\n https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events",
"title": "Pushing real-time updates to clients with Server-Sent Events (SSEs)"
}