mrkeyoor.com_
Sat 08 Aug 22:54 UTC
npmWeb Frontendupdated 08 Aug 2026

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.

Verdict

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.

API stability4/5Three published versions, all from September 2016, and nothing since, so the constructor, postMessage with its optional transfer list, the register function and the withTransferList wrapper are permanent. Code written against 1.0.4 nine years ago behaves the same today. The mark against it is that the surface is not fully described: terminate() exists in index.js but appears nowhere in the README, so a documented-only reading of the API is incomplete in both directions.
Docs3/5The README is inherited from promise-worker and adapted well: it shows both sides of the wiring, the transfer list on send and receive, promise returns from the worker, error propagation, multi-type message routing and a full Service Worker example including waiting for the controller. It is honest that stack traces cannot cross the boundary and that performance may be worse for non-transferable messages. What is missing is everything about lifecycle: terminate, timeouts, what happens when a worker dies, and the detaching semantics of a transferred buffer.
Maintenance1/5Versions 1.0.2, 1.0.3 and 1.0.4 were all published between 22 and 23 September 2016, and there has been no release since. The repository is not archived but was last pushed on 2020-07-01, has 11 stars, 2 forks and no open issues. Its upstream, promise-worker, last published 2.0.1 in March 2019. Neither project is being developed, and the 3.8 million weekly downloads here come from dependency trees rather than from anyone choosing it recently.
Ecosystem2/5The download count is a poor guide: this fork reports around 3.8 million weekly installs against roughly 26,000 for the upstream it copies, which points at a small number of widely depended-on packages pulling it in rather than broad direct adoption. There are no TypeScript declarations, no ESM build, no framework bindings and no plugins. Worker tooling has moved to Comlink and to bundler-native worker syntax, neither of which shares an interface with this, so nothing here transfers.

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
Skip it if

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 - detached

After 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 path

Only 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

PackageRegistryPick it when
comlinknpmYou want worker calls to look like ordinary method calls with proxies, plus explicit transfer support, from a maintained project
workerpoolnpmYou need a pool of workers with queuing, cancellation and timeouts rather than one worker behind one promise, in the browser or in Node
threadsnpmYou want typed worker APIs with observables and pooling and are working in TypeScript
promise-workernpmYou do not need transferables and would rather run the upstream this was forked from, which keeps the message-stringifying optimisation this fork removed