How We Fixed Performance With JS Object Variable Mutation
It’s vitally important in software development to figure out what your software will need to do and to make sure that the technologies you use to build it support this functionality. It is a major waste of resources, let alone a massive frustration to development teams, to have to go back to the drawing board.
Even with the most careful planning, however, things don't always go as you want them to. Some libraries and frameworks over-promise on features, leading teams to build on them only to find that their final product doesn't meet expectations. Additionally, developers working on products that tread new ground may run into unanticipated compatibility or performance issues.
This article explores one such scenario that we ran into while implementing a new piece of functionality — JS Object variable mutation — for our Appsmith internal apps platform. If you're unfamiliar with us, you can check out how easy it is to use Appsmith to build a CRUD app, and you can read about how we’re helping businesses improve their internal processes by giving them the tools to build better internal tools.
Why are we talking about JS Object variable mutation in Appsmith?
Variable mutation, in its simplest definition, is changing the value of a programming variable. In the context of Appsmith, JS Objects encapsulate variables and functions at a page-level scope for re-use — analogous in form and function to JavaScript objects, which encapsulate properties and methods.
Historically, variables stored in Appsmith JS Objects have acted as constants — they were defined in the application code and are immutable from there on. They could not be changed while the app was running. This was not very useful, and was always something that we knew we would need to address to make the JS Objects functionality feature-complete.
To the delight of our users who have been waiting patiently for this feature to arrive, variable mutation in Appsmith’s JS Objects is now fully supported.
How were users working around this issue?
Appsmith is a versatile platform, so the lack of variable mutation didn't slow down those using the platform to build their apps.
The workaround favored by most was to use Appsmith's storeValue() function to save data for the current session from within JS Object functions. As this was not really what storeValue()
was intended to be used for, it was neither convenient nor intuitive. Storing data from JS Objects in this way was overly verbose, requiring setting and retrieving them using names unique to each object. It was also slow, as it used the browser’s sessionStorage property.
In addition to being overly verbose when used to manage variables for JS Objects, using storeValue()
required extra scaffolding to work. The key/value pairs stored using this method had to be initialized in a page load function before they could be read. Every object would need its own set of uniquely named key/value pairs stored in the session using storeValue()
for each of its variables, with each being initialized on page load. It was a lot to keep track of, and it wasn't clear that this initialization process was necessary, leading to a lot of frustration.
Our users were quite clear that they were expecting mutable JS Object variables to arrive sooner rather than later. Appsmith's JS Objects are written in JavaScript and they're called “JS Objects,” so you’d expect that they would behave like JavaScript objects and let you update their variables!
Initial implementation... and performance problems
So, that's what we built, and we built it in the most obvious way — using JavaScript Proxy objects to track mutations and reflect those changes across Appsmith’s framework. Initially things looked good — it worked, aside from a few hacks to make some data types work with map and set, and we were following the example of other projects that had similar requirements. If it was good enough for them, it should be good enough for us, right?
However, once we had fully implemented the functionality, we ran into performance issues caused by a mitigation that we had put in place. Appsmith relies on JavaScript workers to execute the user's code from JS Objects, and when using a Proxy
object, we cannot directly pass data from workers back to the main thread (which handles things like UI widgets bound to the variables in a JS Object). This is because the postMessage API that is used to send messages between the web workers and the main thread internally uses the structuredClone() function, which does not support Proxy
objects. To get around this, we were using our own nested cloning algorithm that removed the Proxy
object in the worker thread before passing it to the main thread, which was harming performance.
Whenever we had to send data to the main thread back from a worker, this cloning algorithm had to run. Wherever and whenever the data in the JS Object appeared, the algorithm was triggered. This was OK when an app used only a few simple objects, but the more objects with the more mutable variables were added, the worse performance would become. Our users build complex applications, so this proportional degradation of performance was unacceptable.
This diagram shows how our initial solution to passing data from the worker to the main thread worked — and where the slowdown was occurring. You can also see the code we used here.
Architecture-wise, this feature was also hard to maintain. The algorithm for cloning the data had to appear everywhere the data was used – so if we missed it in even a single place when adding a new feature, we’d introduce a bug. However, if it had been performant and provided the best results for our users of all available options, we would have stuck with it.
The reality was, using Proxy
objects to achieve variable mutation in this manner was just too slow and we couldn't justify releasing it as it was. We assessed the potential impact of holding this feature back from our users (and carefully considered how much goodwill we had!) and decided to find a better way.
How we did it better — matching the existing functionality, with great performance
So, we had a problem to solve — we had to mimic the functionality provided by the initial solution, but without the downsides. We had to get creative. Deciding to rebuild a feature that already had resources invested in it isn't favorable from a planning perspective, but from a programmer’s perspective it's great — we love to problem solve.
We wanted a solution that solved both the performance and development issues caused by the cloning algorithm. It had to be efficient, so that the user interface didn’t become slow or unresponsive, and DRY, so that we wouldn't have to call the algorithm at every appearance of an Appsmith JS Object.
Our ultimate solution wound up being pretty simple. Instead of using a Proxy
object to intercept and track changes to a JS Object’s variables, we have a custom class that takes a variable from a JS Object as input and adds setter/getter methods that track read and write operations to the variable itself. Calling a setter indicates that a variable may have been updated, and we can then run some differential logic on it to confirm whether there was an actual update. Once the change has been confirmed, it can be propagated and passed from the worker to the main thread. You can see the code behind this here.
This diagram shows our updated solution, and how we avoided using Proxy objects to track changes.
As we no longer have any Proxy
object, we don't need any nested cloning logic to remove it from the JS Object variable before passing it from the worker to the main thread. This removes the performance issue as it’s much cheaper to call the getter or setter than run the cloning algorithm.
I'm sure that to some programmers this may not seem to be the best way to track updates when compared to the Proxy
implementation — but when workers are involved, we found this new approach to be very practical. Most importantly, it worked, and it was fast. We could return to our users with a solution that would really improve their experience with our product.
Delivering on promises to users without compromising the Appsmith experience
We could have delivered JS Object mutation in its initial state and just added the proviso “Hey, it works well enough, it's just slow if you use too many variables! If you suffer performance issues, restructure your app!” Many products foist the consequences of poor implementation on the user and frame it as a problem for them to solve. This isn’t good enough for us. We wanted to fully implement this feature in a way that would give our users what we had promised them without compromising the performance of our platform.
Now that we’ve achieved this, we’re looking at how we can more quickly identify potential problems with our planned implementation, so that we can reduce the delays caused when this happens. We are also making sure that we keep in touch with those who have reached out to us about the features they need and the bugs they may have encountered, and keep them up to speed with progress so that they can align their own development timelines with ours.
This experience highlighted to me the importance of staying engaged with your community, making sure you listen to them, and keeping them informed about any developments that affect them. By listening to your users, you will know which features to prioritize, and you can make sure to set realistic expectations for them and validate their feedback. That way, if there are delays, you are more likely to be met with understanding rather than frustration.
You also can’t go breaking the user experience for a solution that technically works, but degrades the overall user experience, to solve a single problem. It’s important to not settle on the first solution to a problem if it isn’t good enough — even if it appears to be the only one at first. Programming is a creative endeavor, and there’s (almost) always a workaround.
If you're interested in reading more about how we go about building Appsmith, check out our blog. If you want to see the results of our hard work in action, Appsmith offers a free cloud-hosted version, or you can host on your own infrastructure using Docker.