I've been using Miguel Grinberg's excellent turbo-flask extension to bring the power of Turbo to a side project I'm working on. It's a new extension, so documentation is a little thin on the ground right now, but Miguel was kind enough to help me figure out how to use it to stream progress updates to users on background jobs they're running. Hopefully this post will help out anyone trying to implement something similar, as there were a few surprises along the way.
Prerequisites
This tutorial assumes that you have turbo-flask installed and imported into your app, and have glanced at this great post from Miguel in which he uses Turbo Streams to push updates to a web page automatically at specified intervals. Whilst that tutorial focuses on pushing updates to all clients, I'll show you how to serve specific clients updates on the background jobs they created. If you've worked through Miguel's book, you can think of this tutorial as helping you to replace the JavaScript in Chapters 21 and 22, which displays a progress bar with a live percentage in it.
Most modern web apps rely on the client requesting updates from the server using AJAX, but Turbo gives us the chance to remove extraneous JavaScript from our pages and simply use the Turbo library we're already including on them to 'push' updates from the server to the client using WebSocket connections. They call this Turbo Streams, and you can read about it in the Turbo handbook. Here's a brief summary:
Most modern web apps rely on the client requesting updates from the server using AJAX, but Turbo gives us the chance to remove extraneous JavaScript from our pages and simply use the Turbo library we're already including on them to 'push' updates from the server to the client using WebSocket connections. They call this Turbo Streams, and you can read about it in the Turbo handbook. Here's a brief summary:
Turbo Streams deliver page changes as fragments of HTML wrapped in self-executing <turbo-stream> elements. [...] These elements are delivered by the server over a WebSocket, SSE, or other transport to bring the application alive with updates made by other users or processes.
Pushing Updates to All Connected Clients
We'll first look at and understand the code for Miguel's solution to updating all users, before tweaking his code so that it'll suit our needs. If you have not read the original tutorial, you'll miss out on a lot of useful context, so please check it out first.
base.html
base.html
<!doctype html> <html> <head> <title>Turbo-Flask Streams Demo</title> {{ turbo() }} </head> <body> {% include "loadavg.html" %} {% block content %}{% endblock %} </body> </html>
The base template includes turbo in the <head>, and simply includes a second template - loadavg.html - in its <body>. That's it!
loadavg.html
<div id="load" class="load"> <table> <tr><th></th><th>1 min</th><th>5 min</th><th>15 min</th></tr> <tr><th>Load averages:</th><td>{{ load1 }}</td><td>{{ load5 }}</td><td>{{ load15 }}</td></tr> </table> </div>
Other than the variables (load1, load5, load15), the important part of 'loadavg.html' is that we add a unique ID to the element we'd like Turbo to target with the new data from the server.
In the above example, you can see that the table displaying the load averages is wrapped in a div with the ID "load". We will later plug this ID into our update function so that Turbo knows which element on the page to target when we ask it to perform one of its five actions (append, prepend, replace, update, or remove).
routes.py
import threading # ... @app.before_first_request def before_first_request(): threading.Thread(target=update_load).start()
Here we use the @app.before_first_request decorator to run the update_load function as soon as the first client connects. We're importing threading to be able to create a separate thread – a bit like a second instance of the app – which focuses on running the 'update_load' function, and nothing else.
We need to use a background thread because once a Flask WSGI worker handles a request from a client, it identifies itself as being free to handle a new request. Using a background thread keeps the function running irrespective of client requests ending, meaning the function will loop in perpetuity rather than concluding.
Miguel's update_load function looked like this:
routes.py
import time # ... def update_load(): with app.app_context(): while True: time.sleep(5) turbo.push(turbo.replace(render_template('loadavg.html'), 'load'))
First, since the function is running in what amounts to a separate, contextless instance of the app, we give the function application context with app.app_context().
Then, every five seconds, we use Turbo's push method and the replace helper to find the 'load' ID in 'loadavg.html' and replace the client's view (which is rendered when they request it via a normal route) with whatever the latest version is on the server, as if the client were requesting it fresh.
It's a very distinctively 'Turbo' approach: 'loadavg.html' renders the latest values whenever it is loaded, so all we're doing is finding a way to grab the values from 'loadavg.html' without reloading the page. If a user is not using a modern web browser with support for WebSockets, then the page will simply display static values.
Pushing Updates to Specific Clients
To get this working as a means to updating individual users on their specific background jobs, we're going to need to find a way to join the dots between Turbo, our background thread, and our function, which all operate with insufficient context to push notifications to individual clients.
To illustrate this lack of context, if we tried to use Turbo's to functionality to push the update to=current_user…
To illustrate this lack of context, if we tried to use Turbo's to functionality to push the update to=current_user…
def update_load(): with app.app_context(): while True: time.sleep(5) turbo.push(turbo.replace(render_template('loadavg.html'), 'load'), to=current_user)
… we would see a NoneType error in the console, because current_user does not exist in the background thread where update_load runs.
But before we get into that, let's look at the template changes I've made to recycle Miguel's code for notifications. I'm using Bootstrap 5's toast component to render nice looking alerts for users, but I won't cover much about them here.
I include notifications in base.html so that they are accessible from any view in the app:
I include notifications in base.html so that they are accessible from any view in the app:
base.html
[...] {% if current_user.is_authenticated %} {% with tasks = current_user.get_tasks_in_progress() %} {% if tasks %} <div class="toast-container"> {% for task in tasks %} {% include '_alerts.html' %} {% endfor %} </div> {% endif %} {% endwith %} {% endif %} [...]
If the current user is authenticated, we get their running tasks and, if they have any, render a container on the page where our notifications will live. (In my project if a user is not authenticated they cannot have any running tasks, and we therefore do not need to render anything if a user is not logged in.)
Then, for every task the user has (for task in tasks), we include the '_alerts.html' template, which looks like this:
_alerts.html
<div class="toast" id="{{ task.id }}" role="alert" aria-live="assertive" aria-atomic="true"> <div class="toast-header"> <strong>Running {{ task.name.capitalize() }}</strong> <small>{{ task.user_id }}</small> </div> <div class="toast-body"> <p>{{ task.description }} {{ task.get_progress() }}</span>%</p> </div> </div>
Each notification is wrapped in a div with an id equal to task.id, which is what turbo will look for as its target.
That's everything the user sees. Now let's look at how we can help Turbo identify the right clients to push updates to.
Each connected client has a unique ID which Turbo can see from the background thread, accessible to us with turbo.clients (e.g. print(turbo.clients) will return all the connected clients Turbo can see). A single connected client looks like this to Turbo…
{1: [<flask_sock.ws.WebSocket object at 0x10ae70e50>]}
… which means all we need to do is find a way to link the turbo client ID back to a user. Fortunately turbo-flask has a built-in decorator which can help us with this:
routes.py
@turbo.user_id def get_user_id(): if current_user.is_authenticated: return current_user.id else: return None
This confused me at first because the function isn't explicitly called anywhere, but the magic is all in the decorator. This function to return a user's ID is self-explanatory, but adding the @turbo.user_id decorator tells turbo to link each connected client to a user's ID. I also only try to match the user to an ID if the user has authenticated: otherwise we receive an error whenever an anonymous user connects and the function can't match them to an ID.
Now, let's look at our background thread, which looks a little different to the one from Miguel's tutorial:
routes.py
@bp.before_app_first_request def before_first_request(): threading.Thread(target=progress_update, args=(current_app._get_current_object(),)).start()
Some differences are cosmetic: my project uses app blueprints, so instead of the vanilla @app.before_first_request decorator we are using the corresponding decorator for blueprints. I've also renamed the function to make it more obviously applicable to our intentions here.
The major difference here is that we call the function with an argument which will pass the current object from the current app, giving our thread all the context it needs to allow the function to update clients as required. Again, this wasn't necessary with the original tutorial because it didn't matter who the user was: we just needed to update the server load on a page. Now, we need to be able to figure out which tasks belong to which users, and which connected clients belong to those same users.
Our progress_update function needs to do a few things:
1. It must now accept an argument which passes application context to the function,
2. It must identify the tasks which need updates,
3. It must push updates to those tasks to the users who created them.
Here's where we end up:
routes.py
def progress_update(app): with app.app_context(): while True: tasks = Task.query.filter_by(complete=False).all() if len(tasks) > 0 and turbo.clients: for task in tasks: turbo.push(turbo.replace(render_template('_alerts.html', task=task), task.id), to=task.user_id) time.sleep(1)
This time our function is able to accept an argument (app), which is the current_app._get_current_object() part of our thread initialisation. This simply means that we can now write some logic to figure out if and when we should push updates to a connected client.
Because many users could have background jobs, and some users may even have multiple jobs, the first thing we do is to return a list of all the jobs in our database's Task table which are not yet finished, as determined by the boolean value of task.complete being equal to False. Then, if the list of tasks is greater than zero and turbo can also see connected clients, we:
1. Loop over the tasks (for task in tasks),
2. Tell Turbo we're going to push a replace update out (turbo.push(turbo.replace…),
3. Specify a new rendering of the '_alerts.html' template as the data we want to send out (render_template('_alerts.html', task=task)),
4. Specify the target element to replace with the new data (task.id),
5. Send this entire update to the user who created the task in question (to=task.user_id).
We need to remember to pass along task=task when we use render_template. When we rendered the user's view the first time around, we assigned the variable task to the value returned by current_user.get_tasks_in_progress() within our Jinja template…
{% with tasks = current_user.get_tasks_in_progress() %}
… but now we're using the background thread to render '_alerts.html', and it needs to know which tasks to provide updates on without access to our 'base.html' template or any other context.
Mistakes I Made
• Gunicorn and other WSGI servers can cause issues with their native load balancing, so it's important to spin up your local server with a single worker (multiple threads are fine). Here's how to do that with Gunicorn:
$ gunicorn --reload --workers=1 --threads=12 --timeout=60 myappname:app
• Because we used tasks = Task.query.filter_by(complete=False).all() in our function, it's not possible to use turbo's 'remove' action to hide notifications for completed tasks from the user within the same block: when the task reaches 100% it is marked as complete, and no longer appears in our list of tasks. I therefore evaluate the task's progress within the Jinja template and hide the toast using CSS.
• Turbo streams show up in the 'sources' tab of the Web Inspector (tested in Safari and Chrome on macOS), with a green status indicator to show that the WebSocket connection is open. Clicking on the turbo-stream source should, if everything is working properly, show you the data being pushed into the app in real time, wrapped in a couple of additional pieces of markup (<turbo-stream> and <template>) which Turbo parses to understand exactly what to do with the data within them.
• I accidentally created a bizarre recursion by applying the id Turbo looks for to a span element nested within '_alerts.html', meaning Turbo was replacing a <span> within '_alerts.html' with the entire '_alerts.html'.
• Because the approach I've taken is to essentially re-render the entire toast every second, a few CSS animations and transitions I had applied to toasts behaved strangely. For example, it's very common to show an animated loading spinner alongside a progress bar, but because this solution pushes the entire notification including the animation, the animation restarts from its initial position whenever an update is rendered in the browser (which is every one second). You could solve this by only sending the updated value of task.get_progress() and not the entire '_alerts.html' template in the turbo push.
Many thanks to Miguel for kindly spending a long time to help me troubleshoot this.