Adding Web Workers To The Static Site Created With React And Python

Rob Blackbourn
4 min readJul 26, 2021

In my previous articles I created a static site with react and python, then addressed issues of scalability. In this article I introduce Web Workers in order to run the Python code in it’s own thread, freeing up the UI thread. You can find the source code here, and the static site on GitHub Pages here.

Web Workers

My previous solution worked well for the simple task of generating matrices, but if I was doing anything computationally expensive it would block the UI thread, and the app would become unresponsive until the calculation returned. I need to run the python code in another thread. In the browser this is done with a “web worker”.

The pyodide documentation for this is very clear, the main issue is how to plug it into React. The problem is that the React build process uses webpack to bundles all the individual source files into a few large files, but the worker object takes a named JavaScript file in it’s constructor. There are a few open source packages to deal with this. I chose worker-loader as this is a contrib package to “webpack”, and will be part of the next base distribution.

We can add it to the previous project in the usual way:

npm install --save worker-loader

This package gives us some special syntax to import the worker. I renamed the “src/pythonCode.js” to “src/pythonWorker.js” and changed the import in “src/App.js” to the following:

import PythonWorker from 'worker-loader!./pythonWorker.js' // eslint-disable-line import/no-webpack-loader-syntax

Note the worker-loader! syntax. The eslint error must be disabled to allow the file to be transpiled.

Now I needed a way to call the code in the worker. Communication to the worker happens through a publishing and subscribing to “messages” which are simple JavaScript objects. Rather than write special messages for each function I chose to use the comlink package which creates a wrapper around the connection allowing simple function calls.

npm install --save comlink

All of the pieces are now in place. I rework the “src/pythonWorker.js” (renamed from “src/pythonCode.js”) in the following manner.

import * as Comlink from 'comlink/dist/esm/comlink'self.importScripts('https://cdn.jsdelivr.net/pyodide/v0.17.0/full/pyodide.js')const PYTHON_CODE = `
import random
import numpy as np
def generate_matmul_exercise(max_rows, max_cols):
m = random.randint(1, max_rows)
n = random.randint(1, max_cols)
p = random.randint(1, max_cols)
rng = np.random.default_rng()
A = rng.integers(low=-10, high=10, size=(m, n), dtype=np.int32)
B = rng.integers(low=-10, high=10, size=(n, p), dtype=np.int32)
C = A @ B
return {
'm': m,
'n': n,
'p': p,
'A': A,
'B': B,
'C': C
}
def generate_matadd_exercise(max_rows, max_cols):
m = random.randint(1, max_rows)
n = random.randint(1, max_cols)
rng = np.random.default_rng()
A = rng.integers(low=-10, high=10, size=(m, n), dtype=np.int32)
B = rng.integers(low=-10, high=10, size=(m, n), dtype=np.int32)
C = A + B
return {
'm': m,
'n': n,
'A': A,
'B': B,
'C': C
}
`
function wrapGenerateMatmulExercise() {
const func = self.pyodide.globals.get('generate_matmul_exercise') // eslint-disable-line no-restricted-globals
return (maxNumberOfRows, maxNumberOfColumns) => {
console.log('generateMatmulExercise')
const obj = func(maxNumberOfRows, maxNumberOfColumns)
const results = {
m: obj.get('m'),
n: obj.get('n'),
p: obj.get('p'),
A: obj
.get('A')
.toJs()
.map((x) => Array.from(x)),
B: obj
.get('B')
.toJs()
.map((x) => Array.from(x)),
C: obj
.get('C')
.toJs()
.map((x) => Array.from(x))
}
obj.destroy()
return results
}
}
function wrapGenerateMataddExercise() {
const func = self.pyodide.globals.get('generate_matadd_exercise') // eslint-disable-line no-restricted-globals
return (maxNumberOfRows, maxNumberOfColumns) => {
const obj = func(maxNumberOfRows, maxNumberOfColumns)
const results = {
m: obj.get('m'),
n: obj.get('n'),
A: obj
.get('A')
.toJs()
.map((x) => Array.from(x)),
B: obj
.get('B')
.toJs()
.map((x) => Array.from(x)),
C: obj
.get('C')
.toJs()
.map((x) => Array.from(x))
}
obj.destroy()
return results
}
}
async function loadPyodideAndPackages() {
await self.loadPyodide({
indexURL: 'https://cdn.jsdelivr.net/pyodide/v0.17.0/full/'
}) // eslint-disable-line no-restricted-globals
await self.pyodide.loadPackage(['numpy']) // eslint-disable-line no-restricted-globals
self.pyodide.runPython(PYTHON_CODE) // eslint-disable-line no-restricted-globals
}
const pyodideReadyPromise = loadPyodideAndPackages()const pythonContext = {
async setup() {
await pyodideReadyPromise
this.generateMatmulExercise = wrapGenerateMatmulExercise()
this.generateMataddExercise = wrapGenerateMataddExercise()
}
}
Comlink.expose(pythonContext)

This is a chunky piece of code! First lets address the minor changes I made to the original.

Because the code will be run in a separate thread, the Python functions no longer need to be “async”, and the awaits and asyncs are removed from the wrapper code.

Previously I had used a script tag in “public/index.html” to import the pyodide code, however this won’t be available to the worker thread. To handle this I remove the script tag and add the self.importScripts to the start of the worker code. Note the use of self to gain access to the global context of the worker.

Lastly the block of code at the end after the wrappers loads the pyodide WebAssembly, the numpy package, and the local Python code. Finally I create the pythonContext object, which gets wrapped by comlink. Initially this object has a single asynchronous function setup. When called this waits for the pyodide initialisation to complete, the adds the functions to the wrapped object.

Creating the worker

The last step is to create the worker in “src/App.js”. All this required was a minor change the componentDidMount function.

  async componentDidMount() {
const pythonWorker = Comlink.wrap(new PythonWorker())
await pythonWorker.setup()
this.setState({ pythonWorker })
}

Now we can run the app:

npm start

For this toy example the changes seem quite minor. The progress bar is now smoothly updating on load, because the UI thread doesn’t get blocked when the wasm gets loaded. The examples are generated slightly quicker. However for more computationally expensive problems the difference will significant.

--

--