Heximon Logo
Capabilities

Queue

Move slow work off the request path onto durable channels — the Queue producer port, QueueEventHandler consumers under queue { handlers }, per-handler retry and payload validation, MemoryQueue in dev, and a durable transport per platform.

Move slow work off the request path. Your code publishes a message onto a named channel and returns right away; a consumer picks it up later and runs your handler — with automatic retry and optional payload validation. The channel is durable, so the work survives the request that triggered it having already returned.

The same handler code runs on every backend: an in-process queue while you develop, a durable platform queue in production. You swap the transport, not your handlers.

The shape

A queue is one idea with three moving parts: a producer publishes, a transport holds the message, and a consumer drains it back into your handlers.

 your code
    │
    │  queue.publish("orders-placed", payload)      returns immediately — fire-and-forget
    ▼
 ┌──────────────────────────── transport ─────────────────────────────┐
 │  dev / test    MemoryQueue             in-process, drains this tick│
 │  production    a durable queue         one per platform (see table)│
 └────────────────────────────────────────────────────────────────────┘
    │  delivered later
    ▼
 consumer  ──►  QueueChannelDispatcher  ──►  every handler bound to the channel
                                             (each runs with retry + optional validation)
    ▼
 class OrderPlacedHandler implements QueueEventHandler<"orders-placed", OrderPlaced> {
   handle(payload) { … }
 }

Everything below the publish line is the framework's job — you write only the producer call, the handler, and one line of module config.

Publish onto a channel

Channels and their payloads live in one typed map. Extending QueueEventTypes makes the map the contract both sides share, so publish rejects an unknown channel or a mismatched payload at compile time.

orders/order-events.ts
import type { QueueEventTypes } from "@heximon/queue";

export interface OrderPlaced {
  readonly orderId: string;
  readonly sku: string;
  readonly quantity: number;
}

export interface OrderEventMap extends QueueEventTypes {
  readonly "orders-placed": OrderPlaced;
}

Inject your typed queue producer and call publish. It is fire-and-forget: it enqueues the message and returns, so the request never waits on the slow part.

orders/orders.controller.ts
export class OrdersController implements Controller<"/orders"> {
  public constructor(private readonly queue: OrderQueue /* …, log: OrderLog */) {}

  public async place(action: Post<"/">): Promise<{ orderId: string; queued: true }> {
    const body = (await action.request.json()) as PlaceOrderBody;
    const orderId = uuid.v7();

    this.queue.publish("orders-placed", { orderId, sku: body.sku, quantity: body.quantity });

    action.response.status = 202;

    return { orderId, queued: true };
  }
}

OrderQueue is your producer — a one-line subclass of the transport that fixes its channel map (shown in the next section). The 202 comes back before the handler runs.

Consume a channel

A consumer is a class that declares implements QueueEventHandler<channel, Payload> and a single handle(payload). Its constructor is its dependency list — inject whatever it needs. The compiler reads the channel from the first type argument and routes every message on it to this handler.

orders/order-placed.handler.ts
import type { QueueEventHandler } from "@heximon/queue";
import type { OrderPlaced } from "./order-events";
import { OrderLog } from "./order-log";

export class OrderPlacedHandler implements QueueEventHandler<"orders-placed", OrderPlaced> {
  public constructor(private readonly log: OrderLog) {}

  public async handle(payload: OrderPlaced): Promise<void> {
    this.log.record(payload);
    await Promise.resolve();
  }
}

The channel must be a string literal — the compiler turns it into the dispatch key at build time. Several handlers may bind the same channel; each runs when the channel drains. (extends QueueEventHandler<…> binds identically — use it when you want your own base class on top of a handler.)

Wire it into a module

List handlers under the module's queue: { handlers } key — never in providers. That's how the compiler knows to route each class to its channel instead of treating it as a plain injectable. The producer transport is bound in providers; anything a handler depends on stays an ordinary provider too.

app.module.ts
export class AppModule extends Module({
  providers: [
    OrderLog,
    NetlifyAsyncWorkloadsPlatformSimulator,
    {
      // In production: `new OrderQueue()`, and the platform delivers. In dev/CI: publish through an
      // in-process stand-in so the same producer + consumer run locally, no platform required.
      provide: OrderQueue,
      useFactory: (simulator: NetlifyAsyncWorkloadsPlatformSimulator): OrderQueue =>
        new OrderQueue(simulator.netlifyClient),
    },
  ],
  http: { controllers: [OrdersController] },
  queue: { handlers: [OrderPlacedHandler] },
}) {}

The producer itself is a one-liner — a named subclass of the transport that pins the channel map, so publish("orders-placed", …) stays statically checked:

orders/order-queue.ts
import { NetlifyAsyncWorkloadsQueue } from "@heximon/queue/netlify";
import type { OrderEventMap } from "./order-events";

export class OrderQueue extends NetlifyAsyncWorkloadsQueue<OrderEventMap> {}

Enable the tier by adding new QueuePlugin() (from @heximon/queue/compiler) to your heximon.config.ts plugins list. A class listed under queue: { handlers } whose heritage names no QueueEventHandler is constructed but never fires — an almost-always-missing implements QueueEventHandler<…> clause.

Retry and validate

Delivery is at-least-once, so handlers should tolerate a retry. Opt into a per-handler retry policy with an optional third type argument — QueueEventHandler<"orders-placed", OrderPlaced, { retry: { attempts: 3, delay: 200 } }>, where attempts counts the first try and delay is milliseconds. Omit it to keep the default (retry contention only).

Opt into runtime payload validation by overriding getPayloadSchema() to return any Standard Schema (zod, valibot, arktype). It runs once, before handle, and outside the retry loop — so a malformed payload is rejected instead of retried.

class ValidatingWarmer extends QueueEventHandler<"cache.warm", { id: number }> {
  public override handle(): void {}

  public override getPayloadSchema(): StandardSchemaV1<{ id: number }> {
    return idSchema;
  }
}

Dedup redeliveries

Because delivery is at-least-once, a message can arrive twice. If handling it isn't naturally idempotent, add QueueIdempotencyInterceptor from @heximon/queue/idempotency under queue: { interceptors } — it dedups on the stable envelope id over a Storage backend, so a redelivery is dropped instead of re-run. See Idempotency for the full picture.

Production transports

The dev transport is MemoryQueue, in-process and zero-config. For production, bind the durable transport for where you deploy — the handler and module wiring don't change, only the producer subclass does. Push transports let the platform deliver each message (no process to run); poll transports run a drain loop inside a long-lived Node/Bun/Deno process.

PlatformSubpathDelivery
Cloudflare Workers@heximon/queue/cloudflarepush — the platform delivers a batch to the worker
Vercel Functions@heximon/queue/vercelpush — to a dedicated function
Netlify Functions@heximon/queue/netlifypush — to a generated workload consumer
SQL (pg / mysql / sqlite)@heximon/queue/drizzlepoll — a long-lived process; FOR UPDATE SKIP LOCKED on pg/mysql
AWS SQS@heximon/queue/sqspoll — long-polling consumer (also a push consumer for SQS-triggered Lambda)
Upstash Redis Streams@heximon/queue/upstashpoll — a long-lived process; XREADGROUP / XACK

The SQL, SQS, and Upstash producers run anywhere with a global fetch (edge included); only their poll consumers need a persistent process. Full per-platform wiring lives on the deploy pages — Vercel, Netlify, and AWS Lambda.

Relation to integration events

A raw QueueEventHandler is the low-level tier. For the common case — "a domain thing happened, react to it across modules or services" — reach for the typed Integration Events tier instead: a producer publishes through a typed event class injected by class identity, and a consumer implements IntegrationEventHandler. Both ride this exact queue, so everything above — retry, validation, the idempotency interceptor, the durable transports — applies to that tier unchanged.

See also

  • Integration Events — the typed event producer over this transport: publish by class identity, consume one class per handler, plus the transactional outbox and cross-service fan-out.
  • IdempotencyQueueIdempotencyInterceptor, for deduping redelivered messages under at-least-once delivery.
  • Events — the in-process EventBus, for a side effect that doesn't need to cross a process boundary or survive a restart.
  • Scheduled jobs — the sibling tier for recurring cron work.
  • Example: Netlify queue — a raw QueueEventHandler end to end: POST /orders publishes and returns 202, the handler fulfils the order off the queue, GET /orders reads it back.
  • Example: L08 — queue — the integration-events tier over the in-memory queue: a sign-up endpoint queues a templated welcome email while the request returns 201.
Copyright © 2026