"You just leave this tae us, Kelda." ― Terry Pratchett, The Wee Free Men
Kelda is a thread pool abstraction on top of Web Workers. Use it to unclutter the main JS thread for a better user experience.
JavaScript is single-threaded. Web Workers allow the developer to offload computationally-intensive tasks to separate threads so the main UI thread isn't blocked from doing other important work (e.g. responding to user interaction). Workers can communicate with the main thread via postMessage
and with each other via BroadcastChannel
.
Workers do run on separate hardware threads, meaning that there is a hard limit to how many can be used at any one time. It is the responsibility of Kelda to manage the thread pool appropriately, e.g. using a sensible default or a user-specified pool depth.
To install, run npm install kelda-js
.
To use Kelda, create specific functions for the work you'd like Kelda to perform in a Web Worker, and pass them to Kelda at will. You can either pass a function, or a URL to a remote work module (more on both these options below).
Note that the arguments you pass to your work functions, as well as the return values of your work functions, cannot themselves be or contain functions. This is because only data of types supported by the structured clone algorithm can be sent between the main thread and Worker threads.
import Kelda from 'kelda-js';
import longRunningCalculation from './longRunningCalculation';
const options = { threadPoolDepth: 3 };
const kelda = new Kelda(options);
// Up to three jobs can be performed at once since a threadPoolDepth of 3 was specified.
const result = await kelda.orderWork(longRunningCalculation);
// longRunningCalculation runs in a Web Worker if available and in the main thread if not.
// Work functions you pass to Kelda *must* be entirely self-contained,
// and cannot contain any references to variables from outside their scope.
const result2 = await kelda.orderWork({
url: '/path/to/work/module',
exportName: 'myWork'
});
// If your work function requires variables outside its scope (e.g. other modules),
// you may expose it as a remote module.
// Simply provide Kelda with the URL of the script and the name of the work export (defaults to "default")
const eagerId = await kelda.load({ url: '/path/to/work/module' });
const lazyId = kelda.lazy({ url: '/path/to/work/module' });
// For ease of reuse, you may also load a remote work module directly into Kelda in exchange for a work ID.
// You can then execute the work repeatedly using that work ID, passing different arguments as needed.
const result3 = await kelda.orderWork(eagerId, arg1, arg2);
// Script is already loaded - eager
const result4 = await kelda.orderWork(lazyId, arg1, arg2);
// Script won't load until the first call to .orderWork() with this ID - lazy
A remote work module is a regular ES module with exports corresponding to the work functions you want to expose. The trick is that you'll need to compile this module to a JS bundle with a special property: it must return a module-like object when evaluated, with key-value pairs corresponding to the module's exports. This provides Kelda a way to hook into your module and retrieve your work functions in a Worker thread.
You are responsible for managing the build chain for your work modules, but some examples are provided below.
Due to the limitations of Web Workers, we cannot pass functions from the main thread to a worker thead. Instead, Kelda stringifies functions that you pass to it, and eval
s them in the Worker thread*. This approach cannot recursively copy references to the worker thread, so any variable referenced in the work function must be defined inside it.
For most practical usecases, you will probably want to be able to reference variables outside of the scope of your work functions. Apart from allowing you to modulerize your code, this will also enable you to take advantage of the wider JS ecosystem (e.g. using npm modules, or TypeScript). In this case, you cannot pass a function directly to Kelda - instead, you'll have to use a remote work module.
*Important note: do not ever put dynamic, user-generated content into a work module. Due to the nature of eval
this is a potential cross-site scripting attack vector. If you need to pass user-generated content to Kelda, do so by passing the content as arguments to kelda.orderWork()
.
You can bundle a work module in any way you like, as long as it returns a module-like object. There are multiple build tools available in the JS ecosystem that you can use to achieve this, depending on your preference. Let's see how this should work from an example.
Given the following work modules, how might we bundle them for Kelda?
// work/sayHello.js'
import localizedGreeting from '../localizedGreeting';
export function sayHello(name, locale) {
const greeting = localizedGreeting(locale);
return `${greeting}, ${name}!`;
}
// work/inefficientFibonacci.js'
export default function inefficientFibonacci(n) {
if (n === 0) return 0;
if (n === 1) return 1;
return inefficientFibonacci(n - 1) + inefficientFibonacci(n - 2);
}
For Kelda to work with these work modules, we need to bundle them into scripts that evaluate to the following:
- The bundle for
sayHello.js
shouldeval
to{ sayHello: function(){...} }
- The bundle for
inefficientFibonacci.js
shouldeval
to{ default: function(){...} }
Here's an example implementation of bundling remote work modules with webpack:
- Create a separate webpack config file for your work modules
- Make your work modules the entry points
- Add any config options you might want (e.g. you might want to extend your regular webpack config)
- npm/yarn install
terser-webpack-plugin
- Add a custom
TerserPlugin
to your webpack config that turns off thenegate_iife
andside_effects
optimizations - Add build task to your
package.json
- e.g."build-work-scripts": "webpack --mode=production --config=./work.webpack.config.js"
- Expose work bundles on your server (e.g. through
public
folder) - this makes remote work modules "remote", as they are loaded separately from the main JS bundle - Pass URL of work scripts to Kelda (see usage examples above)
The custom TerserPlugin
will prevent terser from removing the return value of your module (the exports) in production mode. Note that this will opt out of those optimizations for the entire build process, so you may end up with a larger bundle. Other build tools may have a more elegant way of bundling a work module.
// work.webpack.config.js
const TerserPlugin = require('terser-webpack-plugin');
module.exports = {
// ...your other webpack config here
entry: {
sayHello: './work/sayHello.js',
inefficientFibonacci: './work/inefficientFibonacci.js'
},
optimization: {
minimizer: [
new TerserPlugin({
//...your other terser config here
terserOptions: {
compress: {
negate_iife: false,
side_effects: false
}
}
})
]
}
};
The purpose of the Kelda project is to provide a managed threadpool abstraction for the browser. It tries to use Web Workers under the hood, and falls back to computation in the main thread when this is not possible (although Workers are well-supported by browsers at this point).
One or two similar solutions have been attempted (e.g. fibrelite), but these fall short of being satisfactory because they provide too few features to be truly useful, and because they fail to solve the technical constraints inherent in Workers. Kelda aims to overcome these shortcomings by providing:
- A global abstraction over Worker threads, thereby guaranteeing that the desired thread pool depth can never be exceeded at any given time
- Solving the variable scoping issue or providing a satisfactory workaround (currently doing this by having the user create separate build outputs for their worker scripts)
- Opt-in extensions, such as out-of-the box performance profiling that would allow developers to immediately gain insight into the tradeoffs of using Kelda for any given operation (Kelda could even potentially decide by itself which operations to move to Workers without the developer having to know about it)