message-queue
message-queue 1.0.0

In-memory message queue with FIFO ordering, grouped batching, deduplication, retries, timeouts and persistence hooks.

Contents

Contents

message-queue

An in-memory message queue with FIFO ordering, grouped batching, deduplication, retries, processing timeouts, delayed delivery and persistence hooks — SQS-flavoured semantics for the browser and Node.

Built for the work you want done eventually, in order, once per payload: syncing edits to a server, sending notifications, draining user actions against an API. You enqueue messages; handlers (workers) pull them one unit at a time. A queue's flavour is fixed at creation — a plain queue delivers single messages and has no groupId at all, a grouped queue requires a groupId on every message and delivers whole batches. A message re-sent with the same deduplicationId replaces the queued one; failures are retried until maxAttempts; runaway handlers are cut off by maxProcessingTime; and lifecycle hooks let you mirror the queue into a DB or storage so nothing is lost on restart.

strict FIFO order grouped batching deduplication retries & timeouts persistence hooks framework-free
A handler answers with a boolean

Every delivery must be answered: return true to consume it, false to send it back for another attempt. A thrown error or a rejected promise counts as false. There is no third state — a handler that never settles holds its worker slot until maxProcessingTime (if set) cuts the attempt off.

Getting started

Install#

sh
npm i @dmytromykhailiuk/message-queue
Requirements

No framework, no DOM — plain TypeScript that works in any browser and in Node ≥ 18. Ships ESM and CJS with type declarations for both. One runtime dependency: @dmytromykhailiuk/execution-blocker, which serializes queue mutation against delivery.

Quick start#

Create a queue once at module scope, register a handler, enqueue messages. The handler is called with one unit at a time and answers with a boolean:

tsa plain queue — single messages
import { createMessageQueue } from "@dmytromykhailiuk/message-queue";

const queue = createMessageQueue<{ url: string }>({
  maxAttempts: 3,
  maxProcessingTime: 10_000,
  onError: (reason, input) => console.error(reason, input),
});

// true consumes the delivery, false (or a throw) retries it.
queue.addHandler(async ({ message, attempt }) => sendOne(message.data));

await queue.addMessage({ data: { url: "/sync/1" } });
await queue.addMessage({ data: { url: "/sync/2" } }, 5000); // enqueued in 5s

The grouped flavour is selected at creation — every message must name its group, and handlers receive whole batches:

tsa grouped queue — batches
const digests = createMessageQueue<AppEvent>({ grouped: true });

digests.addHandler(async ({ messages }) =>
  sendDigest(messages.map((m) => m.data)), // everything queued for the group
);

await digests.addMessage({ data: event, groupId: `user:${userId}` });

Messages are delivered in the order they were enqueued. Add more handlers to process independent units concurrently — see Concurrency.

Two flavours, one model#

The queue does not schedule raw messages — it schedules units of work. A unit is what one handler call receives, and its shape is decided once, when the queue is created:

Flavour You enqueue The unit The handler receives
createMessageQueue() { data, deduplicationId? } one message { message, attempt }
createMessageQueue({ grouped: true }) { data, groupId, deduplicationId? } every queued message of that groupId { messages, attempt }

The flavour is not a per-message choice: a plain queue's addMessage has no groupId parameter (a smuggled one is a TypeError), and a grouped queue rejects any message without a non-empty groupId. Handlers never need to ask "is this a batch?" — the queue's type already answers it.

Units line up FIFO: whoever entered the queue first is delivered first. A unit that fails goes to the back of the line. Three rules follow from the model:

  • One unit — one handler call. A group of ten messages is a single delivery to a single handler, not ten.
  • Units are independent. Two different groups — or two unrelated plain messages — can be processed by two handlers at the same time.
  • Keys share one namespace. Inside one queue the unit id is the groupId (grouped flavour), else the deduplicationId, else the message's own id — use distinct strings for distinct things.

Declare the queue in one module and import it everywhere work is produced or consumed:

tsqueue.ts — one instance per domain
import { createMessageQueue } from "@dmytromykhailiuk/message-queue";

export const syncQueue = createMessageQueue<SyncJob>({ maxAttempts: 5 });

// anywhere else in the app
import { syncQueue } from "./queue";
await syncQueue.addMessage({ data: job });

Reference

createMessageQueue#

ts
function createMessageQueue<T>(options: GroupedQueueOptions<T>): GroupedMessageQueue<T>;
function createMessageQueue<T>(options?: QueueOptions<T>): MessageQueue<T>;

Returns a frozen queue with no messages and no handlers. The overload is picked by the grouped option, and it decides every type downstream — addMessage, the handler input, the hooks and onError. Each call is an isolated world — two queues never see each other's messages or workers. Invalid options (maxAttempts or maxProcessingTime that is not a positive number) throw a TypeError immediately.

Options#

groupedboolean — optional, default false

true selects the grouped flavour: every message must carry a groupId and handlers receive whole batches. Fixed at creation — there is no per-message opt-in.

maxAttemptsnumber — optional

Give up on a unit after this many failed attempts and report "Max attempts exceeded" to onError. Omit for unlimited retries. See Retries & giving up.

maxProcessingTimenumber (ms) — optional

Treat an attempt as failed when the handler has not settled within this many milliseconds, and report "Max processing time exceeded". The late result of the timed-out handler is ignored. See Processing timeouts.

onError(reason: ErrorReason, data) => void — optional

Called when the queue times out an attempt or gives up on a unit. reason is "Max processing time exceeded" or "Max attempts exceeded"; data is the exact input the handler received (HandlerInput or GroupedHandlerInput, by flavour).

onQueueCreated(queue) => void — optional

Called synchronously with the queue right after it is created — the place to rehydrate persisted messages back into it.

onMessageAdded(message) => void — optional

Called every time a message actually enters the queue (after its delay, after deduplication replacement) — the place to persist it.

onMessageHandled(input) => void — optional

Called after a delivery is consumed (the handler returned true) — the place to remove it from storage. A unit dropped by maxAttempts fires onError, not this hook.

The queue object#

Everything below is a stable reference on a frozen object — safe to destructure, impossible to monkey-patch.

addMessage(message, delayTime?: number) => Promise<QueueMessage | GroupedQueueMessage>

Enqueue a message — groupId required on a grouped queue, absent on a plain one. Resolves with the enqueued form (queue id and timestamp included). See addMessage.

addHandler(handler: Handler<T> | GroupedHandler<T>) => () => void

Register a worker; returns its unsubscribe function. See Handlers.

size() => number

How many units are waiting for a handler. A ten-message group counts as one.

inFlight() => number

How many units are being processed right now.

Probes are for observing

size() and inFlight() are snapshots for badges and debug overlays. Don't build control flow on them — the answer can change between the check and your next line.

addMessage#

ts
// plain queue
addMessage(
  message: { data: T; deduplicationId?: string },
  delayTime?: number,
): Promise<QueueMessage<T>>;

// grouped queue — groupId is required
addMessage(
  message: { data: T; groupId: string; deduplicationId?: string },
  delayTime?: number,
): Promise<GroupedQueueMessage<T>>;

Enqueues data and resolves once the message is actually in the queue — with the enqueued form, which adds two fields the queue assigned:

ts
const message = await queue.addMessage({ data: { url: "/sync" } });

message.id;        // unique id assigned by the queue
message.createdAt; // ISO-8601 timestamp of enqueueing
message.data;      // your payload, unchanged

On a grouped queue the groupId names the batch the message joins — it is required, and an empty string is rejected. On a plain queue the parameter does not exist, and a smuggled one is a runtime TypeError. deduplicationId makes the message replaceable while it waits. All keys are plain strings — build them from your domain (`user:${id}`, `doc:${path}`).

Delayed delivery#

With delayTime the message enters the queue only after that many milliseconds. Until then it is invisible — size() does not count it, deduplication does not see it, and messages enqueued in the meantime go first:

ts
void queue.addMessage({ data: retryJob }, 30_000); // try again in 30s
await queue.addMessage({ data: urgentJob });       // delivered first

// addMessage resolves AFTER the delay — await it only when you want to
// wait for the enqueueing itself.
Delay, then FIFO

The delay orders the enqueueing, not the delivery: once the timer fires the message joins the back of the queue and normal FIFO takes over from there.

Handlers#

ts
const unsubscribe = queue.addHandler(async (input) => {
  // …process input…
  return true; // consumed; false (or a throw) retries the unit
});

unsubscribe(); // in-flight work finishes; nothing new is delivered

A handler is a worker: while it is processing one unit, the queue does not hand it another. Handlers can be added before or after messages — a handler registered late simply drains the backlog. Adding the same function twice registers it once; unsubscribing is idempotent.

The handler input#

The input's shape follows the queue's flavour — there is no runtime discriminator to check, because a queue only ever delivers one shape:

ts
// plain queue
interface HandlerInput<T> {
  message: QueueMessage<T>;
  attempt: number;
}

// grouped queue — the whole batch, in arrival order
interface GroupedHandlerInput<T> {
  messages: GroupedQueueMessage<T>[];
  attempt: number;
}

queue.addHandler(async ({ message, attempt }) => sendOne(message));
digests.addHandler(async ({ messages }) => sendBatch(messages));

attempt is 1-based and counts deliveries of the unit: the first delivery is attempt 1, the first retry attempt 2, and so on. It resets when the unit is consumed or dropped.

Concurrency#

The number of handlers is the queue's concurrency. One handler means strictly sequential processing; N handlers mean up to N independent units in flight at once — messages of one group still never split across workers:

tsa worker pool is just addHandler in a loop
const stopAll = Array.from({ length: 4 }, () =>
  queue.addHandler(processJob),
);

// later: wind the pool down
for (const stop of stopAll) stop();
Same function, one registration

Handlers are tracked by function identity, so Array.from({ length: 4 }, () => queue.addHandler(processJob)) registers one worker, not four. Wrap the call — queue.addHandler((input) => processJob(input)) — to give each worker its own identity.

Grouped queues#

A queue created with { grouped: true } batches by groupId — which every message must carry. Messages that share a groupId travel as one unit: the queue delivers every queued message of the group together, as a single batch, to a single handler — in arrival order:

ts
const queue = createMessageQueue<Email>({ grouped: true });

await queue.addMessage({ data: emailA, groupId: "user:42" });
await queue.addMessage({ data: emailB, groupId: "user:42" });

// one delivery: { messages: [emailA, emailB], attempt: 1 }
await queue.addMessage({ data: emailC }); // ✗ rejects — groupId is required

The batch keeps growing while it waits — every addMessage with the same groupId merges in. Success (true) consumes the whole batch; failure sends the whole batch — including anything that joined in the meantime — to the back of the queue for another attempt.

Different groups are different units: with two handlers, "user:42" and "user:7" are processed in parallel, while messages inside each group stay batched.

Locked while processed#

While a batch is in a handler, its group is locked: addMessage for the same group waits until the attempt settles, then merges. A message added mid-flight can therefore never be swallowed by the current batch's success — it always lands in the next batch:

tsnothing falls between the cracks
// batch ["a", "b"] is being processed…
void queue.addMessage({ data: "c", groupId: "g" }); // waits at the lock

// …the batch succeeds and is consumed. Only then does "c" enter:
// next delivery is { messages: ["c"], attempt: 1 }
Don't await addMessage from inside the same unit's handler

The lock that protects the batch also means: a handler that awaits addMessage() for the unit it is currently processing waits for itself — a deadlock (until maxProcessingTime, if set, cuts the attempt off). Enqueue without awaiting — void queue.addMessage(…) — and the message merges as soon as the attempt settles. Enqueueing for other units is safe to await.

Deduplication#

A message with a deduplicationId occupies one slot in the queue. Enqueue again with the same id while the first is still waiting, and the new payload replaces the old one — same slot, same position in line, one delivery:

ts
await queue.addMessage({ data: draftV1, deduplicationId: "doc:7" });
await queue.addMessage({ data: draftV2, deduplicationId: "doc:7" });
await queue.addMessage({ data: draftV3, deduplicationId: "doc:7" });

queue.size(); // 1 — one slot, latest payload
// the handler receives draftV3, once

Deduplication spans exactly the waiting time. Once the message is consumed (or dropped after maxAttempts), the id is free again — the next addMessage with it starts a fresh unit with attempt: 1.

Inside a grouped queue, deduplication works per batch entry: the newer message replaces the older one in the batch and moves to the batch's end, while other entries keep their places.

Latest-wins, not first-wins

This is the "collapse redundant work" flavour of deduplication: the queue keeps the newest payload, because a fresher autosave or sync request supersedes the one it replaces. If you need first-wins semantics, check size()-independent app state before enqueueing.

Retries & giving up#

An attempt fails when the handler returns false, throws, rejects — or exceeds maxProcessingTime. A failed unit goes to the back of the queue and is redelivered with attempt + 1; other units are not blocked by it:

ts
queue.addHandler(async ({ attempt, ...input }) => {
  console.log(attempt); // 1, then 2, then 3…
  return trySend(input);
});

// delivery order with a failing "a": a#1, b#1, c#1, a#2, a#3, …

With maxAttempts the queue eventually gives up: the unit is dropped after the last failed attempt and onError receives "Max attempts exceeded" together with the exact input of that final delivery:

ts
const queue = createMessageQueue<Job>({
  maxAttempts: 3,
  onError: (reason, input) => {
    if (reason === "Max attempts exceeded") {
      deadLetter.push(input); // your dead-letter strategy
    }
  },
});
No maxAttempts means forever

Without maxAttempts a permanently failing unit retries indefinitely — it won't block other units, but it will never leave the queue either. Set maxAttempts and handle onError when "give up eventually" is part of your semantics.

Errors are answers, not events

The queue never rethrows what a handler throws — a thrown error is simply a failed attempt. Log inside the handler if you need the error itself; onError tells you only that the queue timed out an attempt or gave up on a unit.

Processing timeouts#

maxProcessingTime puts an upper bound on one attempt. When a handler has not settled within the limit, the attempt fails: onError fires with "Max processing time exceeded", the unit rejoins the queue by the usual retry rules, and the worker slot is freed so the queue keeps moving:

ts
const queue = createMessageQueue<Job>({
  maxProcessingTime: 10_000, // an attempt may take at most 10s
  maxAttempts: 3,            // timeouts count as failed attempts
  onError: (reason) => console.warn(reason),
});

Two consequences worth knowing:

  • The late result is ignored. The queue cannot cancel your function — a timed-out handler keeps running, but whatever it eventually returns (or throws) changes nothing: the attempt already failed, and a late rejection is swallowed, never an unhandled rejection.
  • The worker returns to the pool immediately. The next delivery may reach that handler while its timed-out body is still running. If the handler touches shared state, make it safe to overlap with itself — or don't set maxProcessingTime and keep the strict one-at-a-time guarantee.
A timeout is a deadline, not a cancellation

If the underlying work must actually stop, wire cancellation yourself — pass an AbortSignal into the work and abort it on a timer shorter than maxProcessingTime. The queue's timeout only decides how long it waits for an answer.

Persistence hooks#

An in-memory queue forgets everything on reload — the hooks are its storage seam. Three callbacks cover the lifecycle of a message, and together with onError they are enough to mirror the queue into localStorage, IndexedDB or a server:

Hook Fires Storage move
onQueueCreated(queue) synchronously, right after creation read storage, re-enqueue what was left
onMessageAdded(message) when a message actually enters the queue write the message
onMessageHandled(input) when a delivery is consumed delete its message(s)
onError(reason, input) on timeout / on giving up on "Max attempts exceeded": delete + dead-letter

Hooks are observers, not middleware: they are called synchronously, they cannot veto or transform anything, and a hook that throws is contained — reported via console.error, never allowed to break the queue. Their argument types follow the queue's flavour, like everything else.

  • onMessageAdded waits for the delay. A message enqueued with delayTime is persisted when it actually enters the queue, not at the call.
  • Replacement fires it again. A deduplication replacement is a new onMessageAdded with the new message — storage keyed by deduplicationId overwrites naturally.
  • Failed attempts fire nothing. onMessageHandled only reports consumption; a retrying unit stays in storage until it is handled or dead-lettered.

A storage recipe#

Key entries by deduplicationId || id — the same key the queue deduplicates by — and the four callbacks keep storage exactly in sync with the queue:

tsmessages survive a page reload
const key = (m: { deduplicationId?: string; id: string }) =>
  m.deduplicationId || m.id;

const queue = createMessageQueue<Job>({
  maxAttempts: 5,

  onQueueCreated: (q) => {
    for (const saved of db.readAll()) {
      void q.addMessage({ data: saved.data, deduplicationId: saved.deduplicationId });
    }
    db.clear(); // onMessageAdded re-persists them under fresh ids
  },

  onMessageAdded: (message) => db.put(key(message), message),
  onMessageHandled: (input) => db.delete(key(input.message)),

  onError: (reason, input) => {
    if (reason === "Max attempts exceeded") {
      db.delete(key(input.message)); // dead-letter instead of retrying forever
    }
  },
});
Persist the payload, not the promise

What you can safely store is the message — data, deduplicationId, groupId. Attempt counters and in-flight state are the queue's own business: a restored message always starts fresh at attempt: 1, which is exactly what you want after a crash.

Patterns#

Autosave that never floods#

Fire an autosave on every keystroke; deduplication collapses the backlog so the server sees at most one in-flight save plus one queued — always the newest:

ts
const queue = createMessageQueue<Doc>();

const autosave = (doc: Doc) =>
  void queue.addMessage({
    data: doc,
    deduplicationId: `save:${doc.id}`, // newer drafts replace queued ones
  });

queue.addHandler(async ({ message }) => api.save(message.data));

Batch per entity#

Group by entity id and each delivery is "everything pending for this entity" — one network call instead of N, while different entities still process in parallel:

ts
const queue = createMessageQueue<AppEvent>({ grouped: true });

const notify = (userId: string, event: AppEvent) =>
  void queue.addMessage({ data: event, groupId: `notify:${userId}` });

queue.addHandler(async ({ messages }) => {
  const events = messages.map((m) => m.data);
  return api.sendDigest(events); // one digest email per user
});

Scheduled retry with backoff#

The built-in retry redelivers as soon as a worker is free. For spaced-out retries, consume the failure yourself and re-enqueue with a delay:

ts
queue.addHandler(async ({ message }) => {
  try {
    await api.send(message.data);
  } catch {
    const wait = 2 ** message.data.tries * 1000; // 1s, 2s, 4s…
    void queue.addMessage(
      { data: { ...message.data, tries: message.data.tries + 1 } },
      wait,
    );
  }
  return true; // always consume — the re-enqueue IS the retry
});

A bounded worker pool#

N wrapped handlers give you "at most N requests in flight" for free — enqueue as fast as you like, the pool drains at its own pace:

ts
const stops = Array.from({ length: 3 }, () =>
  queue.addHandler((input) => upload(input)), // wrapper per worker
);

for (const file of files) void queue.addMessage({ data: file });
This is a queue, not a scheduler

The library orders, batches, collapses and retries — it does not debounce, prioritise, or cancel. Those belong a layer above: debounce before enqueueing, put priorities into separate queues, abort inside the handler.

TypeScript#

The queue is generic over the payload, and the grouped option picks the flavour end to end — addMessage, the handler input, the hooks and onError all agree, and mixing flavours is a compile error:

ts
interface Job {
  url: string;
  tries: number;
}

const plain = createMessageQueue<Job>();          // MessageQueue<Job>
const message = await plain.addMessage({ data }); // QueueMessage<Job>
plain.addMessage({ data, groupId: "g" });         // ✗ no groupId on a plain queue

const grouped = createMessageQueue<Job>({ grouped: true }); // GroupedMessageQueue<Job>
grouped.addMessage({ data });                     // ✗ groupId is required
grouped.addHandler(({ messages }) => {
  messages;                                       // GroupedQueueMessage<Job>[]
  return true;
});

The queue object is frozen (Object.freeze), so its methods cannot be reassigned — what you export from queue.ts is exactly what every consumer gets.

Exports#

Values

createMessageQueue

Types

MessageQueue · GroupedMessageQueue · Message · GroupedMessage · QueueMessage · GroupedQueueMessage · Handler · GroupedHandler · HandlerInput · GroupedHandlerInput · QueueOptions · GroupedQueueOptions · ErrorReason