Getting WebGPU-accelerated libraries working in DCP work functions
WebGPU compute itself works fine inside a DCP worker sandbox – adapters, devices, buffers, shader modules, compute pipelines, and readback all behave exactly as documented. What doesn’t automatically work is any library that wraps WebGPU with its own module-loading machinery, because that machinery was written assuming it runs in an ordinary web page, not a sandboxed eval context with no real page URL. onnxruntime-web – the WebGPU execution provider behind transformers.js and used throughout the CallRAG project – is the running example throughout this page, but the underlying obstacle and its fix apply to any WebGPU-capable library built the same way.
Step 0: Confirm the sandbox itself isn’t the problem
Before debugging a library, rule out DCP’s own WebGPU exposure as a source of confusion – it’s a small, cheap check, and it wasn’t the problem here.
job.requirements.environment = { webgpu: true } is required for a slice to be scheduled onto a WebGPU-capable worker at all; without it, navigator.gpu doesn’t exist in the sandbox, by design. Bare WebGPU usage – no external library – confirms the sandbox itself is sound:
async function workFunction() {
const adapter = await navigator.gpu.requestAdapter();
const device = await adapter.requestDevice();
const buf = device.createBuffer({
size: 64,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(buf, 0, new Float32Array(16));
// ... createShaderModule / createComputePipeline / dispatchWorkgroups / submit / mapAsync, same as any WebGPU code
return 'ok';
}
job.requirements.environment = { webgpu: true };
A full working example (a small matrix-multiply compute shader) confirmed every step of this – adapter request, device request, buffer creation, shader compilation, pipeline dispatch, and mapped readback – runs correctly in a real DCP worker sandbox, including through DCP’s own GPU-usage-timing wrapper and its global-scope access-control layer. If your bare-WebGPU version of this works and a library on top of it doesn’t, the problem is specific to that library’s module loading – not the sandbox.
The real obstacle: Dynamic import() has no base URL to resolve against
Building WASM modules for DCP jobs covers the sandbox’s fetch() origin restriction and how Emscripten’s default .wasm-loading path runs into it. WebGPU-capable libraries hit a different, unrelated problem that looks superficially similar but needs a different fix.
onnxruntime-web’s WebGPU (JSEP) execution provider does a genuine dynamic import() of a companion .mjs file during initialization, as part of loading its WebAssembly fallback path – this is architecturally different from its plain CPU execution provider, which only ever needs fetch()-loaded (or directly supplied) .wasm bytes. A DCP work function executes inside a sandboxed eval, with about:blank as its base URL. Dynamic import() of a relative specifier needs a real base URL to resolve against – under about:blank, that resolution fails outright:
TypeError: Failed to resolve module specifier './ort-wasm-simd-threaded.asyncify.mjs'.
The base URL is about:blank because import() is called from a CORS-cross-origin script.
This failure is commonly misdiagnosed. Supplying the .wasm binary directly (the standard fix for the fetch() problem – see wasm-modules.md) does nothing here, because the failure isn’t a blocked network request; it’s a module-resolution failure that happens before any request would even be made. In practice this surfaced first as a much less specific error from deeper inside the library’s own error handling (Cannot convert undefined to a BigInt) – the underlying import() failure gets caught and re-thrown by intermediate code, so the actual symptom you see may not implicate module resolution at all. If you hit a confusing, seemingly unrelated crash the moment device: 'webgpu' is requested, suspect this before anything else.
The fix: An absolute data: URL doesn’t need a base URL at all
onnxruntime-web (and libraries built on it, including transformers.js) expose env.wasm.wasmPaths, which can be a plain string prefix – or an object with per-file overrides:
env.wasm.wasmPaths = {
mjs: 'https://example.com/ort-wasm-simd-threaded.asyncify.mjs',
wasm: 'https://example.com/ort-wasm-simd-threaded.asyncify.wasm',
};
An absolute URL here sidesteps relative-specifier resolution – but a remote https: URL just relocates the problem to fetch()’s own origin allowlist. The fix that actually works in a DCP sandbox is a data: URL: import() of a data:text/javascript;base64,... URL is self-contained and needs no base URL to resolve, and no network request happens at all.
The exact property path depends on which object you’re configuring: it’s env.wasm.* on onnxruntime-web’s own exported env, but env.backends.onnx.wasm.* on transformers.js’s re-exported env, which nests the underlying runtime’s config one level deeper. Check the library’s own docs for the right path rather than assuming either shape.
const fs = require('fs');
// Read the .mjs glue file and the .wasm binary it wraps once, at build time.
const mjsBase64 = fs
.readFileSync(
'./node_modules/onnxruntime-web/dist/ort-wasm-simd-threaded.asyncify.mjs',
)
.toString('base64');
const wasmBytes = fs.readFileSync(
'./node_modules/onnxruntime-web/dist/ort-wasm-simd-threaded.asyncify.wasm',
);
// ... embed both as job.requires() modules or job arguments, the same way you'd ship any other local asset.
// Inside the work function, after the library is loaded:
const { pipeline, env } = require('@huggingface/transformers');
env.backends.onnx.wasm.wasmBinary = wasmBytes; // still needed -- see wasm-modules.md
env.backends.onnx.wasm.wasmPaths = {
mjs: 'data:text/javascript;base64,' + mjsBase64,
}; // the actual fix
env.backends.onnx.wasm.numThreads = 1;
env.backends.onnx.wasm.proxy = false;
const transcriber = await pipeline(
'automatic-speech-recognition',
'Xenova/whisper-tiny.en',
{ device: 'webgpu' },
);
Both settings matter, for different reasons: wasmPaths.mjs fixes the import() that was actually failing; wasmBinary is still required so that once the glue module successfully loads, it doesn’t turn around and try to fetch() the .wasm binary by URL, which would just fail the same way described in wasm-modules.md.
Note
Set configuration like this on the library’s own exported env – if you separately require() the underlying WebGPU-capable package elsewhere in your code, you may be touching a different module instance than the one the library actually uses internally (this can happen when a bundler resolves an ESM import and a CommonJS require() of the same logical package to two different physical files). The override then silently has no effect, with no error to indicate why. If you set something like this and it doesn’t seem to take effect, this mismatch is the first thing to check.
A second, separate obstacle: Self-location code at the library’s own module entry point
The import() failure above isn’t the only place this class of bug shows up. onnxruntime-web’s WebGPU entry point itself (what onnxruntime-web/webgpu’s package.json exports map resolves to – dist/ort.webgpu.min.js for require(), dist/ort.webgpu.bundle.min.mjs for a bundler’s default import condition) runs its own self-location detection (document.currentScript?.src / self.location?.href / import.meta.url) at module load/init time – before any of your own configuration code (like the wasmPaths.mjs fix above) even runs. Under DCP’s about:blank-base-URL sandbox, this throws immediately (Failed to construct 'URL': Invalid URL), so the module never finishes loading at all.
This is a genuinely different failure from the import()/base-URL one above – same underlying cause (code written assuming a real page URL, sandboxed eval has none), but a different call site, at a different point in the loading sequence, needing a different fix. There’s no configuration escape hatch for this one – it’s not exposed through env.wasm.* the way the .mjs path is. The only fix found was patching the minified source directly, replacing the self-location lookup with a no-op, before the file is ever bundled into a job:
// Applied to node_modules/onnxruntime-web/dist/ort.webgpu.min.js,
// ort.webgpu.min.mjs, and ort.webgpu.bundle.min.mjs (require/import/
// bundler-default entry points respectively -- which one actually gets
// resolved isn't directly observable from the job side, so patch all three).
const patched = source.replace(
'en=()=>{if(!!1)return typeof document<"u"?document.currentScript?.src:typeof self<"u"?self.location?.href:void 0}',
'en=()=>void 0',
);
(The exact minified variable names and surrounding pattern are specific to the onnxruntime-web version in use, and will need re-deriving against a different version – treat the string above as an example of the shape of the fix, not a literal copy-pasteable patch.)
Since this patches node_modules directly, it doesn’t survive a fresh npm install on its own – wire it into a postinstall script (or equivalent) so it’s reliably reapplied, and make the patch function idempotent (skip silently, don’t error, if the pattern’s already gone) so re-running it after it’s already applied is harmless. Both fixes are needed together for device: 'webgpu' to work at all: this one lets the module load in the first place; the wasmPaths.mjs fix above is what makes the load succeed once it’s past this point.
A methodology for diagnosing a similar failure in a different library
If a different WebGPU-capable library breaks with an unclear error the moment it touches a DCP sandbox, an escalation ladder isolates the actual layer at fault faster than debugging the failure in place:
Bare WebGPU, no library at all (the Step 0 example above). If this fails, the problem is in your job’s requirements or the sandbox’s WebGPU exposure itself – everything below assumes this step passes.
The library’s lowest-level API, directly – for example,
onnxruntime-web’s rawInferenceSession.create(), with no framework on top. This is where the actualimport()/base-URL failure surfaced, several layers before it reached transformers.js’s own error handling.The full framework, once step 2 works. Confirm the fix applies cleanly when the library is used the way your actual code will use it – there’s often one more instance-mismatch or bundling wrinkle (see the note above) between “the raw library works” and “the framework built on it works.”
Each rung should be dispatched as its own minimal DCP job. The error at rung 2 was materially more specific and actionable than the error every use of rung 3 alone had produced – going straight to the full framework and debugging from there would have taken considerably longer.
Note
This class of bug is specifically about the sandbox’s about:blank base URL, which an ordinary browser tab or a plain Web Worker doesn’t have. Testing a WebGPU-capable library in a normal browser context before ever touching DCP – even a real dedicated Worker – will not reproduce it. If you want to catch this locally, the reproduction needs an actual about:blank-sourced execution context (for example, code loaded via eval() or a Function() constructor rather than a normal <script> tag or module import), not just “runs in a Worker.”
What you have at this point
A WebGPU-capable library (or your own code built on one) that initializes cleanly inside a DCP work function, with device: 'webgpu' actually exercising the GPU rather than falling back to CPU or crashing outright. Whether this is worth doing for a given workload is a separate question from whether it can be done – WebGPU-capable workers are a smaller pool than CPU-only ones, since job.requirements.environment.webgpu restricts scheduling to workers that report GPU capability, so it should be requested for workloads that actually benefit from GPU acceleration rather than by default.