Skip to content
easy-web-worker
Interactive example · Memory

Send 64 MB to a Worker without copying it

Send the same buffer as a copy and as a transfer, and compare the time and what the sender is left holding.

Try it hereLive example
Application preview

Loading the example…

buffer.worker.ts
import { defineWorker } from 'easy-web-worker/defineWorker';

const worker = defineWorker(({ onMessage }) => ({
  /** Receives the buffer, changes every byte and gives the same memory back. */
  invert: onMessage<ArrayBuffer, ArrayBuffer>().handle((message) => {
    const bytes = new Uint8Array(message.payload);

    for (let index = 0; index < bytes.length; index++) {
      bytes[index] = 255 - bytes[index];
    }

    // the second argument transfers the result instead of copying it
    message.resolve(bytes.buffer, [bytes.buffer]);
  }),
}));

export type BufferWorker = typeof worker;
Ready. Run the example to begin.Reset returns this example to its initial state.
Take it with you

A transfer moves ownership. The data changes threads and nothing is duplicated.

01

Press Send a copy. The main thread still holds the whole buffer afterwards, so the data existed twice.

02

Press Transfer it. Still in the main thread drops to 0 MB, because the memory moved to the worker.

03

Raise the size. The copy gets slower with every megabyte; the transfer barely changes.

Read the full guide

The code

A returned value is always copied. To transfer a result, the method completes the message itself and passes the transfer list to message.resolve. onMessage<TPayload, TResult>().handle(...) is the form for that.

buffer.worker.ts
import { defineWorker } from 'easy-web-worker/defineWorker';

const worker = defineWorker(({ onMessage }) => ({
  /** Receives the buffer, changes every byte and gives the same memory back. */
  invert: onMessage<ArrayBuffer, ArrayBuffer>().handle((message) => {
    const bytes = new Uint8Array(message.payload);

    for (let index = 0; index < bytes.length; index++) {
      bytes[index] = 255 - bytes[index];
    }

    // the second argument transfers the result instead of copying it
    message.resolve(bytes.buffer, [bytes.buffer]);
  }),
}));

export type BufferWorker = typeof worker;

On the main thread the transfer list is the second argument of the call. It is the only difference between the two buttons.

TransferDemo.tsx
import { useEffect, useRef, useState } from 'react';
import { unwrap } from 'easy-web-worker/createWorker';
import '../shared/demo.css';
import { createLog } from '../shared/log';
import { megabytes, milliseconds } from '../shared/format';
import { createBufferWorker } from './client';

type Mode = 'copy' | 'transfer';

interface Run {
  mode: Mode;
  elapsed: number;
  /** Size of the buffer the main thread still holds after sending it. */
  kept: number;
  returned: number;
  firstByte: number;
}

const log = createLog();
const sizes = [16, 64, 128] as const;

export function TransferDemo() {
  const [size, setSize] = useState<(typeof sizes)[number]>(64);
  const [running, setRunning] = useState<Mode | null>(null);
  const [runs, setRuns] = useState<Partial<Record<Mode, Run>>>({});
  const worker = useRef<ReturnType<typeof createBufferWorker> | null>(null);

  useEffect(
    () => () => {
      if (worker.current) void unwrap(worker.current).dispose();
    },
    [],
  );

  const send = async (mode: Mode) => {
    worker.current ??= createBufferWorker();

    setRunning(mode);

    const buffer = new ArrayBuffer(size * 1024 * 1024);
    const startedAt = performance.now();

    // the transfer list is the only difference between the two calls
    const result = await (mode === 'transfer' ? worker.current.invert(buffer, [buffer]) : worker.current.invert(buffer));

    const run: Run = {
      mode,
      elapsed: performance.now() - startedAt,
      kept: buffer.byteLength,
      returned: result.byteLength,
      firstByte: new Uint8Array(result)[0],
    };

    setRuns((current) => ({ ...current, [mode]: run }));
    setRunning(null);
    log.write(
      mode === 'transfer'
        ? `transferred · main thread keeps ${megabytes(run.kept)} · ${milliseconds(run.elapsed)}`
        : `copied · main thread keeps ${megabytes(run.kept)} · ${milliseconds(run.elapsed)}`,
    );
  };

  const card = (mode: Mode, title: string) => {
    const run = runs[mode];

    return (
      <section className="demo-card" aria-label={title}>
        <span>{title}</span>
        <dl className="demo-stats">
          <div className={`demo-stat ${run && mode === 'transfer' ? 'demo-stat--good' : ''}`}>
            <dt>Round trip</dt>
            <dd data-testid={`${mode}-elapsed`}>{run ? milliseconds(run.elapsed) : 'not run'}</dd>
          </div>
          <div className="demo-stat">
            <dt>Still in the main thread</dt>
            <dd data-testid={`${mode}-kept`}>{run ? megabytes(run.kept) : 'not run'}</dd>
          </div>
          <div className="demo-stat">
            <dt>Came back</dt>
            <dd data-testid={`${mode}-returned`}>{run ? megabytes(run.returned) : 'not run'}</dd>
          </div>
        </dl>
      </section>
    );
  };

  return (
    <div className="demo">
      <section className="demo-card" aria-label="Buffer">
        <fieldset className="segmented">
          <legend>Buffer size</legend>
          {sizes.map((value) => (
            <label key={value}>
              <input type="radio" name="buffer-size" checked={size === value} disabled={running !== null} onChange={() => { setSize(value); setRuns({}); }} />
              <span>{value} MB</span>
            </label>
          ))}
        </fieldset>
        <div className="demo-actions">
          <button type="button" className="demo-button" onClick={() => send('copy')} disabled={running !== null}>
            {running === 'copy' ? 'Sending…' : 'Send a copy'}
          </button>
          <button type="button" className="demo-button demo-button--primary" onClick={() => send('transfer')} disabled={running !== null}>
            {running === 'transfer' ? 'Sending…' : 'Transfer it'}
          </button>
        </div>
        <p className="demo-caption">
          The worker inverts every byte and sends the buffer back. A copy duplicates the memory on the way in; a transfer
          moves it, so the sender is left with an empty buffer.
        </p>
      </section>

      <div className="demo-grid demo-grid--stack">
        {card('copy', 'Sent as a copy')}
        {card('transfer', 'Transferred')}
      </div>
    </div>
  );
}

export const resetTransferDemo = () => {};

export const watchTransferDemo = log.watch;

Related documentation

Keep exploring

Another piece of the model.

↑↓ Navigate ↵ Open Esc Closeeasy-web-worker docs
Get started

Real Web Workers. Typed methods.
Cancelable promises.