Off Main Thread Architecture with Vuex
In the last chrome dev summit, Surma’s talk on the off-main-thread architectures and a following article on its possible applications on common technologies like React and specifically Redux inspired me to explore similar opportunities for Vue and Vuex.
This article was intended to be a 30-minute talk, but I decided to write it down in-case I didn’t get the chance to do it.
Nature of JavaScript
First let me quickly remind myself about the nature of JavaScript execution. JavaScript is a single-threaded language, meaning you cannot execute two statements at the exact same time and by extension you cannot execute two functions at the same moment. Vue reactivity actually relies heavily on this fact.
Any asynchronous logic is not concurrent, stuff like fetch
, setTimeout
and Microtasks/Promises in an independent execution/environment handled as web APIs, any callbacks are then executed on the main-thread when calls to those APIs finishes, this is what is called the “event loop”. Again, not concurrent.
The main-thread has different names, but in programming languages like C# it is called the “UI-Thread”. Throughout this article I would be using Main/UI thread interchangeably but just know that they refer to the same thing.
Let me breakdown some concepts from Surma’s article…
UI thread for UI work only
That means any non-visual logic that exists in your application should not be done on the main thread. Which is a lot of stuff we already do in our apps, here is a couple of non-UI stuff that we do everyday:
- fetching data from endpoints.
- Image/Data Processing.
And if you think about it, that almost covers most of our components in our apps. And if you really think deeply about it, it boils down to state management in a way or another.
Remembering my C# days as a Universal-apps developer that never became a thing, I still remember a very lovely quote from one of C# books that I read:
Async/Await
is infectious, whenever you mark a method as async, mark other methods as async as well. There is no downside to marking methods in your app asasync
.
That made sense, as C# is a managed language and using async/await
automatically meant concurrent. And in the usual MVVM architecutre, typically the data lived in the UI-thread bound to the UI with data binding, but any actions to manipulate them were done off the UI-thread using async/await
.
This is not the case in JavaScript, but we do have a way to do real concurrency.
Concurrency In JavaScript
You will need concurrency if you are doing a lot of work, doing a lot of work on the main-thread will make it unresponsive and will stop everything in the webpage. Stuff like GIFs or videos will stutter, buttons will not respond and scrolling will be janky. The only way to perform better is to do less work. Moving some of the work to the background-threads will make the main-thread do less work and thus able to process more things.
While JavaScript is single-threaded, we have a relatively old construct that allows us to do real-concurrent computing, called Web Workers.
The worker thread can perform tasks without interfering with the user interface.
This is the very definition of a background thread in other programming languages. Like the quote suggests, Web Workers are true background threads as in they literally have no access to the DOM and many other Web APIs are also not available due to them being UI-thread related stuff.
You can communicate with workers through an event-based API. This is how to spawn a worker in the main-thread and send/receive messages with it:
jsvar myWorker = new Worker('worker.js');
myWorker.postMessage('Hello World!');
myWorker.onmessage = (e) => {
console.log(e.data);
};
The worker.js
file is a JavaScript file that contains the code for your worker, here is what the other side looks like:
js// Handles messages from the main-thread.
onmessage = (e) => {
console.log('Message received from main script');
var workerResult = `Message: ${e.data}`;
console.log('Posting message back to main script');
// Replies to the main-thread
postMessage(workerResult);
};
Additionally you can use self
to point to the worker global context:
jsself.postMessage(workerResult);
self.onmessage = (e) => {
// ...
};
That means we can dispatch in either direction from the main-thread or any worker-thread, but also means our dispatched work is always asynchronous.
Async in Vuex
Now that I reviewed the fundamental peaces we are working with, let’s see how does that map to Vue and specifically Vuex.
Vuex is the “official” state-management solution for Vue.js and it tackles the problem of managing application state and its life-cycle throughout your app. Vuex can be used to do the following:
- Shared State Store.
- Data fetching/caching layer.
- Data Persistence Layer.
- Dynamic run-time module data store.
- Act as a client-side ORM.
While I do no agree on the last two use-cases but it can be done and a lot off great people are advocating it. You will notice that the common property between all of mentioned use-cases is all of them are achievable if the state is decoupled from the UI components. A typical Vuex store/module looks like this:
jsconst store = new Vuex.Store({
state: {
count: 0,
},
mutations: {
SET_COUNT(state, value) {
state.count = value;
},
},
actions: {
increment({ commit, state }) {
commit('SET_COUNT', state.count + 1);
},
},
});
I have omitted
getters
as they are only data mapping utility for thestate
.
What we do here is relatively simple, but if we were to do some heavy workload that would block the main-thread it will have serious consequences.
If you have paying close attention, I have been highlighting some aspects that map to any Vuex store/module. Let’s check how state
, mutations
and actions
translate to doing off main-thread architecture.
First we have the state
, there isn’t really anything we can do about them, state should always exist in the main-thread and I believe we would be breaking reactivity if doing off-main-thread state is even possible.
Secondly, we have mutations
. Mutations aren’t really helpful because in Vuex they must be synchronous, which is a deal-breaker for our architecture as we could only communicate with web-workers in an event-based fashion which means asynchronous. So we conclude that mutations should stay in the main-thread as well given they only set the data.
Lastly we have actions
, from vuex docs:
Actions can contain arbitrary asynchronous operations.
That makes it a perfect fit for our experiment here, and even they are called dispatchers
in other libraries/languages. If we would think about actions
as event-dispatchers we can treat them as our communication hub between the main-thread and background threads.
So our takeaway in this section is that Actions are async event dispatchers.
Going off-road
Let’s start applying what we discovered. To build web-workers with import
we would need a bundler capable of handling web workers, of course Webpack does that some what well using the worker-plugin or worker-loader, use either.
A major limitation is that it cannot share code chunks between the main-thread bundle and the worker thread bundle. I won’t go into the details of setting up a Vue project as you can do so easily with vue-cli
.
Here is an example I created that does some prime numbers stuff, consider it as a simulation for a heavy workload that you do in your Vuex stores.
If the GIF is not locking up for you, try to run the example on FireFox.
What’s funny here is that I have setup a Loader
component to show the user that some work is in progress, but because we are doing so much work the UI-thread never gets the chance to even display it!
Let’s start thinking about how we would move our actions off UI-thread, let’s start by building our actions worker:
js// actions.js
function calculatePrimes(iterations, multiplier) {
// ...
}
self.onmessage = (e) => {
if (e.data === 'generateItems') {
// Perform the calculation
const primes = calculatePrimes(400, 1000000000);
// TODO: Send result back to the main-thread
}
};
That looks simple enough, we dispatch the appropriate action based on the received message from the main-thread. We don’t have a good idea yet on how to send the data back so let’s come back to it later.
Now in our main-thread our store.js
file will be changed slightly, it will look like this:
js// store.js
import Vuex from 'vuex';
import Vue from 'vue';
// Will be handled by worker-plugin
const actions = new Worker('./actions.js', { type: 'module' });
Vue.use(Vuex);
export default new Vuex.Store({
state: {
// ...
},
mutations: {
// ...
},
actions: {
async generateItems({ commit }) {
// TODO: Dispatch action to the worker thread.
commit('SET_WORKING', true);
actions.postMessage('generateItems'); // how do we wait for the data?
// actions.postMessage('generateItems', { commit }); // Can we send commit fn to the worker?
commit('SET_WORKING', false);
},
},
});
Now the computation is being done correctly but we didn’t hook the mutations part in our off-thread actions, you could try to send the commit
function to the worker-thread but you will recieve an error when you do so, this is because not all JavaScript structures can be sent between threads. Only the ones that support structure cloning algorithm.
That means our actions will be only able to calculate the state and then defer setting its value on the main-thread. Let’s modify our actions.js
file to tell us which mutation to run:
js// actions.js
function calculatePrimes(iterations, multiplier) {
// ...
}
self.onmessage = (e) => {
if (e.data === 'generateItems') {
// Perform the calculation
// We can trigger any mutations from here!
self.postMessage({ type: 'SET_WORKING', payload: true });
const primes = calculatePrimes(400, 1000000000);
self.postMessage({ type: 'SET_ITEMS', payload: primes });
// We can trigger any mutations from here!
// Set the loading state back to false
self.postMessage({ type: 'SET_WORKING', payload: false });
}
};
Our store code will then listen for all messages recieved from the worker-thread and trigger the appropriate mutation based on its type
property.
js// store.js
import Vuex from 'vuex';
import Vue from 'vue';
// Will be handled by worker-plugin
const actions = new Worker('./actions.js', { type: 'module' });
Vue.use(Vuex);
const store = new Vuex.Store({
state: {
// ...
},
mutations: {
// ...
},
actions: {
async generateItems({ commit }) {
actions.postMessage('generateItems');
},
},
});
// Handle incoming messages as commits!
actions.onmessage = (e) => {
store.commit(e.data.type, e.data.payload);
};
export default store;
Here is the same example after our modifications, note that codesandbox does not support web workers at the time of this writing:
You can see the code here.
Now that we have successfully managed to execute actions off the main-thread it is still not very robust. Let’s see how could we refactor this to support any Vuex module we might have.
Refactoring
It would be great if we could write our Vuex modules as-is without having to detach the actions from the store, Surma’s article used comlink to expose web-workers as asynchronous interfaces, pretty much similar to what we did earlier but in a much reliable way.
I couldn’t use comlink properly due to the nature of Vuex actions being a part of the store, I didn’t really want to fiddle around for a long time and decided to have my own implementation of the matter tailored to Vuex modules.
I will follow a similair API to comlink’s. First we need to define an wrap
function that would accept a store options object (state, mutations, actions) and force its actions to be executed by dispatching it instead on the worker thread.
js// lib.js
import Vuex from 'vuex';
// Use this in the Main-thread
export function wrap(storeOpts, worker) {
if (!storeOpts.actions) {
throw new Error('Your Vuex store must have actions');
}
// Clone store options
const opts = {
...storeOpts,
actions: { ...storeOpts.actions },
};
// cleanup actions
const emptyAction = () => {};
Object.keys(opts.actions).forEach((key) => {
opts.actions[key] = emptyAction;
});
const store = new Vuex.Store(opts);
// Handle commits by the worker
worker.onmessage = (e) => {
store.commit(e.data.type, e.data.payload);
};
// Intercept actions and dispatch it to the worker.
// https://vuex.vuejs.org/api/#subscribeaction
store.subscribeAction((action) => {
worker.postMessage(action);
});
return opts;
}
There shouldn’t be anything new here, the wrap
function accepts storeOpts
object that contains the store definition, and a worker instance.
Next we need to implement the other side, an expose
function that also accepts a storeOpts and wraps the actions
code with a custom commit
function that will send the data back to the main-thread.
js// lib.js
export function expose(storeOpts) {
if (!storeOpts.actions) {
throw new Error('Your Vuex store must have actions');
}
// we only need the actions.
const opts = { actions: { ...storeOpts.actions } };
const actions = opts.actions;
Object.keys(actions).forEach((key) => {
const executeAction = actions[key];
actions[key] = function offThreadAction(payload) {
// A fake `commit` fn that dispatches the mutation on the main-thread.
function commit(mutationKey, value) {
self.postMessage({ type: mutationKey, payload: value });
}
// Run the actual action code with our fake `commit` fn
return executeAction({ commit }, payload);
};
});
// Whenever a message is received from the main-thread.
// Execute it as an action, as it would have { type: 'actionName', payload: '...' }
self.onmessage = (e) => {
actions[e.data.type](e.data.payload);
};
return opts;
}
Let’s put all of this in action, here is our store file:
js// store/index.js
import Vue from 'vue';
import Vuex from 'vuex';
import { wrap } from '../lib';
import opts from './opts';
Vue.use(Vuex);
export default wrap(
opts,
new Worker('./worker', { type: 'module' })
);
We separated the worker file because currently they must live in their own file:
js// worker.js
import { expose } from '../lib';
import store from './opts';
expose(store);
You can find the complete code for this refactored example here
There is room of improvement of course, like supporting nested modules or more complicated stores but that is outside of the scope of this post I might publish a Vuex plugin for it if I find the time to build something flexible and robust.
Conclusion
We managed to adapt our Vuex store into off main-thread architecure, this however doesn’t improve performance as the same amount of work is being done. We merely offloaded the work to a background thread which allowed our app to stay responsive to handle user interaction.
Thanks for reading 👋