Introducing WebDash!

May 11th, 2021

Towards the end of 2020, my team at work had some time to experiment with new technologies.We were trying to solve interesting problems, one of which was:

How can we standardise the environment a user is running our apps in, without having advance knowledge of their machine specs, OS etc.

The trick here was that we're not just shipping a regular, isolated product - because that obstacle can be (somewhat) overcome by using pre-compiled binaries or a VM solution like Java. The product we shipped was a platform which lets clients write their own code in Python. Not only that, but they could also have some degree of flexibility and install their own custom packages and, in some cases, connect to external services.

Sounds great, right? What I didn't tell you is that we would ship and install the entire platform locally on the client machine. We can debate the merits of that approach for eternity, but let me refocus your attention on the why this was challenging: we had no way of knowing what on the client's machine could interfere with our installation process. Antivirus software, outdated Windows 7 machines and restrictive corporate IT policies were all potential sticks waiting to be thrown into our spinning installation wheel (pun intended for you Pythonistas).

So after some thought, our 💡 moment came:

All our clients do have access to a standardised environment. That environment is the web browser!

What if we could, using the browser, create a runtime environment which will always behave in the exact same way on each machine it is run on? And if that vision is attainable, what technology should we use? Now, at this point I should probably clarify what "runtime environment" means and why the idea is absolutely bonkers - we are trying to ship a Python Virtual Environment with custom packages, and get that environment to run in the browser. Once that environment is running in the browser, we want to allow users to execute their Python code in the environment. We definitely care about security, so arbitrary code should not have any access to machine resources unless it is allowed to. It turns out WebAssembly is the answer to our prayers, and boy did we hit the jackpot because it also turns out that the Pyodide project gives us a full Python runtime, together with some of the most common scientific stack packages, compiled to WebAssembly.

So now we have a Python kernel which can run in the browser and includes packages like pandas, numpy and scikit-learn which people in the financial industry love and rely upon. The next step is figuring out what is the best way to allow people to build their apps using that stack and publish them. We actually had that part figured out before we went on this adventure - we would support either Jupyter Notebook published using voila, or we will roll out a Plotly Dash flavour. If you have never played with Dash and you do data science - you should definitely look into it. Dash allows Data Scientists and Quant Analysts to productionise their models as a web app, using Python-only code whilst being served by a Flask web server. They do that by exposing HTML tags as Python objects, and bundling React components as widgets. This system may seem simplistic but it works exceptionally well. Users of this system need not to write any non-Python code - ever. We eventually decided to go down the Dash route because the scope of the Dash project was narrower; it was not a scientific notebook, but an explicit dashboarding solution. This meant that if our magic carpet was to take off, it would have a higher chance of staying in the air given it only needs to handle flying (weird analogy, I know, forgive me).

And on that note, now is a good time to thicken the plot by introducing an influential figure in this project:

Flask is a web server. How on earth will you run a web server inside a web browser, to server a web site to that same browser?

Paul Ivanov, undisclosed location, early 2021

Paul was right. To illustrate just how ludicrous this entire idea is, check out the title of the talk we both gave internally once this project was official:

Dash running on Flask running in Python running in web assembly (WASM) running in the web browser via JavaScript. Client-server communication without a (real) server.

Back to WebDash. Trying to solve this problem took us down a destructive path of trying to emulate entire *nix operating systems in WebAssembly (thank you, Fabrice Bellard), and using that virtual system to then install Python, create a virtual environment, run Flask and then somehow, once that process is running, serve a web site. Although we got surprisingly far using that approach, at some point it became unwieldy to bundle and operate. We had to try something else.

After a few brainstorming sessions, we came up with a new idea - use Flask as a normal execution engine, but instead of letting Flask handle all the communications, we will take care of the request/response objects and transportation layer from/to Flask ourselves. The idea was simple, really, if somewhat awkward. Digging into the Flask documentation, we found that Flask does have both Request/Response contexts, although they must be used in conjunction with an active HTTP request:

RuntimeError: Working outside of request context.

This typically means that you attempted to use functionality that
needed an active HTTP request. Consult the documentation on testing
for information about how to avoid this problem.

But we were not convinced it's the end of the road, because professional engineers do testing, and surely there is a way to process requests and create responses using some mocking mechanism or something similar. Lo and behold, it turns out Flask does provide a testing client to simulate requests:

def generate_report(year):
    format = request.args.get('format')
    ...

with app.test_request_context(
        '/make_report/2017', data={'format': 'short'}):
    generate_report()

This was it. That was our ticket to Valhalla. The plan was as follows:

  1. Dash generates a request on the front-end and attempts sending a POST request to Flask
  2. We intercept that request, apply slight processing, season with spices, and dispatch to our WASM-powered Flask server
  3. The Flask testing client to reads the request and produces a Response object.
  4. We return the Response object generated by our WASM-Flask instance to Dash on the front-end.

If this sounds bizarre or just outright confusing, perhaps a little storytelling using Paul's illustrations could help:

As you know, in a regular setting, the browser AKA "The Client" makes a request to a server. For content consumption, it typically means using a GET request:

If the request is valid, the server sends back the initial HTML page (in our case it's HTML, but it could be any other asset):

The HTML page likely includes references to additional assets such as CSS, JavaScript and static assets like images and favicons. In the context of Plotly Dash, the page includes a script tag which is used as the entry point for Dash's front-end: Dash Renderer. The browser makes a GET request to retrieve the JavaScript file containing Dash Renderer:

At that point, our Dash application is loaded in the browser and ready to go.

Now that the application has been loaded, we need to be able to make it responsive to our inputs. If we hover on a particular point which is used as an input to some calculation and has an event listener attached, the Flask backend needs to be able to read that input and return a response object. The sending of the data is done via POST requests.

Now that the very basics of HTTP requests are known, it is easier to explain how WebDash works:

We implement this entire operation using our WASM-based Flask server. We achieve that by intercepting those GET and POST requests and routing them to our WASM-land Flask server. The HTML asset which contains references to Dash Renderer is served entirely from the virtual backend, and so is Dash Renderer and any other dependencies the specific Dash application has! 🤯🤯🤯

The only time a real web server is used in WebDash is during the loading of what I like to call the "bootstrap" routine - when we load Pyodide, the underlying Dash app and WASM-Flask into the WASM virtual file system.

At that point, essentially, WebDash is entirely self sufficient. We can actually kill the real web server which which served the initial bootstrapping routine to load all the of the dependencies (before we intercepted the GET/POST requests), and WebDash will not know the difference. In essence, we achieved what we set out to do, just a few paragraphs above:

Dash running on Flask running in Python running in web assembly (WASM) running in the web browser via JavaScript. Client-server communication without a (real) server.

Congratulations for making it until the end! Coffee and cookies (it's a thing) on me next time you're in town. If you enjoyed reading this essay, please do let me know on Twitter. You know what, please do let me know if you hated it, too!

If you are interested in the WebDash project and want to try it out for your own app or perhaps contribute, you can find the project here.

Finally, I would like to finish by thanking Paul Ivanov for the countless hours he selflessly spent hacking (read: mentoring) with me - Fridays, Saturdays and random weekday evenings. Or, rather, I should probably thank his family for not banning him from talking to me. (Seriously, thank you ❤️)

Until the next time (and please stay safe), Itay