Buffer reference
BufferReference is useful when you need to send a large ArrayBuffer to a
thread worker without copying its bytes. It moves the bytes instead of
sharing them. Import it from the knitting/unsafe subpath:
import { BufferReference } from "knitting/unsafe";Moving the buffer
Section titled “Moving the buffer”Creating a BufferReference detaches the source immediately. The bytes now
belong to the reference, so the original view can no longer access them. For a
typed-array view, byteLength and length become zero; APIs that require an
attached ArrayBuffer may throw.
const pixels = new Uint8Array([0, 64, 128, 192, 255]);
const ref = new BufferReference(pixels); // pixels.buffer is now detached
console.log(pixels.byteLength); // 0 — the source was movedconsole.log(ref.byteLength); // 5That is the trade-off that makes the transfer zero-copy: after the move, there is only one owner of the bytes.
Send it to a worker
Section titled “Send it to a worker”Wrap the buffer and pass the reference as a task argument:
import { createPool, isMain, task } from "knitting";import { BufferReference } from "knitting/unsafe";
export const invert = task<BufferReference, BufferReference>({ f: (ref) => { const pixels = ref.toUint8Array(); const out = new Uint8Array(pixels.length); for (let i = 0; i < pixels.length; i++) out[i] = 255 - pixels[i]; return new BufferReference(out); },});
if (isMain) { const pixels = new Uint8Array([0, 64, 128, 192, 255]); using pool = createPool({ threads: 1 })({ invert });
const result = await pool.call.invert(new BufferReference(pixels)); console.log([...result.toUint8Array()]); // [255, 191, 127, 63, 0]}Reading the bytes
Section titled “Reading the bytes”Use either accessor to read the bytes:
| Method | Returns | Notes |
|---|---|---|
toUint8Array() | Uint8Array | A view over the bytes. |
toArrayBuffer() | ArrayBuffer | An ArrayBuffer containing the bytes; a subview may require a copy. |
Both methods can be called more than once while the reference is active. Keep
the reference alive while you use the returned view or buffer, and call
release() when you are done. Releasing the reference detaches any views it
created first, so a view retained after release becomes empty or throws instead of reading
freed memory.
Releasing the reference
Section titled “Releasing the reference”BufferReference implements Symbol.dispose, so using is usually the easiest
way to clean it up:
{ using result = await pool.call.invert(new BufferReference(pixels)); const out = result.toUint8Array(); console.log([...out]);} // result is released hereIf you are not using using, call release() yourself.
After release(), stop using any view you took from the reference. On runtimes
where the host and worker cannot safely keep the same backing store alive,
Knitting detaches those views before releasing the worker’s memory. If
detaching fails, it keeps the memory alive rather than risk a use-after-free.
Important constraints
Section titled “Important constraints”- Thread workers only. The handle refers to memory in the current process.
Sending it to a process worker throws. For cross-process sharing, use
ProcessSharedBuffer(see Shared memory). ArrayBuffer-backed views only.SharedArrayBuffercannot be detached and is rejected. SAB-backed typed-array views are also rejected.- The move is one-way. A reference may be read more than once while it is
active, but it cannot be used after
release(). Do not hand its view to a timer, stream, or other work that continues after the task.
BufferReference is intended for trusted, same-process code. It is not a
security boundary, so do not accept raw metadata or native pointers from
untrusted code.
Large binary returns
Section titled “Large binary returns”For a large result, return an ordinary top-level Uint8Array or ArrayBuffer.
At 256 KiB and above, thread workers use the safe ownership path
automatically:
| Runtime | Result ownership |
|---|---|
| Node 22/24 with the native addon | The host adopts the backing store without a byte copy. |
| Deno and Bun | The host makes one private copy before releasing the worker’s memory. |
| Older Node backends | The same one-private-copy fallback. |
The host result is owned and remains valid after later calls and pool shutdown. There is no wrapper or manual release for this path:
export const render = task<number, Uint8Array>({ f: (size) => { const out = new Uint8Array(size); out.fill(7); return out; // 256 KiB and above: ownership moves automatically },});Smaller or non-movable values use the ordinary payload-copy path. Use
BufferReference when the input is already an owned ArrayBuffer or typed-array
view and moving it into a thread worker is the bottleneck.
For an intentionally short-lived borrowed return from a worker’s shared arena,
use sharedBytes() with
unsafe: { SharedBytes: true } instead. That opt-in path has stricter lifetime
rules and is not a replacement for ordinary owned results.
Explicit borrowed returns
Section titled “Explicit borrowed returns”sharedBytes(byteLength, zeroFill = false) allocates a return view in the
worker’s shared arena. The task must write every byte it returns, and the host
must copy a result that needs to outlive the next batch of calls:
import { createPool, isMain, task } from "knitting";import { sharedBytes } from "knitting/unsafe";
export const render = task<number, Uint8Array>({ f: (size) => { const out = sharedBytes(size); for (let i = 0; i < out.length; i++) out[i] = i & 0xff; return out; },});
if (isMain) { using pool = createPool({ threads: 4, unsafe: { SharedBytes: true }, })({ render });
const borrowed = await pool.call.render(1024 * 1024); const owned = borrowed.slice(); // keep this beyond the current return window console.log(owned.byteLength);}Borrowed return views are kept for a window of 32 large results per worker lane.
Outside a worker return lane, or when the shared path is unavailable,
sharedBytes() falls back to an ordinary private Uint8Array. Compiled workers
reject unsafe options instead of falling back.
Use it in an Envelope
Section titled “Use it in an Envelope”An Envelope can carry a BufferReference body when you need a JSON header
alongside binary data:
import { Envelope, task } from "knitting";import { BufferReference } from "knitting/unsafe";
export const processImage = task< Envelope<{ op: string }, BufferReference>, Envelope<{ done: boolean }, BufferReference>>({ f: (env) => { const pixels = env.payload.toUint8Array(); const out = new Uint8Array(pixels.length); for (let i = 0; i < pixels.length; i++) out[i] = 255 - pixels[i]; return new Envelope({ done: true }, new BufferReference(out)); },});Disposing the envelope also disposes a BufferReference body. An
ArrayBuffer or SharedArrayBuffer body has nothing to dispose.
See Payloads — Envelope for the full body type table.
When to use it
Section titled “When to use it”For smaller buffers, the setup cost can outweigh the time saved by avoiding a
copy. Use BufferReference when profiling shows that copying large buffers is
actually a bottleneck; this is usually more relevant for buffers that are
hundreds of kilobytes or several megabytes in size.
For process workers, use ProcessSharedBuffer instead. For smaller payloads, a
plain ArrayBuffer or typed array is simpler and works with both worker types.