Skip to content

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";

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 moved
console.log(ref.byteLength); // 5

That is the trade-off that makes the transfer zero-copy: after the move, there is only one owner of the bytes.


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]
}

Use either accessor to read the bytes:

MethodReturnsNotes
toUint8Array()Uint8ArrayA view over the bytes.
toArrayBuffer()ArrayBufferAn 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.


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 here

If 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.


  • 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. SharedArrayBuffer cannot 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.


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:

RuntimeResult ownership
Node 22/24 with the native addonThe host adopts the backing store without a byte copy.
Deno and BunThe host makes one private copy before releasing the worker’s memory.
Older Node backendsThe 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.

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.


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.


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.