[{"url":"/","page":"@arv/threads","section":null,"text":"@arv/threads brings real threads to JavaScript, in plain TypeScript. Your code runs in parallel on Web Workers that share memory through a SharedArrayBuffer. You also get a programming model you can reason about. Every value is owned by one thread, shared read-only, or a cell built for sharing. A checker proves that your code follows those rules. Here's a quick taste: import {f64, scope, SharedArray, struct, type Struct} from '@arv/threads'; const Body = struct({x: f64, y: f64, vx: f64, vy: f64, m: f64}); // Lives in shared memory. type Body = Struct<typeof Body>; function step(bodies: SharedArray<Body>, lo: number, hi: number) { for (let i = lo; i < hi; i++) bodies[i].x += bodies[i].vx; // Every thread sees the same memory. } export function main(threads: number) { const bodies = new SharedArray(Body, 800); scope(s => { for (let t = 0; t < threads; t++) s.spawn( step, bodies, Math.floor((t * 800) / threads), Math.floor(((t + 1) * 800) / threads), ); }); // Joins every thread it started. } Status: experimental The API, the checker and the runtime work, and they're tested. But the compiler that lays shared structs out in shared memory is a fork of TypeScript 7, arv/threads-checker, and you have to build it yourself. See Status. You can try it in the browser, with the compiled JavaScript side by side, in the playground. Its source is in playground/. Disclaimer This project was mostly built by iterating with the help of Claude Code (Claude Opus 5.5)."},{"url":"/#where-to-go-next","page":"@arv/threads","section":"Where to Go Next","text":"Install the packages, and set up the Vite plugin, the page and the checker. Write, check and run a small program in the tutorial. Read about the model, the three kinds of values it rests on."},{"url":"/api/goroutines-and-channels/","page":"Goroutines and Channels","section":null,"text":"Everything here comes from \"@arv/threads\". go(f, ...args) Starts f as a goroutine, and nothing waits for it. A plain function gets a thread of its own, and an async function is light, one of many on a worker. new Channel<T>(capacity = 0) send(value) moves a value in, recv() takes the next one out (undefined once closed, as for a sent undefined), and close() closes it. It's iterable. ch.recvOk() recv(), plus whether a value came: [value, true], or [undefined, false] once the channel is closed and empty. ch.sendAsync(v), ch.recvAsync(), ch.recvOkAsync(), for await (const v of ch) The same, for an async function. They wait without blocking the worker. select(...channels), selectAsync(...channels) Waits for the first channel that has a value or is closed, and gives [index, value, ok]. ok is false for a closed channel. Go-Style Concurrency shows them at work."},{"url":"/api/maps-and-sets/","page":"Maps and Sets","section":null,"text":"Everything here comes from \"@arv/threads\". new SharedMap<K, V>(entries?) and new SharedSet<T>(values?) are Map and Set in shared memory. They have the same methods, and the same semantics: Keys are compared as SameValueZero. A string is compared by its text, and a cell or a shared value by identity. Iteration is in insertion order. A loop sees what's added while it runs, and skips what's deleted. Keys and values can be numbers, strings, booleans, null, undefined, cells (structs, SharedArrays, maps, channels, locks), shared values, or plain objects and arrays. Plain objects and arrays move in, and are shared from then on. Each call is atomic. getOrInsert(key, value) and getOrInsertComputed(key, f) add a key only if it's missing, so threads that add the same key all get one value: const Count = struct({n: i32}); type Count = Struct<typeof Count>; function count(counts: SharedMap<string, Count>, words: Shared<string[]>) { for (const w of words) atomic.add( counts.getOrInsertComputed(w, () => new Count()), 'n', 1, ); }"},{"url":"/api/odds-and-ends/","page":"Odds and Ends","section":null,"text":"Everything here comes from \"@arv/threads\". log(...args) prints a line from any thread. heapStats() reports the shared memory in use, and the blocks allocated and freed."},{"url":"/api/owned-and-shared-values/","page":"Owned and Shared Values","section":null,"text":"Everything here comes from \"@arv/threads\". Sending<T> A value that moves. The receiver owns it, and the sender must not use it again. Shared<T> A deeply readonly value that any number of threads may read. share(value) Freezes a value the caller owns, deeply, into a Shared<T>. consume(value) Ends a value's lifetime, for the checker. The Model explains the three kinds of values, and when to use each."},{"url":"/api/shared-memory/","page":"Shared Memory","section":null,"text":"Everything here comes from \"@arv/threads\". Shared memory holds data that many threads read and write in place, with no copying. Reads and writes aren't ordered between threads. A Lock, a Channel, joining a thread, or an atomic operation is what orders them. struct({ x: f64, n: i32, done: bool, name: str, next: ref<T>() }) A shared struct type. new S() makes a zeroed one, and Struct<typeof S> is the type of its values. f64, i32, u8, bool, str, ref<T>() Field kinds: a number, a 32-bit integer, a byte, a boolean, a string (interned), and a reference to a struct, a SharedArray, a Channel, a Lock or a Condition (or null). new SharedArray(Number | i32 | u8 | Boolean | String | S, length) A fixed-length array of numbers, i32s, bytes, booleans, strings or structs, stored inline. atomic.add(s, \"field\", v), atomic.add(array, i, v) An atomic operation on an i32 field or on an element of a SharedArray<i32>: load, store, add, sub, and, or, xor, exchange, compareExchange, wait and notify. Compiled, it's that Atomics call itself."},{"url":"/api/shared-memory/#integers","page":"Shared Memory","section":"Integers","text":"i32 and u8 are types too. They're numbers, kept the way an Int32Array or a Uint8Array keeps them, so 2³¹ wraps and 256 is 0. The checker requires that what you store into one is an integer. That means integer arithmetic, or an explicit conversion such as Math.round(x) or x | 0. A number that may have a fraction isn't allowed, because the store would truncate it silently."},{"url":"/api/shared-memory/#atomics-and-locks","page":"Shared Memory","section":"Atomics and Locks","text":"With an atomic counter, threads can claim work with no lock: const Work = struct({next: i32}); function worker(work: Struct<typeof Work>, n: number) { for (let i; (i = atomic.add(work, 'next', 1)) < n;) process(i); // Claims each item once. } A lock turns several reads and writes into one step. A struct can hold its own lock, just as it can hold a channel, and using releases the lock at the end of the block: const Account = struct({balance: f64, deposits: i32, lock: ref<Lock>()}); function deposit(account: Struct<typeof Account>, amount: number) { using _ = account.lock!.hold(); account.balance += amount; account.deposits++; }"},{"url":"/api/shared-memory/#freeing-and-structs-that-refer-to-themselves","page":"Shared Memory","section":"Freeing, and Structs That Refer to Themselves","text":"Structs and SharedArrays are reference counted, and they're freed when no thread can reach them. To declare a struct that refers to its own type, use an interface: interface Node { value: number; next: Node | null; } const Node = struct<Node>({value: f64, next: ref<Node>()}); Reference Counting explains how they're counted and freed."},{"url":"/api/the-page/","page":"The Page","section":null,"text":"The page's API comes from \"@arv/threads/browser\": start({ workers, check, onLog }) starts the pool, with one worker per core by default. run(f, ...args) runs f on a thread and resolves with its result. stop() stops the pool. The pool's options are the ones the first start() was given. If the pool hasn't started, run starts it with the defaults. So a page that wants other options calls start before its first run. A later start with other options throws. stop() terminates the workers, along with the threads they ran, and fails the runs that are waiting. After that, a spawn throws until start() starts a new pool on the same heap. That's also how you recover a stuck pool, such as one with a thread that never ends: call stop(), then start() again. Set Up shows a page's entry module."},{"url":"/api/threads/","page":"Threads","section":null,"text":"Everything here comes from \"@arv/threads\". spawn(f, ...args) Starts f on a thread. Owned arguments move, and shared values and cells are shared. The Thread it returns stays here: it isn't sent or shared. thread.join(), thread.joinAsync() Waits for the thread, and its result moves back. If the thread threw, it throws an Error that says what the thread threw, whose cause has the thread's stack. scope(s => ...) Structured threads. It joins every thread that s.spawn started before it returns. The body isn't async. partition(items, parts), take(items, i) partition splits an owned array into owned parts, and take takes one element out. register(import.meta.url, {f, ...}) Registers a module's top-level functions, so a thread finds the one it runs. The compiler adds this call for you, and a program that runs uncompiled calls it itself. new Lock() using _ = lock.hold() holds the lock until the end of the block, and await lock.holdAsync() waits for it without blocking. new Condition() wait(lock) releases the lock until another thread calls notifyOne() or notifyAll(), then holds it again. waitAsync(lock) waits without blocking."},{"url":"/checker/","page":"The Checker","section":null,"text":"The checker runs in CI, like a linter. It reports what TypeScript's types can't see: A value used after it was sent. An alias of something sent. Sending what the caller still holds. A write to a shared value through a cast. A number that may not be an integer, stored into an i32 or a u8 (see Shared Memory). A blocking call in an async function (see Go-Style Concurrency). Here's how you run it, and what an error looks like: npx @arv/threads-check -p tsconfig.json --runtime @arv/threads app.ts(23,16): error TS96007: 'order' cannot be sent: it may be referenced by the caller. Declare the parameter 'Sending<T>' to take ownership. The checker needs no fork. @arv/threads-check runs on stock TypeScript 7.1's API. The checker reads the source, so it reports the same errors whether a program is compiled or runs with its types only erased (see Performance). The tutorial shows how the checker follows each value through a function, and the mistakes it catches."},{"url":"/go-style-concurrency/","page":"Go-Style Concurrency","section":null,"text":"Threads can also talk to each other the way Go does. go(f, ...args) starts a goroutine, and a Channel<T> moves values between threads in order. You can send and recv, loop with for (const v of channel), and wait on several channels with select. When you send a struct or a SharedArray on a channel, it stays where it is, in shared memory. The receiver reads the same one."},{"url":"/go-style-concurrency/#closed-channels","page":"Go-Style Concurrency","section":"Closed Channels","text":"recv() returns undefined both for a sent undefined and once the channel is closed. To tell the two apart, use const [v, ok] = channel.recvOk(), just like Go's v, ok := <-ch. select gives you the same ok, as its third element. So a Channel<void> either signals by close(), which wakes every receiver, or is read with recvOk()."},{"url":"/go-style-concurrency/#async-goroutines","page":"Go-Style Concurrency","section":"Async Goroutines","text":"A goroutine of an async function is light, just like Go's are. One worker can run thousands of them, and each one waits with await without blocking the others. Here's what each kind costs in Node on an M5 Pro: Goroutine Time to start Memory On a worker of its own 3.2 ms 8.6 MB Of an async function 0.2 µs About 350 bytes A blocking call in an async function would stop every goroutine on its worker. So the checker reports it, and the runtime throws. interface Replica { position: i32; changed: Channel<void> | null; } // Closed, and replaced, on every change. const Replica = struct<Replica>({position: i32, changed: ref<Channel<void>>()}); async function request( replica: Replica, position: i32, answers: Channel<string>, ) { for (;;) { const changed = replica.changed!; if (atomic.load(replica, 'position') >= position) break; await changed.recvAsync(); // One of thousands waiting on this worker. } await answers.sendAsync('ready'); } for (let k = 0; k < 10_000; k++) go(request, replica, (k % 1000) + 1, answers); // 0.2 µs each Goroutines and Channels in the API reference lists every call."},{"url":"/install/","page":"Install","section":null,"text":"Install the runtime, and add Vite and the ownership checker as dev dependencies: npm install @arv/threads npm install --save-dev vite @arv/threads-check # Vite, and the ownership checker"},{"url":"/install/#what-it-needs","page":"Install","section":"What It Needs","text":"@arv/threads needs: Vite 8, or Vite+, which is built on it. With Vite+, alias vite to Vite+'s core, as this repository's pnpm-workspace.yaml does. Node 22.13 or later. Vite+ needs 22.18. A browser with SharedArrayBuffer. The runtime that the page and its workers run (@arv/threads, @arv/threads/heap and @arv/threads/browser) is TypeScript, and Vite compiles it as it bundles the page. What Node runs (the Vite plugin, and @arv/threads-check) is JavaScript. Next, set up the Vite plugin, the page and the checker."},{"url":"/model/","page":"The Model","section":null,"text":"A thread is a worker with its own JavaScript heap, and objects can't cross between heaps. That's why every value is one of three kinds: Kind In the types How it crosses threads Use it for Owned Ordinary types, and Sending<T> where it moves It moves. It's copied once, and the sender loses it. Work a thread does alone, and its result Shared Shared<T>, which is deeply readonly It's frozen. Every thread reads its own copy, made once. Tables, settings and lookups Cell struct(...), SharedArray, SharedMap, Lock, Channel It doesn't. There's only one copy, and it's in shared memory. Data many threads read and write in place, and what orders them Code inside a thread is plain JavaScript on plain objects, so it runs at full speed. The cost is at the crossings, and the types tell you where they are. import {log, share, spawn, type Sending, type Shared} from '@arv/threads'; type Order = {id: number; items: number[]}; // The order moves to the thread. The thread owns it now, and may change it. function total(order: Sending<Order>): Sending<number> { order.items.push(5); return order.items.reduce((sum, x) => sum + x, 0); } // The rates are shared. They're frozen, and any thread can read them. function convert( amount: number, rates: Shared<Record<string, number>>, to: string, ): Sending<number> { return amount * rates[to]; } export function main() { const order = {id: 1, items: [10, 20, 30]}; log(spawn(total, order).join()); // 65. Using `order` here is an error. const rates = share({USD: 1, EUR: 0.92, SEK: 10.5}); log(spawn(convert, 100, rates, 'EUR').join()); } Threads can also talk to each other the way Go does, with goroutines and channels: see Go-Style Concurrency. The tutorial shows what happens underneath when values cross between threads."},{"url":"/performance/","page":"Performance","section":null,"text":"Here's how long a few programs take on an Apple M5 Pro: Program ms on 1, 2, 4 and 8 threads N-body, 800 bodies in shared structs (plain V8 on one thread: 25) 29, 15, 8, 6 Mandelbrot on goroutines and channels 712, 341, 180, 97 A sieve of Eratosthenes on a SharedArray<boolean> of 10 million 76, 56, 40, 37"},{"url":"/performance/#crossing-threads","page":"Performance","section":"Crossing Threads","text":"Values cross threads about as fast as JSON would carry them, and they're read back faster. Moving 50,000 parsed JSON requests costs about 1.1 times JSON.stringify to write, and a third of JSON.parse to read. A few things keep it fast: Each distinct set of keys is written once per message. Strings that repeat within a value are written once. ASCII text is stored at one byte per character. All told, 23 MB of JSON text crosses in about 7 ms."},{"url":"/performance/#compiled-or-with-the-types-only-erased","page":"Performance","section":"Compiled, or With the Types Only Erased","text":"The compiler is what makes shared structs fast. But a program also runs with its types only erased, by Node's type stripping for example. (The Vite plugin needs the compiler for now.) All it needs is the one line the compiler would add: register(import.meta.url, {main, ...}), imported from @arv/threads. That line registers the module's top-level functions, so that a thread finds the one it runs. The checker reads the source, so it reports the same errors either way. The runtime still throws on a use after move. Code that moves and shares plain objects runs at the same speed either way. Shared structs and SharedArrays don't. With types erased, each struct is a handle whose fields are accessors, and a SharedArray is a Proxy. That makes N-body 50 to 60 times slower: ms on 1, 2, 4 and 8 threads Compiled Types erased N-body, 800 bodies in shared structs 29, 15, 8, 6 1820, 971, 498, 301 Mandelbrot on goroutines and channels 712, 341, 180, 97 713, 349, 180, 99 The playground runs every program both ways (Compiled and Types erased), so you can compare them yourself. The Prototypes has the measurements this work started from, against Porffor and plain V8."},{"url":"/prototypes/","page":"The Prototypes: JS-to-JS Threads on Workers","section":null,"text":"Disclaimer This was mostly done by iterating with the help of Claude Code (Claude Opus 5.5). This page is about the prototypes this project grew out of, and what they found. They compile JavaScript to JavaScript for threads, and they run on Web Workers and node worker_threads with a SharedArrayBuffer. Porffor's threads and its playground take a different route: they compile JavaScript to C, and then to WebAssembly. V8 objects can't cross workers, so the big question is what to do with objects. We tried two designs: Shared everything, publish on escape. This is the threads model from Porffor's threads/README.md. An object stays an ordinary V8 object until another thread can reach it. At that point, it moves into a SharedArrayBuffer heap. Code that shares nothing runs at plain V8 speed, but reading a shared object costs about 17x. Its code is no longer in this repository, but the tag publish-on-escape has it. Own and shared values. A value is one of three things: owned by one thread and moved across, deeply immutable and copied once per thread that reads it, or an explicit cell. Ordinary code is plain JS, with nothing extra per access. N-body on 8 threads runs 4x faster than plain single-threaded JS. Programs are plain TypeScript, written against threads.d.ts. A checker built on a fork of tsgo proves the ownership rules in CI, and a bundler plugin compiles shared structs to typed-array accesses. We measured on an Apple M5 Pro (6 fast and 12 slower cores) with node 24.18 and Chrome 154. This work started in Porffor's repository, as threads/escape-proto, on top of its threads work. The own and shared design then grew into the runtime (src/runtime/), the threads-check checker (src/checker/) and the playground. That's where the work goes on. The tutorial walks you through writing, checking and running a small program, and explains how each step works. To set up the repository, see Development."},{"url":"/prototypes/#running-the-prototypes","page":"The Prototypes: JS-to-JS Threads on Workers","section":"Running the Prototypes","text":"You run a program on node with src/run.ts, or in Chrome with src/chrome.ts: node src/run.ts nbody-own # on node: nbody-own, nbody-cells, refcount-hand node src/run.ts nbody-own --threads=1,1,8 # the thread counts the program runs with node src/chrome.ts nbody-own # in Chrome, on Web Workers (needs playwright-core) node src/chrome.ts nbody-own --trace=t.json && node src/trace-summary.ts t.json node src/bench.ts # every configuration, median of 5 runs A program is either one of the programs in src/, given by name, or a module, given by path. Its imports of @arv/threads resolve to the runtime. On node, a resolve hook takes care of that. In Chrome, the test server rewrites the imports. chrome.ts drives the installed Google Chrome through playwright-core. It uses the playwright-core dev dependency, or the install that PLAYWRIGHT_CORE points at (its node_modules/playwright-core). With --trace, it records a DevTools performance trace with every worker's CPU samples. You can open that trace in DevTools' Performance panel. bench.ts measures against the original programs in src/presets/, run on plain V8. These are the programs that Porffor's playground has as presets. The TypeScript programs (*.ts) go through three tools. Stock tsc checks them, the ownership checker checks them too, and the tsgo fork compiles them. The fork is arv/threads-checker, and you build it with go build -o built/local/tsgo ./cmd/tsgo: vp run typecheck # types: stock TypeScript <threads-checker>/built/local/tsgo own check -p tsconfig.programs.json --ownRuntime @arv/threads # ownership, for CI <threads-checker>/built/local/tsgo -p tsconfig.programs.json --ownRuntime @arv/threads # emit build/ts node src/run.ts build/ts/nbody-struct.js"},{"url":"/prototypes/#files","page":"The Prototypes: JS-to-JS Threads on Workers","section":"Files","text":"The code is in src/. That's the runtime, the ownership checker, and the prototype's programs and scripts, which run on the runtime. playground/ is the playground (its page), with a page, its programs, and its Vite config. Here's what each file is for: File What it is presets/ The original programs, in plain JavaScript with threads. The measurements compare against these. runtime/runtime.ts The shared heap: the allocator, free lists, interned strings, deferred decrements and the worker pool. runtime/threads.ts, threads.d.ts The API that programs import, in plain TypeScript: own and shared values, shared structs, channels, maps and locks. runtime/own.ts Own and shared values on top of the runtime, plus shared structs and SharedArrays, Lock and Condition. runtime/chan.ts Go-style goroutines and channels on own and shared values. runtime/maps.ts SharedMap and SharedSet. runtime/heap.ts, browser.ts, threads-worker.ts A page's side: its shared memory, start() and await run(f, ...args), and its pool of Web Workers. runtime/vite-plugin-threads.ts The bundler plugin. It compiles with the fork, registers thread functions, serves the playground's compiles and sets cross-origin isolation. runtime/compiled.ts What the plugin and the playground's page both do with compiled code. checker/ threads-check, the ownership checker, built on TypeScript 7.1's API. nbody-own.ts N-body, written by hand for own and shared values. nbody-cells.ts The same, with the positions in a shared typed-array cell instead of snapshots, so nothing is copied per step. nbody-struct.ts N-body in TypeScript with a SharedArray of shared structs, which the fork lays out in the shared heap. nbody-ts.ts, tsconfig.programs.json The same, with ordinary objects that are moved and shared. mandelbrot-chan.ts, nbody-chan.ts, nbody-chan-struct.ts, pipeline.ts The presets with channels (N-body both copying plain objects and over shared structs), plus a small example of what channels cost. REFCOUNT.md, refcount.ts, refcount-hand.ts Reference counting for shared structs and SharedArrays: the design, its test, and that test written by hand the way the compiler emits it. tutorial.ts The example from TUTORIAL.md. async-main.ts An async main, which run.test.ts runs. run.ts, worker.ts, program.ts Run a program on node worker_threads. chrome.ts, index.html, page.ts, web-worker.ts Run a program in Chrome. bench.ts, trace-summary.ts The measurements."},{"url":"/prototypes/#shared-everything-publish-on-escape","page":"The Prototypes: JS-to-JS Threads on Workers","section":"Shared Everything, Publish on Escape","text":"This was the first design. We tried it and set it aside. Its runtime, expand.ts and the hand translations are at the tag publish-on-escape. We translated the programs by hand, following the rules a transform would apply: Property access is plain JS. A shared object's handle has a prototype per shape, with a getter and a setter per slot. A local object that moves to the shared heap turns into the same kind of view of its copy. That way, the engine's own inline caches tell local and shared objects apart. Elements, and names that an array also has (length, push...), check $fwd first. That's because a local array that moved keeps its own length and elements. Storing a local object somewhere shared publishes it. The store copies the object, and what it reaches, into the heap. That store always runs on the owning thread, so moving the object needs no handover. Closures keep the variables they capture in environment objects. That way, a closure can move with its variables. Loop back-edges are clean points. At a clean point, a thread hands over the objects that other threads asked for. Shared objects follow the threads design: one 8-byte value word per slot readers that never lock a lock word per object owned by the thread that made it handover at clean points Here's how it performed on one thread: One thread, against plain V8 Cost N-body on local objects 1.04x (1.00x without the clean-point polls) Mandelbrot 0.91x (top-level constants are inlined) N-body on shared objects 16.8x Shared reads are slow mostly because TurboFan stops inlining the getters once a function's inlining budget runs out. With a larger budget (a V8 flag), N-body on shared objects comes down to 6.8x. A clean-point poll costs little here. In a tight loop over objects, though, it can cost up to 2.5x, because it stops TurboFan from peeling the loop. Own and shared values, in the next section, do better. They don't need the shared heap's objects or their handover, so they replaced this design."},{"url":"/prototypes/#own-and-shared-values","page":"The Prototypes: JS-to-JS Threads on Workers","section":"Own and Shared Values","text":"This is the design that grew into the runtime. Here's what it looks like: import {spawn, share} from './own.ts'; const t = spawn(worker, id, slice, board, barrier); // slice moves to the thread; cells pass by reference const back = t.join(); // the thread's result moves back const snap = share(obj); // obj is deeply frozen now; any thread reads its own copy board.set(i, snap); // a SharedMap: a cell, the explicit shared, mutable state wait(barrier); // a struct holding a Lock and a Condition: cells too slice[0].x; // after the move: throws \"own: used after move\" Each kind of value works a little differently: Moved values. A moved value is copied into the shared heap once, and the sender's objects are revoked. Shared values. A shared value is written once. Each thread that reads it makes its own frozen copy on first use. Cells. Cells (shared structs, maps, locks and conditions) live in the shared heap and synchronize. own.ts checks at run time that moved objects go unused and shared ones stay unchanged. The ownership checker proves the same thing. The compiler emits the function table that spawn needs (register), and it lowers shared structs (__sharedStruct)."},{"url":"/prototypes/#plain-typescript-a-bundler-plugin-and-a-checker","page":"The Prototypes: JS-to-JS Threads on Workers","section":"Plain TypeScript, a Bundler Plugin and a Checker","text":"import { f64, Lock, scope, SharedArray, struct, type Struct, type Sending, type Shared } from \"@arv/threads\"; const Body = struct({ x: f64, y: f64, vx: f64, vy: f64, m: f64 }); // a shared struct type Body = Struct<typeof Body>; function worker(lo: number, hi: number, bodies: SharedArray<Body>, lock: Lock) { ... } function count(points: Sending<Point[]>, centers: Shared<Point[]>): Sending<number[]> { ... } The model is plain TypeScript (threads.d.ts), so tsc, editors, linters and formatters all take programs as they are. A few names carry the ownership rules: Sending<T> marks a value whose ownership moves. To TypeScript, it's just T. Shared<T> marks a value every thread may read. It's deeply readonly, so tsc already rejects most writes. consume(x) marks the end of a variable's life. The Cell interface marks the shared, mutable kinds: Lock, Condition, Channel, Thread, SharedArray and structs. You declare a struct with struct({...}). For a struct that refers to itself, you use struct<I>({...}) with an interface. Two tools add what the types can't say: The ownership checker, tsgo own check -p tsconfig.json --ownRuntime <the runtime module>, runs in CI like a linter. It reports only ownership errors and leaves type errors to tsc. The ownership errors are use after send, an alias of something sent, a write to a shared value, and sending what the caller still holds. The bundler plugin (runtime/vite-plugin-threads.ts, for Vite) compiles each module with the tsgo fork. Struct fields become typed-array accesses on shared memory, and struct references are counted. Every module registers the functions threads may run, keyed by its path so that the page's bundle and the workers' agree. The plugin also sets the headers that make the page cross-origin isolated on the dev and preview servers. A page imports @arv/threads/heap first. That's the shared memory, which the runtime's views need at load. Then the page calls await run(f, ...args). f runs on a pool of Web Workers, and its result moves back. The page's own thread may not block, so it waits asynchronously. A worker loads a thread's module the first time a thread needs it, so the page's own entry module never runs there. That side of a page is runtime/heap.ts and runtime/browser.ts, which the playground uses."},{"url":"/prototypes/#with-the-types-only-erased","page":"The Prototypes: JS-to-JS Threads on Workers","section":"With the Types Only Erased","text":"A program still runs with its types only erased, through node's type stripping and no compiler. It needs one added line that registers its top-level functions, which is how a thread finds the one it runs. The runtime's own checks stand in for the checker, so a use after move throws. Code that moves and shares plain objects runs at the same speed. On node, Mandelbrot takes 713/349/180/99 ms, against 712/341/180/97 ms compiled. Shared structs don't keep up. Each one is then a handle with accessors for its fields, and a SharedArray is a Proxy. N-body takes 1820/971/498/301 ms, against 29/15/8/6. The compiler is what makes shared structs fast, and the checker is what makes ownership safe before the program runs."},{"url":"/prototypes/#goroutines-and-channels","page":"The Prototypes: JS-to-JS Threads on Workers","section":"Goroutines and Channels","text":"const jobs = new Channel<number>(H), results = new Channel<Drawn>(H); for (let t = 0; t < threads; t++) go(worker, jobs, results); // goroutines: pool threads, never joined for (let y = 0; y < H; y++) jobs.send(y); jobs.close(); // workers' `for (const y of jobs)` ends chan.ts is Go's model on own and shared values. It has go, buffered and unbuffered channels, close, range over a channel, and select. A channel is a cell, and a value sent on it moves. So the checker proves that the sender never uses the value again, as it does for spawn. Threads that share nothing but channels have no data races, unlike Go's goroutines, which share memory. The receiver frees each message, so nothing in the shared heap outlives it. Goroutines come in two weights: A goroutine of a plain function is a whole worker, so it's heavy. One that's blocked on a channel holds its worker. A goroutine of an async function is light. It's one of many on a worker, and each one waits with await. A goroutine that throws prints its error, since nothing joins it. A plain object is copied into its message, since V8 objects can't cross workers. A shared struct or SharedArray is sent as its address. The compiler passes it with a count (__countedRef, see Reference Counting), and the receiver owns what it gets. send moves it, so using it after sending, or using a local alias of it, is a compile error. Transferable ArrayBuffers would also avoid the copy, but only through postMessage, which a goroutine blocked in recv() never sees."},{"url":"/prototypes/#what-channels-cost","page":"The Prototypes: JS-to-JS Threads on Workers","section":"What Channels Cost","text":"Here's what channels cost on node, measured with pipeline.ts: Buffered channels. A message costs about 600 ns. Unbuffered channels. A round trip costs 1.2 us. A waiter spins briefly before it sleeps. Large messages. An array of 100,000 numbers takes 0.8 ms copied, against 0.2 ms moved by address. Most of that is filling the array. Mandelbrot, with a channel of rows to draw, scales as well as anything else (see the next section). N-body that copies its positions through main each step is several times slower than shared structs. N-body can also follow Go's way: share memory, and order it with channels. With the bodies in a SharedArray of shared structs, and channels in the barriers' place, it matches the barriers up to 4 threads. It's a little slower on 8, where main relays every phase. Channels suit pipelines and pools of jobs. Shared typed layouts suit loops over shared data, and channels can order those too."},{"url":"/prototypes/#results","page":"The Prototypes: JS-to-JS Threads on Workers","section":"Results","text":"All times here are in ms. The first benchmark is N-body with 800 bodies and 30 steps. Each number is the median of 5 runs, on a warm pass (each worker's JIT warmed up). The shared everything rows were measured at the publish-on-escape tag. Threads 1 2 4 8 TypeScript, SharedArray of shared structs (typed layout), node 28 15 8 6 TypeScript, SharedArray of shared structs (typed layout), Chrome 31 17 8 6 Own and shared values, positions in a shared cell, node 27 14 8 6 Own and shared values, positions in a shared cell, Chrome 28 15 7 5 TypeScript, moved and shared objects, compiled by the fork, Chrome 35 21 11 14 Own and shared values, Chrome 35 21 12 13 Own and shared values, node 32 20 16 16 Goroutines and channels over shared structs, node 29 15 8 10 Goroutines and channels over shared structs, Chrome 29 17 10 9 Goroutines and channels, copying plain objects, Chrome 92 52 30 24 Goroutines and channels, copying plain objects, node 99 60 45 28 Shared everything, Chrome 426 223 126 85 Shared everything, node 443 227 123 81 Porffor native 143 76 40 29 Porffor Wasm, Chrome 883 455 231 130 Plain V8, no threads 25 The Porffor rows are Porffor's own builds of the presets, measured in its repository. bench.ts doesn't build them. The second benchmark is Mandelbrot, which computes on thread-local numbers: Threads 1 2 4 8 Shared everything, node 675 345 177 92 Goroutines and channels, node 712 341 180 97 Goroutines and channels, Chrome 750 383 196 103 Shared everything, Chrome 689 352 177 93 Porffor native 657 328 167 88 Porffor Wasm, Chrome 1130 573 293 156 Plain V8, no threads 737"},{"url":"/prototypes/#what-the-numbers-show","page":"The Prototypes: JS-to-JS Threads on Workers","section":"What the Numbers Show","text":"With snapshots, own and shared N-body stops scaling past 4 threads. Every thread copies all 800 positions each step, and that work doesn't shrink with more threads. Shared typed layouts copy nothing. That's true both of the positions in a shared cell and of the bodies in a SharedArray of shared structs that the compiler lays out. Both reach 4.5-5.6x on 8 threads. The ceiling on this machine is about 6.4x, with the work handed out in chunks. Past 6 threads, a thread runs on a slower core, and every step waits for it at the barrier. The compiler checks a SharedArray index against the length. An index out of range reads __outOfBounds[i], a Proxy that throws, rather than calling a function. There's a reason for that. In Chrome, a call on the failure path made the typed-layout version 1.6x slower (51/29/16/13 ms), even though it was never taken. The compiler drops the check where the index is provably in range: A[i] in for (let i = 0; i < A.length; i++), with neither A nor i assigned anywhere else. N-body's inner loop is just that case."},{"url":"/prototypes/#what-the-prototypes-leave-out","page":"The Prototypes: JS-to-JS Threads on Workers","section":"What the Prototypes Leave Out","text":"The prototypes don't cover everything. Here's what they leave out: Not everything is freed in the shared heap. Shared structs, SharedArrays, maps and strings are reference counted (Reference Counting), and cycles of them leak. A moved value is freed once it's read. Shared values, channels, locks and conditions are never freed. Ordering relies on V8 in practice, not on the spec. Plain SharedArrayBuffer accesses are taken to behave as relaxed atomics. There are no 8-byte release stores. NaN payloads are kept intact when copied through typed arrays. All three hold in V8 today, but the JS memory model doesn't promise any of them. Only part of the language is covered. own.ts can't send cycles. A shared struct's fields are numbers, booleans, strings, and references to structs, SharedArrays, channels, locks and conditions."},{"url":"/reference-counting/","page":"Reference Counting for Shared Structs","section":null,"text":"Status This is a prototype. It lives in the runtime (own.ts, runtime.ts) and in the tsgo fork's emit. refcount.ts tests it, and refcount-hand.ts is the same test written by hand. The playground's \"Reference counting under a race\" runs it in a browser (programs/refcount.ts). Shared structs, SharedArrays, SharedMaps, SharedSets and interned strings are freed when nothing refers to them. Other cells (Lock, Condition, Channel) and shared values are still never freed. Shared structs and SharedArrays live in the shared heap, and someone has to decide when it's safe to free one. This page explains how we do that, what the compiler emits to make it work, and where it falls short. A shared struct or SharedArray is an address in the shared heap. It's passed around as a plain number, so V8's garbage collector can't see who holds one. Instead, each block counts its own references. There are two kinds: References stored in the shared heap. These are reference fields. References that threads hold. These are locals, handles and a spawned thread's arguments. The compiler emits the increments and decrements for you. Numeric code emits none, because borrowed addresses aren't counted."},{"url":"/reference-counting/#layout","page":"Reference Counting for Shared Structs","section":"Layout","text":"Each kind of block starts with a header of i32 words: Block Words (i32) struct [kind][count], then field k in slot k (8 bytes at byte 8 + 8k) SharedArray [kind = 23][length][element size][count], then the elements from byte 16 struct element of a SharedArray [kind][back], where back is the array's address minus the element's (negative) A struct element of a SharedArray has no count of its own. Its lifetime is its array's, so retaining or releasing an element counts its array. Each field of a struct fills one 8-byte slot: A number field is an f64: __F64[(p >> 3) + 1 + k]. A reference field holds a struct or a SharedArray, or either with | null. It's an i32 address in the low half of its slot, __I32[(p >> 2) + 2 + 2 * k], and 0 for null. A field that holds a Channel, a Lock or a Condition is an address too, but it isn't counted. Those cells last as long as the program."},{"url":"/reference-counting/#the-race-and-deferred-decrements","page":"Reference Counting for Shared Structs","section":"The Race, and Deferred Decrements","text":"Counting alone isn't safe across threads. Here's the race: Thread A reads p.child. Thread B overwrites p.child. That drops the old child's count to 0, and thread B frees it. Thread A increments the count of a block that has already been freed. So one decrement is deferred: the decrement of a field's old value when the field is overwritten. It waits until every thread has passed a quiescent point since then. A thread at a quiescent point holds no uncounted address that it read from a field. Every other increment and decrement is immediate."},{"url":"/reference-counting/#why-a-count-of-0-is-safe","page":"Reference Counting for Shared Structs","section":"Why a Count of 0 Is Safe","text":"When a count reaches 0, two things are true: No field refers to the block. Pending decrements haven't been applied yet, so they still count. Every thread that read the block from a field has passed a quiescent point since then. Together, these mean that no thread holds an uncounted address of the block, or of anything it read through the block. So the block is freed at once, and its reference fields are released at once too. That's why a list of any length is freed in one pass. No block is ever revived."},{"url":"/reference-counting/#quiescent-points","page":"Reference Counting for Shared Structs","section":"Quiescent Points","text":"These are the quiescent points: Condition.wait, and a Lock.hold that has to wait spawn and join the end of a scope a thread's start and its end __quiescent(), which the compiler puts at the end of each iteration of a loop that reads or stores a reference field. It's an atomic load and store. Numeric loops get none. A thread that's blocked in a wait counts as quiescent the whole time. Before it blocks, it moves the decrements it couldn't apply yet to a shared list, just as a thread does when it ends. That's because the wait may last forever, and no other thread works off a thread's own queue. A thread that runs for a long time without a quiescent point delays reclaiming, so memory grows. It never affects correctness."},{"url":"/reference-counting/#async-functions","page":"Reference Counting for Shared Structs","section":"Async Functions","text":"Async functions share a thread. A worker that hosts async goroutines runs one at a time, each until its next await. The compiler holds no uncounted address across an await. An await counts as a call, and an async function's counted parameters are owned. So when one of these functions resumes from one of the runtime's waits, every other one is suspended with nothing uncounted. The runtime's waits are recvAsync, sendAsync, selectAsync, joinAsync, Condition.waitAsync and Lock.holdAsync. That resume is a quiescent point for the thread. So is each turn of the host's loop. A host whose async functions are all waiting passes no quiescent point, though. So a thread with many decrements queued rings the bell of each host that's behind, and each of them passes one."},{"url":"/reference-counting/#epochs","page":"Reference Counting for Shared Structs","section":"Epochs","text":"This technique is quiescent-state-based reclamation. There's a global epoch, and each thread has a word with the epoch it last saw at a quiescent point. A decrement queued in one epoch is safe once every running thread's word is past it. The epoch is an i32 that wraps. Two epochs are compared by their difference, which works while they're less than half the range apart. A thread that falls a quarter of the range behind stalls the epoch until it passes a quiescent point, and memory grows meanwhile. Falling that far behind takes 2^29 reclaims by other threads without a quiescent point of its own, which is hours of them."},{"url":"/reference-counting/#runtime-ownts","page":"Reference Counting for Shared Structs","section":"Runtime (own.ts)","text":"The runtime exports these functions from @arv/threads. Compiled code imports each one under the same name with two leading underscores (retain as __retain), so that none of them clash with the program's own names. The rules below quote them that way. Function What it does sharedStruct(name, fields, refs, kinds) Declares a struct type. refs lists the indexes of the reference fields. kinds has a letter per field: f f64, i i32, u u8, b bool, s str, r reference, c channel, lock or condition. newStruct(T), newSharedArray(T, n) Allocates a zeroed block with count 1, owned by the caller. retain(p) Adds 1 to the count and returns p. Does nothing for 0. release(p) Subtracts 1 from the count. At 0, frees the block and releases its reference fields. Does nothing for 0. store(i, v) Stores a reference at i32 index i. Takes over v's count, defers the old value's decrement, and returns v. storeStr(i, s), strAt(p) storeStr stores a string field or element at i32 index i. It counts s's interned string, defers the old one's decrement, and returns s. strAt returns the string at address p. countedRef(v) Passes a counted argument to spawn, go or a channel's send. Retains v either for the thread, which releases it when its function returns, or for the message, whose receiver owns it. moved(v) Wraps a counted result that a callback returns to the runtime, such as a SharedMap's getOrInsertComputed's callback. The result carries its count already. quiescent() Marks a quiescent point. box(p) Returns this thread's handle for p. The handle holds a count of its own, and a FinalizationRegistry releases it when the handle is collected. unbox(v), cellAddress(v) unbox returns the handle's address, borrowed. cellAddress returns a lasting cell's address (a channel's, a lock's or a condition's). Both return 0 for null. capture(fn, ...ps) Retains each address for a closure that captures them. They're released when the closure is collected. F64, I32, U8, outOfBounds The shared heap's views, which field and element accesses index into, and what an out-of-range index reads (it throws). heapStats() Reports the heap bytes in use, the blocks allocated and freed, and the decrements pending. Freed blocks go to free lists by size, with a per-thread cache over global lists. Allocation takes from these lists before it bumps the shared cursor. Large blocks are reused only at the same size."},{"url":"/reference-counting/#what-the-compiler-emits","page":"Reference Counting for Shared Structs","section":"What the Compiler Emits","text":"Every expression of a counted type (a struct or SharedArray, possibly | null) is one of three things: Owned (+1). These are new S(), new SharedArray(...), __retain(E), a call to a function compiled here whose declared return type is counted, and a call to a declared function whose result is Sending<T> (a channel's recv(), join()). A stable borrow. These are a parameter, a counted local that's never reassigned, A[i] where A is a stable borrow, and __unbox(v). An unstable borrow. These are a reference field read e.f, A[i] where A is an unstable borrow, and the result of any other call. A generic or declared function adds no count. An unstable borrow is valid only until the thread's next quiescent point, so it's never held across a call."},{"url":"/reference-counting/#rules","page":"Reference Counting for Shared Structs","section":"Rules","text":"Locals. A const, or a let that's never reassigned, initialized from a stable borrow is a borrowed local. It does no counting. Every other counted local is owned: It's initialized with E if E is owned, and with __retain(E) otherwise. It's released when its block ends, on every path, as try { ... } finally { __release(x) } around the block. Reassigning it retains the new value before releasing the old one. A parameter that's reassigned is an owned local too, retained on entry. So is every counted parameter of an async function, because it outlives its caller's borrow. A generator can't take a counted parameter. Results. A function returns +1. For a borrow, return E becomes return __retain(E). For an owned local, the local's count moves out, and the local is zeroed so that the finally releases nothing. The count doesn't move out of a local that a closure captures. Temporaries. An owned value that isn't bound to a local is released after its statement. Examples are an argument, f().x, and a statement f();. Arguments. Parameters borrow. An unstable borrow passed to a function is retained around the call, because the callee may reach a quiescent point. Helpers and intrinsics don't need this. Field stores. e.f = E becomes __store((e >> 2) + 2 + 2k, E'), where E' is E if E is owned, and __retain(E) otherwise. If e is an unstable borrow and E contains a call, e is retained first. Field reads. e.f becomes __I32[(e >> 2) + 2 + 2k]. Threads, channels and maps. A counted argument to a Sending<T> parameter of a declared function is passed as __countedRef(E). Those functions are spawn, s.spawn, go, a channel's send and a SharedMap's set. A channel's send also moves the value. Using the variable afterwards, or a local alias of it, is an error. A rest parameter shares the value instead. That covers a thread's arguments, so one SharedArray can go to every thread. It also covers a SharedMap's or SharedSet's entry (set(...entry)), so a struct stays the caller's. A callback whose type in the runtime has a Sending result (getOrInsertComputed's) returns a counted value as __moved(E), with its count. A map or set holds a count on each counted key and value. It defers the decrement of one it drops, and releases the rest when it's freed. A for...of over a channel owns each value and releases it at the end of the iteration. select over channels of counted values is rejected, because nothing could count the values in the tuple it returns. Closures. A closure that captures counted variables is wrapped as __capture(fn, x, y). A counted variable that a closure captures can't be reassigned. Registering the closure has a cost (see Limits), so the runtime's API avoids callbacks where it can. For example, Lock.hold() returns a Disposable for using rather than taking a function. null. In raw positions, null and undefined are 0, and x === null becomes x === 0. Quiescent points in loops. A loop whose body reads or stores a reference field ends each iteration with __quiescent(). That way neither a writer nor a long-running reader holds up freeing. Ordinary containers. A raw address stored in a JS array or object is invisible to counting. The checker rejects the forms it can see: literals, element and property stores, destructuring, and for...of over such a container. It can't see a store made by a generic call, such as list.push(node), or map.set(k, node) on a JS Map. That's still a hole, which we mean to close by boxing at generic boundaries or by rejecting such calls. A SharedMap or SharedSet is the container that counts (see rule 7). Strings. An interned string is counted the same way a struct is, by what holds its address: a str field or a SharedArray<string> element. e.f = s becomes __storeStr(i, s), which leaves the counts alone when the field holds s already. a map's key or value. a message. A message counts its strings and its objects' keys, and gives them back once it's read. A shared value keeps them. Compiled code reads a string field as a JS string (__strAt(__I32[i])). It holds no string's address beyond a runtime call, so a string's decrement doesn't need to wait for a quiescent point. When a string's count reaches 0, it leaves the table at once, so no lookup finds it again. Its block is freed a grace period later, because a lookup on another thread may have found it just before. Each string has a serial, so that a thread's caches (string to address, and address to string) never take a reused address for the string that left it. The runtime's own strings, such as struct layouts and the message for a full heap, are pinned and stay."},{"url":"/reference-counting/#limits","page":"Reference Counting for Shared Structs","section":"Limits","text":"Reference counting here has real limits. Here's what to keep in mind: Cycles leak. A cycle of reference fields keeps its counts above 0. A backup collector (trial deletion at quiescent points) or a rule against recursive struct types would close this. Handles and closures are released late, or not at all in Node. The same goes for a thread that's never joined, and its result. They go once the thread's Thread is collected. A FinalizationRegistry runs only after V8 collects the handle, and only when the worker's event loop gets a turn. A pool worker that has handles to finalize gives it a turn after each thread. While idle, it gives one 50 ms later, then twice as long after each turn that finalized nothing, up to a second. A WeakRef keeps its target alive until the end of the task that made it, so handles made in a long thread outlive it into V8's old generation. Chrome's workers collect those: 200,000 handle-held nodes were freed within a second of the thread ending. Node's idle workers run no major GC, so there they stay. Compiled code counts without handles. Handles are only for any, unknown and uncompiled code. Exceptions. finally releases owned locals, but owned temporaries in a statement that throws leak. Races are the program's to avoid. A channel's send moves a value, but the move check doesn't follow aliases through reference fields or calls. And threads that share a SharedArray (as N-body's do) can write the same element. Counting stays sound either way, because it only decides when a block is freed."},{"url":"/repository/","page":"This Repository","section":null,"text":"@arv/threads is developed in arv/threads. Here's what's in it: src/runtime/ The runtime: the API, the shared heap, a page's side and the Vite plugin src/checker/ The ownership checker (@arv/threads-check) playground/ A page to write programs in, and see what they compile to and how they run site/ The docs site, which GitHub Pages publishes with the playground TUTORIAL.md Writing, checking and running a small program, and how each step works REFCOUNT.md How shared structs and SharedArrays are reference counted and freed docs/prototype.md The prototypes this grew out of in Porffor's threads work, and what they found TUTORIAL.md, REFCOUNT.md and docs/prototype.md are also pages of these docs: the tutorial, Reference Counting and The Prototypes."},{"url":"/repository/#development","page":"This Repository","section":"Development","text":"The repository is a pnpm workspace on Vite+ (vp). It holds the code in src/ at the root, the checker (src/checker/), the playground and these docs (site/). Vite+ brings Vite, Oxlint and a task runner, and it runs pnpm at the version that packageManager names. You need Node 22.18 or later (24.11 or later on Node 24). vp install # Once, at the root vp dev # The playground; TSGO=<threads-checker>/built/local/tsgo for the compiled JavaScript vp run build-wasm # The compiler the playground's build runs in the browser (needs Go) vp build # The playground's production build vp run threads-site#dev # The docs site, served as you edit it vp run threads-site#build # The docs site's static build, into site/dist/ vp fmt # Oxfmt, over the whole workspace vp lint # Oxlint, type-aware, over the whole workspace vp check # Formatting, lint and types, as CI would vp test # The unit tests, as CI would vp run -r build-dist # Each package's dist/, as pnpm pack builds it vp run test-pack # The packages packed and installed in a project; with TSGO, a page on them in Chrome vp run -r typecheck # tsc in every package vp is Vite+'s global CLI. If you don't have it, run pnpm install to install, and pnpm vp <command> for everything else. That uses the CLI from the vite-plus dev dependency. The code is TypeScript, and Node runs it as it is, with its types stripped. Tests sit next to the code they test (*.test.ts), and run on Vitest."},{"url":"/repository/#releasing","page":"This Repository","section":"Releasing","text":"A release is a version tag: Set the same version in package.json and src/checker/package.json, and merge that. Push vX.Y.Z on that commit. The Release workflow (.github/workflows/release.yml) checks, tests and packs both packages. Then it publishes them to npm with provenance, by trusted publishing: each package's settings on npmjs.com name this repository and release.yml as its trusted publisher, so no token is needed. A new trusted publisher setting expires unless a publish succeeds within 48 hours, so add it just before the tag it's for."},{"url":"/repository/#license","page":"This Repository","section":"License","text":"Copyright 2026 Erik Arvidsson. Licensed under the Apache License 2.0."},{"url":"/setup/","page":"Set Up","section":null,"text":"Setting up takes three steps: the Vite plugin, the page, and the checker. In production, your server also needs to send two headers."},{"url":"/setup/#1-the-vite-plugin","page":"Set Up","section":"1. The Vite Plugin","text":"The Vite plugin compiles your TypeScript and registers the functions that threads run. It also sets the cross-origin isolation headers that SharedArrayBuffer needs on the dev and preview servers. // vite.config.ts import threads from '@arv/threads/vite'; export default { plugins: [threads({tsgo: '/path/to/threads-checker/built/local/tsgo'})], }; Option Default What it does tsgo (required) The tsgo fork's binary (see Status). tsconfig 'tsconfig.json' The project the fork compiles, relative to Vite's root. args [] More arguments for tsgo. They come after the plugin's own: -p, --ownRuntime, --outDir, --rootDir, --sourceMap, --noEmit false, --noEmitOnError false and --tsBuildInfoFile. runtime '@arv/threads' The module that programs import the runtime from (tsgo's --ownRuntime). playground false When true, the dev server compiles programs that the page sends it (POST /__threads/compile), as the playground's does. checker null Used with playground. The path of threads-check's entry module, which then reports the ownership errors instead of tsgo own check. The plugin serves what the fork emits for each module of that project. It leaves any other module to Vite, which erases its types and registers none of its functions, so no thread can run them. The project needs to: Include the page's modules and the modules its threads run, all under the tsconfig's directory (the plugin's --rootDir). Resolve imports the way a bundler does (\"module\": \"esnext\", \"moduleResolution\": \"bundler\"), so that @arv/threads is the API's declarations. Emit JavaScript, whatever its tsconfig says. The plugin passes --noEmit false, so a project with \"noEmit\": true, as Vite's templates have, needs nothing more. But a tsconfig.json made only of references (also from Vite's templates) compiles nothing, so name the project itself (tsconfig: 'tsconfig.app.json'). Thread modules may be .ts, .mts or .tsx. Type errors don't stop the build. tsc reports them, and the checker reports the ownership errors."},{"url":"/setup/#2-the-page","page":"Set Up","section":"2. The Page","text":"The page imports the shared memory first, then runs functions on the pool of workers. main runs on a worker too, so it may block: it can join a thread, wait on a condition, or receive from a channel. The page only awaits its result. Keep the functions that threads run out of the page's entry module. A worker loads a function's module to find it, and start(), run() and stop() throw when they're called there. // main.ts, the page's entry module import '@arv/threads/heap'; // First: it creates the shared memory. import {run} from '@arv/threads/browser'; import {main} from './app.ts'; console.log(await run(main, 8)); The Page in the API reference has the rest of the page's API."},{"url":"/setup/#3-the-checker","page":"Set Up","section":"3. The Checker","text":"The checker runs in CI, like a linter. Here's how you run it: npx @arv/threads-check -p tsconfig.json --runtime @arv/threads The Checker says what it reports, and what an error looks like."},{"url":"/setup/#in-production","page":"Set Up","section":"In Production","text":"In production, your server must send these two headers: Cross-Origin-Opener-Policy: same-origin Cross-Origin-Embedder-Policy: require-corp A host that can't send them, such as GitHub Pages, can have a service worker add them. That's what the playground does (coi-sw.js)."},{"url":"/status/","page":"Status","section":null,"text":"@arv/threads is experimental, and it has real limits. Here's where things stand today: The compiler is a fork. A fork of TypeScript 7.0.2, arv/threads-checker, compiles shared structs and SharedArrays to typed-array reads and writes (b.x becomes __F64[(b >> 3) + 1]), and counts their references. It isn't on npm. Build it with go build -o built/local/tsgo ./cmd/tsgo, and name the binary in the plugin's tsgo option. With types only erased, programs still run, but shared structs are much slower (see Performance). The checker needs no fork. @arv/threads-check runs on stock TypeScript 7.1's API. Shared memory is a fixed 256 MB, and not everything in it is freed. Structs, SharedArrays and maps are reference counted, but cycles of them leak. Channels, locks and conditions are never freed. A moved value (a thread's arguments and result, or a channel's message) is freed once it's read, but shared values are never freed. Strings are interned, one copy each, and counted. A string is freed once no field, element, map or message refers to it. Closures don't cross threads. A thread runs a top-level function. A SharedMap or SharedSet has one lock. Every call takes it for a moment, so threads that change one map all the time wait for each other. A thread with many changes should make them in a Map of its own, and merge once. Goroutines have limits. A goroutine of a plain function is a whole worker. One per core is fine, but thousands are not. Async goroutines are light but cooperative. One that computes without awaiting holds up the others on its worker, and none moves to another worker once it has started. Moving revokes. The values you move are revoked, so using them throws. With start({ check: true }), everything inside them is revoked too. Reference Counting explains what is freed and when, and the limits of counting."},{"url":"/the-playground/","page":"Threads Playground","section":null,"text":"The threads playground is a page where you write threads programs, see what they compile to, and watch them run. It's the playground of @arv/threads, which brings threads to JavaScript in plain TypeScript, on Web Workers and a SharedArrayBuffer. You'll find the model, the API and the limits in these docs. Disclaimer Written iterating with Claude Code (Claude Opus 5.5). You can try it at arv.github.io/threads/playground. It compiles everything right in your browser."},{"url":"/the-playground/#using-the-playground","page":"Threads Playground","section":"Using the Playground","text":"Pick a program from the Program menu and edit it in Monaco. You get TypeScript's hover types, completions and errors from the threads API. As you type, the page compiles your program and shows you: The compiled JavaScript. This is what the fork of TypeScript compiles your program to. Everything the compiler added is marked. The JavaScript with its types erased. This is the same program with its types only erased, and it still runs. The ownership checker's errors. They show up as squiggles in the editor and in a list. On the dev server they come from threads-check. In the browser they come from the fork's own check. The output. Run runs the program on a pool of Web Workers. The Compiled and Types erased switch picks which of the two JavaScript versions you see, and which one runs. The page remembers your edits to each program in this browser. If you want the original back, click Reset."},{"url":"/the-playground/#running-a-program","page":"Threads Playground","section":"Running a Program","text":"Run calls the program's main with the thread counts in the Threads field, which starts out as 1, 2, 4 and 8. main runs on a worker too. It's started like any other thread, so it may block, for example on join, recv or a barrier. The page itself never blocks. It only awaits main's result. Stop ends a run that doesn't end on its own. The pool stops, along with every thread on it, and a fresh pool starts."},{"url":"/the-playground/#when-compiling-fails","page":"Threads Playground","section":"When Compiling Fails","text":"If compiling goes wrong, the status in the page's header tells you. On the dev server, the page says when it can't reach Vite's dev server. When the page compiles in the browser, it says if the compiler failed, or if the compiler didn't load at all."},{"url":"/the-playground/#run-it-locally","page":"Threads Playground","section":"Run It Locally","text":""},{"url":"/the-playground/#what-you-need","page":"Threads Playground","section":"What You Need","text":"Node 22.18 or later (24.11 or later if you're on Node 24). A recent Chrome, or another browser with SharedArrayBuffer. The TypeScript fork, for the compiled JavaScript. This is a fork of TypeScript 7.0.2, arv/threads-checker. Build it in a clone of it with go build -o built/local/tsgo ./cmd/tsgo. The ownership checker doesn't need the fork. It's src/checker/, and it runs on stock TypeScript 7.1. The repository is a pnpm workspace on Vite+. Its Development section has the details."},{"url":"/the-playground/#starting-the-dev-server","page":"Threads Playground","section":"Starting the Dev Server","text":"Install the dependencies, then start the dev server with TSGO pointing at the fork: vp install # at the repository's root TSGO=/path/to/threads-checker/built/local/tsgo vp dev # here, or at the root Open the address Vite prints, and you're ready to go."},{"url":"/the-playground/#checking-the-programs","page":"Threads Playground","section":"Checking the Programs","text":"vp run typecheck checks the programs' types, and vp run own-check runs the ownership checker on them. Run both before you send a change to a program."},{"url":"/the-playground/#static-builds","page":"Threads Playground","section":"Static Builds","text":"Without a dev server, the page compiles programs itself. It uses the fork compiled to WebAssembly (arv/threads-checker's cmd/wasm), running in a worker. You get the same JavaScript, tsgo own check's ownership errors, and tsgo erasing the types. The catch is size. The compiler is 34 MB (about 8 MB compressed), because Go's WebAssembly is huge. The page shows its download as it goes. vp run build-wasm # the compiler, into public/compiler/ (needs Go) TSGO=/path/to/threads-checker/built/local/tsgo vp build # into dist/, to be served from /playground/ vp preview # serves dist/, at /playground/ vp run build-wasm builds from the clone that TSGO_FORK names. By default, that's ~/src/arv/threads-checker. The build is served from /playground/. To serve it from another path, build it with BASE=/elsewhere/ vp build. You can also compile in the browser on the dev server. Just add ?wasm to the address."},{"url":"/the-playground/#deploying-to-github-pages","page":"Threads Playground","section":"Deploying to GitHub Pages","text":"On every push to main, the Pages workflow builds it all and deploys it to GitHub Pages, at arv.github.io/threads/playground/."},{"url":"/the-playground/#cross-origin-isolation","page":"Threads Playground","section":"Cross-Origin Isolation","text":"SharedArrayBuffer only works on a cross-origin isolated page. That takes the COOP and COEP headers, which the dev and preview servers send. Some hosts, such as GitHub Pages, can't send these headers. There, a service worker (public/coi-sw.js) provides them. The first time you visit, the page loads once more, under the service worker."},{"url":"/the-playground/#programs","page":"Threads Playground","section":"Programs","text":"The programs you can pick from live in programs/. Each one exports main, which the page calls with the thread counts to run with. The basics come first, with one idea each: Hello, threads Moving a value to a thread Sharing a value A shared struct A SharedArray of booleans (a sieve) Strings in shared memory Atomics (claiming work, and counting together) A channel pipeline A pool of workers on channels Ten thousand async goroutines waiting for a database replica A SharedMap and a SharedSet counting words Then come the bigger examples: N-body four ways: shared structs with a barrier, shared structs with channels, moving and sharing objects, and channels copying objects Mandelbrot on channels JSON parsed on threads The nearest-centers example from the tutorial What channels cost Reference counting under a race A growable SharedVector, written in user space on the API The mistakes the checker catches"},{"url":"/the-playground/#how-fast-they-run","page":"Threads Playground","section":"How Fast They Run","text":"Here's how two of them do, compiled and with the types only erased, on an Apple M5 Pro. Times are in milliseconds, on 1, 2, 4 and 8 threads: Compiled Types erased N-body, shared structs (plain V8, 1 thread: 25) 29 / 15 / 8 / 6 1820 / 971 / 498 / 301 Mandelbrot, channels 712 / 341 / 180 / 97 713 / 349 / 180 / 99"},{"url":"/the-playground/#layout","page":"Threads Playground","section":"Layout","text":"Path What It Is index.html, main.ts The page, built on Monaco boot.ts The page's entry: it isolates the page, then loads main.ts programs/ The page's programs vite.config.ts The dev server, with the runtime's Vite plugin, which compiles with the fork and runs the checker wasm.ts, compiler.ts Compiling in the browser: the page's side, and the worker that runs the compiler build-wasm.ts Builds the compiler, in WebAssembly, into public/compiler/ public/coi-sw.js The service worker that isolates the page where the host can't assets.d.ts The types of what Vite imports besides modules The runtime and the checker are in src/."},{"url":"/tutorial/","page":"Tutorial: Threads With Owned and Shared Values","section":null,"text":"Let's write a small program with threads, check it, compile it and run it. It's a good way to get a feel for how the pieces fit together. The program is tutorial.ts. It generates 200,000 random points, splits them across threads, and counts which of three centers each point is nearest to. It's plain TypeScript written against threads.d.ts. Three tools work on it: Stock tsc checks its types. The ownership checker checks its ownership. The tsgo fork (arv/threads-checker) compiles it to run on threads.ts. If you'd like the ideas behind all this first, see The Model."},{"url":"/tutorial/#before-you-start","page":"Tutorial: Threads With Owned and Shared Values","section":"Before You Start","text":"You'll need a few things installed: The tsgo fork, built. In a clone of it, run go build -o built/local/tsgo ./cmd/tsgo. TypeScript. Node 22.18 or later. It runs TypeScript files as they are. playwright-core, but only if you also want to run the program in Chrome."},{"url":"/tutorial/#write-the-program","page":"Tutorial: Threads With Owned and Shared Values","section":"Write the Program","text":""},{"url":"/tutorial/#three-kinds-of-values","page":"Tutorial: Threads With Owned and Shared Values","section":"Three Kinds of Values","text":"Every value a thread touches is one of three kinds, and its type says which: Owned is the default. An owned value is an ordinary object that only one thread can reach. Sending<T> moves ownership: the caller gives the value up, and the receiver owns it from then on. Shared values have the type Shared<T>. They're deeply immutable, so any number of threads can read them at once. A cell is shared and mutable, but only in a synchronized way. You change it through what synchronizes it (a Lock, a Condition or a Channel). Shared structs and SharedArrays are changed in place instead, with one of those ordering the changes. To TypeScript, Sending<T> is just T, and Shared<T> is a deeply readonly T. That means the whole program is ordinary TypeScript."},{"url":"/tutorial/#the-thread-function","page":"Tutorial: Threads With Owned and Shared Values","section":"The Thread Function","text":"Each thread runs countNearest. It takes its own points, which it owns, and reads the centers, which every thread shares: function countNearest( points: Sending<Point[]>, centers: Shared<Point[]>, ): Sending<number[]> { const counts = centers.map(() => 0); for (const p of points) { let best = 0, bestDistance = Infinity; for (let c = 0; c < centers.length; c++) { const dx = p.x - centers[c].x, dy = p.y - centers[c].y, d = dx * dx + dy * dy; if (d < bestDistance) { bestDistance = d; best = c; } } counts[best]++; } return counts; } These are plain loops over plain objects. The only things in this function that have anything to do with threads are the types of its parameters and its result."},{"url":"/tutorial/#hand-out-the-work","page":"Tutorial: Threads With Owned and Shared Values","section":"Hand Out the Work","text":"Next, main creates the data and hands it out to the threads. In tutorial.ts, the scope part runs once for each thread count you ask for, with threads set to that count, and then logs the totals: const centers = share([ {x: 0.25, y: 0.25}, {x: 0.75, y: 0.25}, {x: 0.5, y: 0.75}, ]); const totals = scope(s => { const parts = partition(randomPoints(200_000), threads); // one owned array per thread const ts: Thread<number[]>[] = []; for (let t = 0; t < threads; t++) ts.push(s.spawn(countNearest, take(parts, t), centers)); const sums = centers.map(() => 0); for (const th of ts) th.join().forEach((n, c) => { sums[c] += n; }); return sums; }); Here's what each call does: share(...) turns a value you own into a shared one. The array literal is fresh, so nothing else can change it. partition splits an owned array into owned parts, and take(parts, t) moves one part out. s.spawn(f, ...args) starts f on a thread. Its arguments are Sending, so part t moves to its thread. centers is shared, so it's just passed along. th.join() moves the thread's result back to the caller. scope joins every thread it started before it returns. That way, no thread outlives the data it uses."},{"url":"/tutorial/#check-and-compile","page":"Tutorial: Threads With Owned and Shared Values","section":"Check and Compile","text":"Now let's check the program and compile it. There are three commands: tsc -p tsconfig.json --noEmit # types /path/to/threads-checker/built/local/tsgo own check -p tsconfig.programs.json --ownRuntime @arv/threads /path/to/threads-checker/built/local/tsgo -p tsconfig.programs.json --ownRuntime @arv/threads The first is stock TypeScript, checking types. The second is the ownership checker. It runs in CI like a linter: it reports only ownership errors, and it exits with status 1 if there are any. The third compiles the program to build/ts. A web page uses the bundler plugin instead (see Set Up). The ownership checker follows each value's region through the function. A value's region is the set of objects reachable from it. The checker rejects three things: Using a region after it was moved. Writing to a shared value. Moving anything the code doesn't own."},{"url":"/tutorial/#common-mistakes","page":"Tutorial: Threads With Owned and Shared Values","section":"Common Mistakes","text":"Here are three typical mistakes, one of each kind: const points = randomPoints(1000); s.spawn(countNearest, points, centers); log(points.length); // 1. points moved to the thread (centers[0] as Point).x = 0.5; // 2. centers is shared s.spawn(countNearest, mine, centers); // 3. mine belongs to the caller The checker reports each one: mistakes.ts(12,17): error TS96001: 'points' cannot be used here: it was sent to 'spawn' on line 11. mistakes.ts(13,5): error TS96010: Cannot change 'centers[0] as Point': it is a shared value, which is immutable. mistakes.ts(14,27): error TS96008: 'mine' cannot be sent: it is captured from an outer scope. Without the cast, centers[0].x = 0.5 is already a tsc error, because a Shared<T> is readonly. Each mistake has a straightforward fix: Use points before you send it, not after. share a changed copy rather than changing the original. Create what a thread gets where you send it, as main does with randomPoints."},{"url":"/tutorial/#what-the-compiler-emits","page":"Tutorial: Threads With Owned and Shared Values","section":"What the Compiler Emits","text":"The compiler erases the types and adds two things for the runtime: An import of its helpers. A table of the module's functions, so that a thread can find the one it should run. Here's what that looks like: import { register as __register } from \"@arv/threads\"; import { log, partition, scope, share, take } from \"@arv/threads\"; function countNearest(points, centers) { const counts = centers.map(() => 0); ... } ... __register(import.meta.url, { randomPoints, countNearest, main }); The loops are untouched. The compiled countNearest is the same plain JavaScript you would have written by hand. You can also run a program with its types only erased, for example with Node's type stripping. In that case, the program has to make the register call itself. The Performance page explains what that costs."},{"url":"/tutorial/#run-it","page":"Tutorial: Threads With Owned and Shared Values","section":"Run It","text":"Finally, run the compiled module on 1, 2 and 4 threads: node src/run.ts build/ts/tutorial.js --threads=1,2,4 1 threads: 56336 56064 87600 2 threads: 56336 56064 87600 4 threads: 56336 56064 87600 You get the same counts no matter how many threads do the work. chrome.ts runs the same module in Chrome, on real Web Workers: node src/chrome.ts build/ts/tutorial.js --threads=1,2,4 That's it! You've written, checked, compiled and run a program on threads."},{"url":"/tutorial/#how-it-works","page":"Tutorial: Threads With Owned and Shared Values","section":"How It Works","text":"Here's what happens underneath when values cross between threads: thread A (its own V8 heap) SharedArrayBuffer thread B (its own V8 heap) owned points -- spawn: copy ---> moved data -- read -----> owned points (plain objects) share(centers) -- write once ----> shared values -- first use -> frozen copy (plain objects) cells: Lock, Condition, structs <-> used in place by both The heaps stay separate. V8 objects can't cross workers, so each thread works on ordinary objects in its own heap. That's why the inner loops run at full speed. Moving copies. spawn writes its arguments into the SharedArrayBuffer once, in a compact binary form. The thread reads them back as plain objects of its own. join does the same in the other direction. As a safety net for code the checker didn't see, own.ts also revokes the sender's moved objects, so any use of them throws. Sharing copies once per reader. share writes a value once. Each thread that reads it makes one frozen copy on first use, and keeps it. Cells are the only shared mutable state. They live in the SharedArrayBuffer and synchronize with Atomics. Threads come from a pool of workers that start before the program does. A thread names its function by the key register gave it. Every worker loads the same module, so the key finds the same function in each one. So the cost is the copying at the crossings. It's proportional to what crosses, and there's nothing extra per property access. In this example, each point is copied once, into its thread. Compare that with the N-body program in the prototype. There, positions are shared every step, so every thread copies all of them every step. That copying is what stops it from scaling past a few threads."},{"url":"/tutorial/#limits","page":"Tutorial: Threads With Owned and Shared Values","section":"Limits","text":"There are a few limits to keep in mind: Shared values are never freed. A moved value is freed once it's read. Structs, SharedArrays, maps and strings are reference counted (see Reference Counting). But what share writes stays. A value captured by a closure can't be moved out of it. That's true even for a value received as Sending. Create it inside the closure instead, as main does in the scope callback. Arrays of owned values are split with the trusted partition and take. The checker can't prove that two slices of one array are disjoint."}]