Skip to content

Buffer reference

BufferReference is a zero-copy handle for moving ArrayBuffer bytes to thread workers. Import it from the subpath:

import { BufferReference } from "knitting/unsafe";

Constructing a BufferReference detaches the source immediately. The bytes now belong to the reference; reading or writing the original buffer after construction returns zero-length or stale data.

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

This is intentional: the move is what makes the transfer zero-copy.


Use BufferReference as a task argument to move bytes to a thread worker without serializing them through the transport:

import { createPool, isMain, task } from "knitting";
import { BufferReference } from "knitting/unsafe";
export const invert = task<BufferReference, BufferReference>({
f: (ref) => {
const pixels = ref.toUint8Array(); // the moved bytes, no copy
const out = new Uint8Array(pixels.length);
for (let i = 0; i < pixels.length; i++) out[i] = 255 - pixels[i];
return new BufferReference(out); // move the result back
},
});
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]
}

Two accessors materialize the moved region:

MethodReturnsNotes
toUint8Array()Uint8ArrayView over the moved bytes.
toArrayBuffer()ArrayBufferThe underlying buffer.

These views are backed by the reference, so use them while it is alive and let using / release() clean up when you are done. In the default copy mode this is ordinary lifecycle hygiene, not a safety hazard — the stricter rules only apply to the borrow return mode below.


BufferReference implements Symbol.dispose. Use using to release automatically, or call release() explicitly:

{
using result = await pool.call.invert(new BufferReference(pixels));
const out = result.toUint8Array();
console.log([...out]);
} // result is released here

After release() the reference no longer owns the bytes, so stop using any view you took from it. In the default copy mode this is just normal cleanup; the only mode where reading too late is an actual use-after-free is borrow on Deno and Bun (see below).


  • Thread workers only. The handle is a process-local pointer. Sending a BufferReference to a process worker throws. For cross-process sharing use ProcessSharedBuffer (see Shared memory).
  • ArrayBuffer sources only. SharedArrayBuffer cannot be detached and is rejected. SAB-backed typed-array views are also rejected.
  • One-shot. Each reference is used once. The worker materializes the bytes with toUint8Array() / toArrayBuffer(), and those values are borrowed for the duration of the call. Do not use them from fire-and-forget work after the task returns.

When a worker returns a BufferReference, the host must take possession of the bytes. The default is copy (safe on all runtimes):

using pool = createPool({
threads: 1,
unsafe: { BufferReferenceReturn: "copy" },
})({ invert });

On Node.js the return is always zero-copy regardless of this setting — the engine co-owns the backing store across threads, so there is no use-after-free risk even with "borrow". On Deno and Bun, "copy" takes a single copy of the returned bytes so they safely outlive the worker. Set it to "borrow" to skip that copy:

import { BufferReferenceReturn } from "knitting/unsafe";
using pool = createPool({
threads: 1,
unsafe: { BufferReferenceReturn: BufferReferenceReturn.Borrow },
})({ invert });
{
using result = await pool.call.invert(new BufferReference(pixels));
const out = result.toUint8Array(); // borrowed — valid only while result lives
console.log([...out]);
} // result is released here; do not read out after this point

The named constants BufferReferenceReturn.Copy ("copy") and BufferReferenceReturn.Borrow ("borrow") are exported from knitting/unsafe.


BufferReference can be the body of an Envelope, combining a JSON header with zero-copy bytes:

import { Envelope } from "knitting";
import { BufferReference } from "knitting/unsafe";
export const process = 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. Disposing is a no-op for ArrayBuffer and SharedArrayBuffer bodies.

See Payloads — Envelope for the full body type table.


Below roughly 256 KiB the per-call pointer setup tends to cost more than just copying through the shared transport. Reach for BufferReference only when the copy cost of a large buffer to a thread worker actually matters in profiling.

For process workers, use ProcessSharedBuffer instead. For smaller buffers, a plain ArrayBuffer or typed array is simpler and works for both thread and process workers.