The API is the product

Most job queues make the call site describe their infrastructure:

await queue.add('sendEmail', input);

Enqiu makes a job feel like the function it represents:

const { jobs, queue, worker, bull, close } = enqiu(definitions, { connection });

const delivery = await jobs.sendEmail(input);
const result = await delivery.result;

The job name comes from the object key. TypeScript infers the input and result from the handler or its Standard Schema, so consumers do not declare a second type or keep a string name in sync.

Submitting and completing are deliberately separate. The first await confirms that the queue accepted the work and returns a handle; handle.result waits for the worker only when the caller actually needs its result. Ignoring the handle is safe and does not create an unhandled rejected promise.

It stopped being a queue

The first release shipped first-party in-memory and Redis drivers — about four thousand lines including seven hundred of Lua. Benchmarked against BullMQ on the same Redis, it reached roughly 9,800 jobs/sec against BullMQ's 12,900. Closing that gap meant batching claims, running scripts by hash and trimming round trips, and the remaining 1.3× turned out to be the cost of maintaining more indexes than BullMQ OSS does — a design trade, not an inefficiency.

Which raised the real question: what was the storage engine for? The part worth having was the typed call site, and that part does not need its own queue.

So Enqiu is now a layer over BullMQ. BullMQ owns storage, scheduling, retries, crash recovery and execution — code that is mature and widely deployed. Enqiu owns the developer experience. src/ is under two thousand lines including its comments and type declarations, about a thousand of which is code that runs; the hard parts became someone else's problem in the good sense.

What the layer actually adds

Five things, and nothing that BullMQ already does:

  • Inferred types end to end. BullMQ's own generics are per queueQueue<DataType, ResultType, NameType> — so with two job types on one queue the payload becomes a union and nothing correlates a name with its own data. Passing resizeImage's payload to sendEmail compiles there. Enqiu makes it a compile error, and there is a @ts-expect-error in the suite that fails the build if it ever stops being one.
  • Standard Schema validation at the boundary. Zod, Valibot, ArkType — anything implementing the spec. Invalid input is rejected before a job is queued.
  • A per-attempt timeout and expiresIn. BullMQ has neither. The handler gets an AbortSignal that fires on the deadline or on a cancellation.
  • A cancelled status. BullMQ has no state for it, and cancelling a job that has not started removes it — so the evidence is gone and refresh() cannot tell "cancelled" from "never existed". Enqiu records the finished snapshot before the removal, durably, so another process sees the same answer.
  • Failures that survive as classes. BullMQ hands a failure to another process as a single string. A timeout or an expiry writes its kind into that string, so handle.result rejects with JobTimeoutError rather than a bare Error. The first version recovered the kind by pattern-matching the error message, which meant rewording a sentence silently changed what callers caught.

Everything else is one property away

bull.queue    // the real BullMQ Queue
bull.worker   // the real BullMQ Worker

Flows, Pro groups, metrics, raw job options, pausing, global concurrency, BullMQ's own events — all reachable, with no wrapper in between and no fork required. The rule the library follows is that anything BullMQ already exposes does not get a second name here; a queue.pause() that forwards to bull.queue.pause() is one more thing to learn and nothing else.

Enqiu also reads its own state from those objects rather than mirroring it. Pause the worker through bull and worker.running reports false, because it asks BullMQ instead of keeping a flag. The two cannot drift apart.

The cost of the layer

Measured against raw BullMQ on the same Redis — 10,000 jobs, concurrency 32, contestants interleaved, median of seven runs — the typed path costs about 2%, and Zod validation about 3%. Calls through bull cost nothing, because nothing is in the way.

Getting an honest number took two attempts. The first benchmark handed raw BullMQ a single shared Redis connection for both its Queue and its Worker while Enqiu let BullMQ manage its own, and ran each contestant to completion in turn so whoever went first absorbed all the JIT warmup. Both flaws pushed the same way: at one point every Enqiu variant "beat" raw BullMQ, which is impossible for a wrapper. Fixing the connection handling and interleaving the runs produced numbers that are merely good instead of flattering.

Hono without duplicate validation

Every schema-backed job exposes its input schema, so a route can validate with the same object the worker uses:

app.post(
  '/emails',
  sValidator('json', jobs.sendEmail.input),
  async (c) => {
    const handle = await jobs.sendEmail(c.req.valid('json'));
    return c.json({ id: handle.id }, 202);
  },
);

Hono and the schema library stay optional application dependencies. bullmq and ioredis are peer dependencies — Enqiu does not pick versions or open connections for you.

Why the handle is explicit

Queue APIs often collapse three different events into one promise: the request was accepted, a worker started it, and the work finished. That is convenient until an HTTP route only needs to acknowledge submission, or a batch process wants to enqueue thousands of items without waiting for each result.

Enqiu keeps those moments separate. Calling a job confirms that it entered the queue and returns a handle. An API can return 202 with the ID, a command can wait for completion, and a fire-and-forget path can ignore the result without changing how the job is defined.

The handle deliberately has no status field. It could only report what was true when the handle was created, which is a value that goes quietly wrong while you hold it. refresh() answers at the moment you ask.

Trade-offs

Gaps in BullMQ's open-source tier are left as gaps rather than faked. Per-key concurrency and per-key rate limiting are BullMQ Pro features; the OSS limiter is one global { max, duration } per worker. Debounce has no open-source equivalent. If you need those, use BullMQ Pro directly.

The in-memory driver is gone, and with it browser support — BullMQ requires Redis and Node. Pin enqiu@0.2.x if you need the old drivers, understanding that they are no longer where the work is going.

Where it fits

Application-level background work: email delivery, imports, media processing, scheduled cleanup, webhooks, and anything needing retries or concurrency limits. It is not trying to replace a streaming log or a cross-company event bus.

The useful thing is narrower than the first version aimed at, and better for it: a typed job call over a queue that already works.