Getting TensorFlow.js (WebGPU and WASM) running in DCP work functions

TensorFlow.js works inside a DCP work function, on either its WebGPU or WASM backend, once a handful of DCP-sandbox-specific obstacles are handled. Two ready-to-use packages already exist on the DCP package manager – most jobs never need anything past the next section. The rest of this page covers how those packages were built, for anyone bundling a different tfjs configuration (a different backend combination, a newer version, or a different library entirely).

Quick start: Using the published packages

Pick the package that matches the worker pool you want:

Package

Backend

Requires webgpu: true?

Worker pool

dcp-tfjs-webgpu

webgpu

Yes

GPU-capable workers only

dcp-tfjs-wasm

wasm

No

Any worker

// GPU-capable workers:
job.requires(['dcp-tfjs-webgpu/tfjs.js']);
job.requirements.environment = { webgpu: true };

// inside the work function:
const tf = require('tfjs.js');
await tf.setBackend('webgpu');
await tf.ready();
// Any worker, CPU only:
job.requires(['dcp-tfjs-wasm/tfjs-wasm.js']);
// no job.requirements.environment.webgpu

// inside the work function:
const tf = require('tfjs-wasm.js');
await tf.setBackend('wasm');
await tf.ready();

Both packages export the full @tensorflow/tfjs API (core, layers, converter, data) with their respective backend already registered – everything documented under tf.* works exactly as it does anywhere else, since it’s the same unmodified tfjs code. dcp-tfjs-wasm also has its WASM binaries embedded and its fetch() patch already applied (see below for why); setWasmPaths() is pre-configured, so setBackend('wasm') is the only step needed.

Note

tf.setBackend() returns a boolean and does not throw on failure – check its return value, and check tf.getBackend() after tf.ready() matches what you asked for. A failed backend silently cascades to another one (webgpu -> webgl -> cpu, or wasm -> webgl -> cpu) with no error at any point. A job that only awaits these calls without checking will report success even after silently falling back to plain, unaccelerated CPU.

Bundling tfjs locally: Seven Node-core-module polyfills

@tensorflow/tfjs-core’s dist/base_side_effects.js unconditionally imports ./platforms/platform_node.js – tfjs bundles both its Node and browser platform implementations together and runtime-selects between them, so there’s no browser-only entry point that avoids pulling the Node one in. platform_node.js itself only touches util and lazily requires node-fetch for HTTP – but DCP’s local job.requires() bundler runs webpack with target: ['es6'] (confirmed by reading dcp-client’s own bundler source directly), which doesn’t automatically polyfill Node core modules the way older webpack defaults did. node-fetch’s own dependency tree pulls in a full HTTP-client stack, surfacing seven missing modules in total: util, punycode, url, http, https, stream, zlib.

Install real polyfills under the exact bare names webpack tries to resolve, using npm’s name@npm:realpackage aliasing for the ones that aren’t published under their own name:

{
  "dependencies": {
    "util": "^0.12.5",
    "punycode": "^2.3.1",
    "url": "^0.11.4",
    "http": "npm:stream-http@^3.2.0",
    "https": "npm:https-browserify@^1.0.0",
    "stream": "npm:stream-browserify@^3.0.0",
    "zlib": "npm:browserify-zlib@^0.2.0"
  }
}

Stop chasing dependencies – stub node-fetch itself

browserify-zlib (needed for the zlib polyfill above) pulls in yet another Node core module, assert, transitively through node-fetch’s own zlib usage – an unbounded tail of “polyfill one module, discover it needs another.”

platform_node.js’s HTTP path is dead code at runtime in a DCP sandbox – tfjs’s own environment detection always selects platform_browser.js there, so none of node-fetch’s real functionality ever executes. Confirm nothing else in the project actually depends on node-fetch (npm ls node-fetch) before replacing it project-wide, then stub it with a local package:

// stubs/empty-node-fetch/index.js
function stubFetch() {
  throw new Error(
    'node-fetch is stubbed out for DCP bundling and should never actually be called',
  );
}
module.exports = stubFetch;
module.exports.default = stubFetch;
{
  "dependencies": { "node-fetch": "file:stubs/empty-node-fetch" },
  "overrides": { "node-fetch": "file:stubs/empty-node-fetch" }
}

The overrides field matters: a plain dependency-level alias only replaces the top-level node_modules/node-fetchtfjs-core installs its own nested copy, which a plain alias doesn’t reach. overrides forces every nested resolution of the package name, not just the top-level one. A stale node_modules/lockfile can mask this too – a clean rm -rf node_modules package-lock.json && npm install may be needed for the override to actually take effect.

An immediate progress() call is required, not just a periodic one

DCP’s own ENOPROGRESS liveness check (documented elsewhere as needing a call at least every ~30 s) also seems to require an initial call almost immediately – a work function with none at all failed within ~22 ms of starting, with WorkerError: No progress error in sandbox. Call progress(0) as the first line of the work function.

WebGPU is opt-in – neither tfjs-core nor the full tfjs package includes it

@tensorflow/tfjs’s own dependency tree only includes tfjs-backend-webgl, never tfjs-backend-webgpu – WebGPU support is a separate, optional package in the tfjs ecosystem, never bundled automatically. Requiring the full @tensorflow/tfjs package alone fails with Error: Backend name 'webgpu' not found in registry. Fix: require @tensorflow/tfjs-backend-webgpu explicitly, purely for its module-load side effect of calling registerBackend('webgpu', ...):

const tf = require('@tensorflow/tfjs');
require('@tensorflow/tfjs-backend-webgpu'); // side-effect only, registers the backend
await tf.setBackend('webgpu');

What looks like a module-instance mismatch usually isn’t one

Bare @tensorflow/tfjs-core (not the full @tensorflow/tfjs package) throws TypeError: r.add is not a function on tensor.add(otherTensor), even though tensor instanceof tf.Tensor is true. This looks exactly like the “different module instance” bug documented for onnxruntime-web (a bundler resolving an ESM import and a CommonJS require() of the same package to two different physical files) – but npm ls @tensorflow/tfjs-core showed proper deduplication, only one physical copy installed, so that theory didn’t hold up.

Before assuming a DCP-sandbox-specific cause, add real runtime introspection rather than guessing further:

const a = tf.tensor1d([1, 2, 3]);
console.log({
  isTensor: a instanceof tf.Tensor,
  protoMethods: Object.getOwnPropertyNames(Object.getPrototypeOf(a)),
  hasAdd: typeof a.add,
  tfHasAdd: typeof tf.add,
});

This revealed the real, much more mundane cause: bare tfjs-core never attaches chained methods (tensor.add(other)) to Tensor.prototype at all – that registration only happens when the full @tensorflow/tfjs package (or an explicit chained-ops import) runs. The functional form, tf.add(a, b), is always present and works everywhere. Not a DCP quirk at all – an ordinary tfjs API-surface gotcha that would bite any environment using bare tfjs-core.

Note

The full @tensorflow/tfjs package does register chained ops – confirmed directly (typeof tensor.add === 'function' was true there). This distinction only matters if bundling bare tfjs-core specifically, e.g. to keep a bundle smaller.

Loading a real model: A custom IOHandler, no fetch() involved at all

Getting model weights into a DCP sandbox has a cleaner answer for tfjs than the fetch()-patching approach documented for onnxruntime-web (see Getting WebGPU-accelerated libraries working in DCP work functions): tfjs’s tf.io.IOHandler is a first-class, documented extension point for exactly this. A custom handler’s load() method can return embedded data directly, with no network call and no origin-allowlist concern at all:

const ioHandler = {
  load: async () => ({
    modelTopology: modelBundle.modelTopology,
    weightSpecs: modelBundle.weightSpecs,
    weightData: base64ToArrayBuffer(modelBundle.weightDataBase64),
  }),
};
const model = await tf.loadLayersModel(ioHandler);

modelBundle here came from capturing a real model.save()’s actual serialized output via tf.io.withSaveHandler(...) locally, then embedding it as base64 – the same shape a model loaded from disk or a real server would produce, just supplied directly instead of fetched. Verified against a known-good local (CPU) computation of the same model: output matched to 2.98e-8, effectively bit-identical, on the first attempt.

tfjs-backend-wasm: A different, deeper sandbox limitation

The WASM backend exists for CPU-only workers – no job.requirements.environment.webgpu at all. Getting it running surfaces a genuinely different problem than anything above.

setWasmPaths() needs all three WASM binaries supplied together (tfjs-backend-wasm.wasm, -simd.wasm, -threaded-simd.wasm) or it throws outright. A data: URL for each – the fix that worked for onnxruntime-web’s WebGPU crash – does not work here. It still fails, on a confirmed real browser-based worker (not just a non-browser worker daemon, ruled out first), with:

TypeError: AbortController is not a constructor
    at .../dcp-client/libexec/sandbox/fetch-factory.js:399

tfjs-backend-wasm’s own usePlatformFetch option (setWasmPaths’s second argument, intended for exactly this kind of environment) doesn’t avoid it either – reading createInstantiateWasmFunc in its source directly shows it still calls util.fetch(...) internally, the same sandboxed fetch()/Request path regardless of URL scheme. The problem isn’t origin-allowlisting this time; it’s that DCP’s own sandbox-provided fetch()/Request implementation is itself missing a working AbortController, and Emscripten’s WASM loader always constructs a real Request object internally, no matter what URL you give it.

The fix: monkey-patch globalThis.fetch directly, intercepting requests for the three binary filenames before the call ever reaches DCP’s broken sandbox fetch/Request machinery at all – the same technique Building WASM modules for DCP jobs uses for the unrelated fetch() origin-allowlist problem, applied here for a different underlying reason:

const realFetch = globalThis.fetch;
globalThis.fetch = function (url, ...args) {
  const match = Object.keys(files).find((name) => String(url).includes(name));
  if (match)
    return Promise.resolve({
      ok: true,
      arrayBuffer: async () => files[match].buffer,
    });
  return realFetch.call(this, url, ...args);
};

setWasmPaths() still needs a value for each binary, but once fetch() itself is intercepted, those values only need to contain the filename the patch matches against – no real URL scheme required.

Publishing your own version

DCP’s own local job.requires() bundler can’t just be reused for publishing. Its webpack output (confirmed by reading dcp-client’s bundler config directly) uses output.library: { type: 'amd', name: 'dcp-localhost' } – specific to how DCP bridges local files into the sandbox’s bravojs loader. A published package needs a hand-wrapped module.declare() file instead, with no AMD involved.

Build a plain CommonJS bundle with your own webpack invocation (target: ['es6'], matching DCP’s own config, but output.library: { type: 'commonjs2' } instead of amd), then wrap the raw output using the “flattening third-party bundled output” pattern from Publishing a DCP package – a local module shim absorbs the bundle’s own module.exports = ..., and its properties get copied onto the real exports via property assignment:

webpack({
  target: ['es6'],
  entry: entryFile, // require('@tensorflow/tfjs'); require('@tensorflow/tfjs-backend-webgpu'); module.exports = tf;
  output: { chunkFormat: 'commonjs', library: { type: 'commonjs2' } },
  resolve: { fallback: { fs: false, crypto: false } },
});
// then:
const wrapped = `module.declare([], function (require, exports, module) {
  var bundleExports = (function () {
    var module = { exports: {} };
    var exports = module.exports;
    ${rawWebpackOutput}
    return module.exports;
  })();
  for (var k in bundleExports) exports[k] = bundleExports[k];
});`;

dcp-tfjs-webgpu (1.5 MB) and dcp-tfjs-wasm (2.8 MB, wasm binaries and fetch-patch embedded) were both built this way and verified end to end against a real worker – see the Quick start section above for how to consume them.