promise-worker-transferable
promise-worker-transferable turns Web Worker and Service Worker messaging into promises. You wrap a Worker on the main thread, call postMessage and get back a promise that settles when the worker replies; inside the worker you register one handler that returns a value or a promise. It is a fork of Nolan Lawson's promise-worker whose reason to exist is transferable objects: both sides can hand over an ArrayBuffer instead of copying it, using an optional transfer list on the way in and a withTransferList wrapper on the way out. The trade is stated in its own README, which says it possibly works slower than the original for messages that are not transferable.
A small, readable fork that solves one real problem, transferring buffers instead of copying them, and then stopped in September 2016 with undocumented gaps around termination, timeouts and error detail. Use it if you are already on promise-worker and need transfers today; choose Comlink or workerpool for anything new.
Use it if
- You are moving large binary payloads such as ImageData, decoded audio or typed arrays between a page and a worker and the structured-clone copy is showing up in your profile
- You already use promise-worker and need the transferable path that upstream declined to add
- You want request and response correlation over postMessage without writing your own message id bookkeeping
- You need the same promise API for a Service Worker as for a Web Worker, since it detects the controller case and routes through a MessageChannel
- You want a maintained dependency: three versions exist, all published within two days in September 2016, the last repository push was July 2020, and there are 11 stars behind 3.8 million weekly downloads
- You need real errors across the boundary, because only error.message survives: the main thread receives new Error(message) with no name, no stack and no custom fields, and the worker unconditionally logs to console.error with no way to turn it off
- You need requests to time out, since a worker that never replies leaves the promise pending forever and its entry in the internal callbacks map, with no timeout and no cancellation
- You call terminate(), which the README does not document and which does not reject the in-flight promises, so every pending call hangs after you tear the worker down
- You want modern ergonomics, because this is CommonJS with no TypeScript declarations, no ESM build, and a browser support matrix written for IE 10 and Android 4.4
- You want RPC rather than one message handler, since the whole worker side is a single callback and you have to route by hand with a type field on the message
Setup reality
The install is small and the two-file split is deliberate: the main bundle requires promise-worker-transferable while the worker bundle requires promise-worker-transferable/register, so neither side ships the other's code. You need a real separate worker.js, since there is no inline-worker helper here. What deserves a read before you commit is index.js and register.js, which together are under 200 lines. Several things live there that the README does not mention. There is a terminate() method that forwards to worker.terminate() and leaves every pending callback in place, so promises created before it never settle. Error handling narrows hard: the worker posts back only error.message, and the main thread rejects with a fresh Error built from that string, so instanceof checks, error codes and stacks are gone, and the worker also calls console.error on every failure with a comment in the source explaining that this is intentional and not configurable. There is no timeout anywhere; the internal callbacks map is keyed by an incrementing message id and entries are deleted only when a reply arrives, so a worker that dies mid-task leaks both a promise and a map entry. The Service Worker path has a sharper edge: it builds its transfer list as [channel.port2].concat(transferList), and concat with an omitted argument appends the value rather than spreading it, so calling postMessage with no transfer list produces a two-element list whose second entry is not a transferable object. Pass an empty array explicitly on that path. Two dependency notes: is-promise is only used inside the worker registration, and lie is a Promise polyfill selected by a ternary that still contains a static require, so most bundlers include it whether or not your targets need it. Finally, remember what a transfer actually means, because the README does not spell it out: the buffer you hand over is detached in the sender, its byteLength becomes 0, and touching it afterwards throws.
Patterns
Send a message and await the replybasic-round-trip
// main.js
const PromiseWorker = require('promise-worker-transferable')
const worker = new Worker('worker.js')
const promiseWorker = new PromiseWorker(worker)
const reply = await promiseWorker.postMessage('ping')
console.log(reply) // 'pong'Two bundles are required: the main file imports the package, the worker file imports the /register entry point. Neither includes the other's code.
Handle messages inside the workerregister-handler
// worker.js
const registerPromiseWorker = require('promise-worker-transferable/register')
registerPromiseWorker((message) => {
if (message === 'ping') return 'pong'
return Promise.resolve(compute(message))
})One handler per worker. Returning a plain value and returning a promise both work; the register code checks with is-promise and resolves it for you.
Hand a buffer over instead of copying ittransfer-into-worker
const imageData = ctx.getImageData(0, 0, w, h)
const result = await promiseWorker.postMessage(imageData, [imageData.data.buffer])
console.log(imageData.data.buffer.byteLength) // 0 - detachedAfter a transfer the sender's buffer is detached and its byteLength is 0. Reading it afterwards throws, so transfer only what you are done with.
Return a buffer without copying it backtransfer-out-of-worker
// worker.js
registerPromiseWorker((message, withTransferList) => {
const out = process(message)
return withTransferList(out, [out.data.buffer])
})withTransferList is the second argument to your handler. Returning it from inside a promise chain works too, which is how async work keeps the transfer.
Dispatch on a type fieldroute-message-types
// worker.js
registerPromiseWorker((message, withTransferList) => {
switch (message.type) {
case 'resize': return withTransferList(resize(message), [message.buffer])
case 'stats': return computeStats(message)
default: throw new Error(`unknown message type: ${message.type}`)
}
})There is one handler, so routing is yours. A thrown error here is caught and sent back as a message string, not as a worker error event.
Carry error detail yourselferror-detail-loss
// worker.js
registerPromiseWorker(async (message) => {
try {
return { ok: true, value: await work(message) }
} catch (err) {
return { ok: false, code: err.code, message: err.message }
}
})
// main.js
const res = await promiseWorker.postMessage(job)
if (!res.ok) handle(res.code)Rejecting loses everything except the message string, and the worker logs to console.error either way. Returning a result object keeps the fields you need.
Stop waiting on a worker that never answersadd-a-timeout
function withTimeout (promise, ms) {
let timer
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error('worker timeout')), ms)
})
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer))
}
const result = await withTimeout(promiseWorker.postMessage(job), 10000)The library has no timeout. Racing does not cancel the worker or free its callback entry, so pair this with terminate and a fresh worker when it fires.
Tear a worker down without leaving promises hangingterminate-safely
const pending = new Set()
function send (message, transferList) {
const p = promiseWorker.postMessage(message, transferList)
pending.add(p)
return p.finally(() => pending.delete(p))
}
function shutdown () {
promiseWorker.terminate() // undocumented, forwards to worker.terminate()
pending.clear() // those promises will never settle
}terminate() does not reject in-flight calls. Track them yourself, or every caller awaiting one is stuck for the life of the page.
Always pass a transfer list on the Service Worker pathservice-worker-transfer-list
// Service Worker branch builds: [channel.port2].concat(transferList)
// concat appends a non-array argument, so omitting it yields
// [port2, undefined] rather than [port2].
await promiseWorker.postMessage(message, []) // safe
// await promiseWorker.postMessage(message) // avoid on this pathOnly the controller path is affected; a plain Web Worker passes the value through to postMessage untouched. An explicit empty array costs nothing on either.
Wire up a Service Worker once it controls the pagewait-for-controller
await navigator.serviceWorker.register('sw.js', { scope: './' })
const ready = navigator.serviceWorker.controller
? navigator.serviceWorker
: await new Promise((resolve) => {
navigator.serviceWorker.addEventListener('controllerchange', function once () {
navigator.serviceWorker.removeEventListener('controllerchange', once)
resolve(navigator.serviceWorker)
})
})
const promiseWorker = new PromiseWorker(ready)Registering is not enough; there is no controller until a service worker takes over the page. Call clients.claim() in the worker's activate event to speed that up.
Run several PromiseWorkers over one Workershare-one-worker
const worker = new Worker('worker.js')
const a = new PromiseWorker(worker)
const b = new PromiseWorker(worker)
await Promise.all([a.postMessage({ type: 'x' }), b.postMessage({ type: 'y' })])Message ids come from one module-level counter and each instance ignores replies it has no callback for, so this is safe. Messages that do not match the [id, payload] shape are ignored entirely.
Move to a maintained worker bridgemigrate-to-comlink
// worker.js
import * as Comlink from 'comlink'
Comlink.expose({
resize (buffer, w, h) {
const out = resize(buffer, w, h)
return Comlink.transfer(out, [out.buffer])
},
})
// main.js
const api = Comlink.wrap(new Worker('worker.js', { type: 'module' }))
const out = await api.resize(Comlink.transfer(buf, [buf]), 800, 600)Comlink keeps the transfer semantics and gives you method calls instead of one message handler, plus ESM and TypeScript types. Errors still lose their stacks across the boundary.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| comlink | npm | You want worker calls to look like ordinary method calls with proxies, plus explicit transfer support, from a maintained project |
| workerpool | npm | You need a pool of workers with queuing, cancellation and timeouts rather than one worker behind one promise, in the browser or in Node |
| threads | npm | You want typed worker APIs with observables and pooling and are working in TypeScript |
| promise-worker | npm | You do not need transferables and would rather run the upstream this was forked from, which keeps the message-stringifying optimisation this fork removed |