Expensive computations in React
I'm currently working on Picwing, a web application, built with React, that allows paragliders to turn their flight log files into images they can share on social media. Here is an example of the type of image that Picwing currently produces:
Initially Picwing ran entirely in the browser and to calculate the distance and XC (cross country) score, shown at the bottom of the image, I use a javascript library which seems to work well. The calculation, however, is computationally expensive which caused some problems.
The library allows the developer to define a maximum time period that the library code is allowed to spend calculating the score. Initially I set this to 1 second. The flow was that a user would select a file from the file explorer, the contents would be loaded into React state, the library would pick up the contents on re-render and calculate the score and distance, it would then put these into state allowing the image to show on screen on the next render.
To prevent the expensive computation from needlessly repeating on any subsequent renders I memoised the call using React's useMemo
hook.
This worked fine initially but I started seeing some issues with sub-optimal scoring and distance calculations and also with non-determinism.
1 second was not enough time for the library to accurately calculate the distance and score so I needed to increase the time. However, now that the calculation was running for longer I would need to provide some feedback to the user about what was going on. It felt like with a 1 second calculation I could get away without this but with any more it just looked like the website was hanging.
So I added some loading UI which would render when the file contents were known but the calculation had not yet completed. However the calculation, which now ran for 5 seconds, blocked any updates to the UI as it ran. The loading feedback never reached the user.
Initially I thought to reach for React's useEffect
hook. useEffect
runs every render after React has updated the DOM. I could wrap the calculation in useEffect
and delay its start until the loading feedback had reached the user. Calculations in useEffect
can be effectively memoised via the dependency array passed to the hook as a second parameter so the calculation would still only have to run once.
This solution worked well enough. Other suggestions for avoiding blocking the render would maybe also to have used the window.requestIdleCallback
api or the setTimeout
trick. However, the problem of non-determinism still remained.
5 seconds of computation on a browser running on a low-end phone is very different to 5 seconds of computation in a browser of a high-end PC. Thus, for the same flight log, different scores and distances would be calculated depending on where Picwing was running.
At this point it seemed sensible to consider offloading the calculation from the browser to a server process. I had been reticent to go down this path as I had enjoyed the development simplicity of everything running in the browser for this project. However, Picwing is built with Next.js which provides out of the box support for API routes. Picwing also uses Vercel for hosting which naturally integrates well with Next.js and eases the deployment burden.
So the calculation code was offloaded to an API route. The local development is handled well by the Next.js cli but there are a few gotchas to be aware of if you are deploying this to Vercel.
On the hobby plan, the maximum execution timeout is 10 seconds meaning that that would be a hard limit to the accuracy with which I could carry out the calculation. Obviously Next.js will not let you know when you are exceeding these limits locally so it may come as a surprise on deployment.
Similarly the maximum payload for serverless functions on Vercel is 5 MB but any requests with a payload over 1 MB will fail by default. This is because this is set in the default config for Next.js API routes and if you want the full payload limits you need to override it by exporting the following alongside your api route.
export const config = {
api: {
bodyParser: {
sizeLimit: '5mb',
},
},
}
However, now that these gotchas are out of the way I have enjoyed the benefits of offloading the computation to the cloud. I have deterministic calculations and an unburdened client which is free to render whatever user interface it wants. It also paves the way to explore whether the whole process could be done in the cloud to potentially open up API use. That puzzle I'll leave for a later date.