Threads EXTENSION
A thread is a store that runs on a Web Worker. Not a store-shaped imitation: the framework itself loads in the worker and runs your definition as a real entity, with the same reactive graph a component, store, plugin or pool gets on the page. Its state and computed properties are evaluated off the main thread, and the page reads a mirror. Bindings, subscriptions and methods work as they do for any store.
What are Web Workers?
A Web Worker is a second JavaScript thread with its own global and no access to the page's global.
The two sides talk through postMessage, and every message is a structured-clone copy.
That is the whole interface.
Everything else is yours to build:
- A shape for your messages. One channel carries every kind, so each message needs a field saying which kind it is, and each end needs the code that reads that field and dispatches on it.
- Request ids, a map of pending callbacks, and the code that matches a reply to its request.
- An ordering rule for when a reply to an old request lands after you have already moved on.
- A copy of whatever state the page needs to show, kept in step by hand.
- Error handling, since a thrown error in the worker arrives as an
errorevent with a message and nothing else. - The loading arrangement: a separate worker file, a bundler step, or a Blob URL that a strict Content Security Policy refuses.
The Threads extension provides all the above. You write only the work the worker does.
What a thread adds
You write a store: state, computed, methods.
The extension runs it in the worker and gives the page a mirror of it.
From that one declaration the page gets:
- State, kept in step. The page reads the mirror as it reads any store. When a computed property changes in the worker, its new value is written into the mirror and the bindings that read it update. There is no message to write and no switch to maintain.
- Writes that are ordered. A write updates the mirror at once and is sent with a sequence number. The worker applies writes in order, and a reply to an older message never overwrites a newer write. Writes that arrive while the worker is busy are applied together, one recomputation for the newest input.
- Methods that return promises. A method in the definition becomes a method on the store that posts the call and resolves with the result, or rejects with the worker's error, name, message and stack intact.
- Residency. State and computeds listed as
workerOnlynever leave the worker. A dataset of a few hundred thousand rows stays on the worker; a keystroke goes out, and a count, a sum and a page of rows come back. - A per-frame channel. An underscore-prefixed field is sent whole when its identity changes, with no per-element reactivity, and a typed array in it is transferred rather than copied. A simulation's positions or a rendered frame cross as one buffer per tick.
- A loop.
tick(dt)runs in the worker on its own timer, and keeps running with the tab hidden. - Diagnostics with codes. A value that cannot cross is reported with its field and type. A write to a field the worker owns, a worker that failed to start under a Content Security Policy, a worker-only array mutated in place: each has a code, a message and an entry on the error-codes page.
- Nothing to bundle. The extension file is its own worker entry.
It loads the definition file by URL and the framework file it finds on the page, under
script-src 'self'; worker-src 'self'. - Types from the definition. The declarations derive the page's view from what you wrote, so a computed's return type is the field's type and a
workerOnlyname is a compile error rather thanundefined. See TypeScript in the guide.
Underneath it is postMessage and structured clone, with no shared memory and no new primitive.
The extension is built on the same calls that you would use writing the messaging yourself.
It changes how much of that you write, and it reports the common mistakes with a code.
Threads compared to alternative solutions
These differ in what crosses the boundary. A raw worker moves messages you design, and the libraries built around workers move functions. A thread moves state.
| Raw Worker | Comlink | Hook-style helpers | Threads | |
|---|---|---|---|---|
| What crosses | Messages you design | Calls and their results | One function's inputs and its result, per call | Writes out, changed results back |
| State on the worker | Yours to keep | Behind the functions you expose | None between calls | Declared; worker-only data never leaves |
| What the page holds | Whatever you copy | A proxy of the exposed object | The last result | A store: the mirror |
| Ordering | Yours to enforce | Per call; stale replies are yours to handle | One call at a time per hook | Sequence numbers, last write wins, inputs coalesced |
Comlink is the right tool when what you have is a function to call, and it is about a kilobyte.
State can live behind the object you expose, and it stays behind it: every read is a call, so await api.count() is a round trip where a thread's orders.count is a property.
threads.js is the same model with pools, observables for streaming results, and TypeScript types across the boundary.
The hook-style helpers, useWebWorkerFn in VueUse and the useWorker family in React, run one function off-thread with its inputs cloned per call.
That suits a hash, a parse or a layout calculation.
It does not suit a dataset that should stay put, because the function is serialised into a Blob and keeps nothing between calls: its documentation requires a function with no local dependencies and no side effects.
The closest of them is Solid's createSignaledWorker, which takes an input signal, an output setter and a function: change the input and the output signal is set with the result.
That is the same idea as a thread's inputs and outputs, for one function.
A thread declares a whole store instead, so several computed properties share the state they read, methods mutate it, and the state itself can be declared worker-only.
Those tools also have things a thread does not: a per-call timeout, worker pools, callbacks back into the page, custom serialisation, and streaming from one call. What a thread does not do in the guide says what each one is and what to use instead.
The mirror
A thread is two entities, one in each realm.
The worker loads the framework file the page loaded and registers your definition through wildflower.store(), so what runs there is a real store with its own reactive graph: computeds track their dependencies, watchers fire, lifecycle hooks run.
The page registers a second store under the same name, the mirror, and the extension keeps the two in step with messages.
Nothing here reimplements reactivity; the extension is the wiring between two instances of the framework's own entity model.
The real store runs in the worker. The page holds a copy of the parts of it the page is allowed to see: the input fields, the computed results, and three bookkeeping fields. That copy is the mirror, and on a WildflowerJS page it is registered as an ordinary store under the thread's name.
A read is a read of the copy: the last values the worker sent, possibly one message behind. A write to an input field changes the copy at once, so your own next read sees it, and is sent to the worker. When the worker's computed properties change, it sends the new values back and the runtime writes them into the copy, which is what makes the bindings update. Nothing the page does reaches the worker's state directly; the two sides only ever exchange messages, and the mirror is the page's side of that exchange.
What is not in the mirror: state and computeds listed as workerOnly, which stay in the worker, and methods, which run there.
The guide uses "the store" and "the mirror" for the same object.
The sizes are from the orders demo: five keystrokes crossed about 20 KB in total, and the 200,000 rows crossed nothing.
Compared to standard Web Worker code
You keep the code that does the work and delete the messaging. A typical pair of files:
// before: worker.js
var rows = [];
onmessage = function (e) {
var m = e.data;
if (m.type === 'load') { fetch(m.url).then(r => r.json()).then(function (data) { rows = data; postMessage({ id: m.id, type: 'loaded', count: rows.length }); }); }
if (m.type === 'search') { var hits = rows.filter(r => r.name.includes(m.q)); postMessage({ id: m.id, type: 'result', count: hits.length, top: hits.slice(0, 50) }); }
};
// before: page
var worker = new Worker('worker.js'), nextId = 1, pending = {};
worker.onmessage = function (e) { var cb = pending[e.data.id]; delete pending[e.data.id]; if (cb) cb(e.data); };
function call(msg) { return new Promise(function (res) { msg.id = nextId++; pending[msg.id] = res; worker.postMessage(msg); }); }
call({ type: 'load', url: '/api/rows.json' }).then(function () { return call({ type: 'search', q: 'smith' }); }).then(function (r) { el.textContent = r.count; });
// after: orders.js, one file for both sides
wildflower.thread('orders', {
state: { rows: [], params: { q: '' } },
workerOnly: ['rows', 'hits'],
computed: {
hits() { return this.rows.filter(r => r.name.includes(this.params.q)); },
count() { return this.hits.length; },
top() { return this.hits.slice(0, 50); }
},
async load(url) {
const res = await fetch(url);
this.rows = await res.json(); // rows is workerOnly: it stays here
}
});
// after: page
<span data-bind="$orders.count"></span>
var orders = wildflower.getStore('orders');
await orders.load('/api/rows.json');
orders.params.q = 'smith';
The search that was a message type is now a write to params.q, and the result that was a reply is now count and top updating in the mirror.
Ordering, ids and the reply switch are gone; the runtime does that work.
Coming from Comlink, threads.js, VueUse or Solid's createSignaledWorker instead?
Coming from another worker library in the guide has the equivalent for each, with code.
Measured
The orders dashboard demo holds 200,000 rows and recomputes five values on every keystroke, with a toggle between running that on the main thread and on a thread. Typing a five-letter word, medians over three rounds, headed Chromium on Apple Silicon, 2026-09:
| Compute on | Keystroke to painted result | Long tasks | Longest animation stall |
|---|---|---|---|
| main thread | 173 ms | 5 | 300 ms |
| thread | 28 ms | 0 | 18 ms |
On the main thread every keystroke is a long task and the page's animation stops for a quarter of a second each time. On the thread the page never misses more than one frame, and about four kilobytes cross per keystroke. The thread's answer is also faster, because the worker reads its own rows as a plain array.
Next
- Using Threads: loading, the definition, the page side, what crosses when, the per-frame channel, lifecycle, every diagnostic code, and the options.
- Orders on a Thread, with a main-thread toggle on the same page.
- The Error Codes page, filtered to Threads.