This React code will cause a DOM error to be thrown:

function transferToOffscreen(node: HTMLCanvasElement | null) {
  node?.transferControlToOffscreen();
}

export default () => <canvas ref={transferToOffscreen} />;

The error:

Uncaught InvalidStateError: Failed to execute ’transferControlToOffscreen’ on ‘HTMLCanvasElement’: Cannot transfer control from a canvas for more than one time

The cause is due to re-running ref callbacks in development in React’s <StrictMode> on the same DOM node coupled with the one-way DOM door of transferring a canvas. Once the canvas is transferred, there is no going back.

The solution can be easy: a ref for our ref!

export default () => {
  const hasTransferred = useRef(false);
  const canvasCb = useCallback((canvas: HTMLCanvasElement | null) => {
    if (hasTransferred.current || !canvas) {
      return;
    }
    canvas.transferControlToOffscreen();
    hasTransferred.current = true;
  }, []);

  return (
    <canvas ref={canvasCb} />
  )
}

In effect, we are adding more code to circumvent strict mode, taking advantage of the fact that while callbacks are invoked twice, plain values remain stable.

Web Worker

An offscreen canvas does not live in isolation. Most likely it is paired with a web worker to enable complex applications like CAD where we want to avoid blocking the main thread. To avoid accumulating multiple expensive workers when the user navigates from a dashboard to our application, we’re going to need some sort of lifecycle.

There’s a tension here. How do we reconcile an irreversible operation with the requirement of a lifecycle in React strict mode?

The easiest solution is to remove strict mode. Some teams are amenable to this suggestion, but it seems like an overreaction and cuts against the react team recommendations.

If you ask an LLM, they’ll come up with a “schedule dispose” technique where the cleanup function of a useEffect schedules the termination of a web worker under a setTimeout(0) and then the re-run cancels the timeout. This is wild, I’ve never seen this before, but it works. However it left a bad taste in my mouth as relying on something as unpredictable as time and scheduling clouds reasoning so I discarded it.

Schedule dispose
export default () => {
  const hasTransferred = useRef(false);
  const workerRef = useRef(null);
  const disposeTimer = useRef(null);

  const canvasCb = useCallback((canvas) => {
    if (hasTransferred.current || !canvas) {
      return;
    }
    
    const offscreen = canvas.transferControlToOffscreen();
    const worker = new Worker(new URL('./canvas.worker.js', import.meta.url));
    worker.postMessage({ canvas: offscreen }, [offscreen]);
    
    workerRef.current = worker;
    hasTransferred.current = true;
  }, []);

  useEffect(() => {
    // Cancel any pending disposal from a prior unmount in this session
    if (disposeTimer.current) {
      clearTimeout(disposeTimer.current);
      disposeTimer.current = null;
    }
    
    return () => {
      disposeTimer.current = setTimeout(() => {
        workerRef.current?.terminate();
        workerRef.current = null;
        disposeTimer.current = null;
      }, 0);
    };
  }, []);

  return (
    <canvas ref={canvasCb} />
  )
}

Another option, would be to eschew React for the canvas element and drop down to using imperative DOM elements:

export default () => {
  const containerRef = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    if (containerRef.current === null) {
      return;
    }

    const canvas = document.createElement('canvas');
    canvas.width = 400;
    canvas.height = 300;
    containerRef.current.appendChild(canvas);

    const offscreen = canvas.transferControlToOffscreen();
    const worker = new Worker(new URL('./canvas.worker.js', import.meta.url));
    worker.postMessage({ canvas: offscreen }, [offscreen]);

    return () => {
      worker.terminate();
      canvas.remove();
    };
  }, []);

  return <div ref={containerRef} />;
}

This works and is how many React agnostic canvas-backed library work (like ECharts), but has the drawback of needing to find a way to make the component extensible to allow callers to customize things like event handling.

The other option is to move the worker ownership out from under React, such that the worker is only terminated on user action like route navigation. This, coupled with lazy initialization works pretty well as any response from the web worker is necessarily asynchronous.

let worker: Worker | null = null;
let transferred = false;

function getWorker(): Worker {
  return (worker ??= new Worker(new URL("./canvas.worker.ts", import.meta.url), {
    type: "module",
  }));
}

function registerCanvas(el: HTMLCanvasElement | null): void {
  if (!el || transferred) return;
  const offscreen = el.transferControlToOffscreen();
  getWorker().postMessage({ type: "init", canvas: offscreen }, [offscreen]);
  transferred = true;
}

function destroyWorker(): void {
  worker?.terminate();
  worker = null;
  transferred = false;
}

export default () => {
  return (
    <div>
      <a href="/some-route" onClick={destroyWorker}>
        Go to some route
      </a>
      <canvas ref={registerCanvas} width={400} height={400} />
    </div>
  );
};

It may seem like a downside to have a global web worker, as you wouldn’t be able to mount the canvas simultaneously in two different locations. But I’d posit it is rare that you want component granular web workers and instead want application level. The React docs give the example of having application initialization occurring outside of components.

Conclusion

The right solution is situation dependent, though I’d recommend the imperative DOM canvas or moving the Web worker ownership out of React, before thinking about a scheduled dispose or disabling strict mode.

The imperative DOM canvas and React agnostic Web worker can even be combined to insulate code from React semantics, and instead expose the management APIs.

I’ve built several applications with transferring offscreen canvases to web workers in React, and the strict mode has been a continual sticking point that LLMs seem inadequate in, so I figured I’d type up my thoughts.