Queue
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.
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.
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.
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.
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:
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.
| Platform | Subpath | Delivery |
|---|---|---|
| Cloudflare Workers | @heximon/queue/cloudflare | push — the platform delivers a batch to the worker |
| Vercel Functions | @heximon/queue/vercel | push — to a dedicated function |
| Netlify Functions | @heximon/queue/netlify | push — to a generated workload consumer |
| SQL (pg / mysql / sqlite) | @heximon/queue/drizzle | poll — a long-lived process; FOR UPDATE SKIP LOCKED on pg/mysql |
| AWS SQS | @heximon/queue/sqs | poll — long-polling consumer (also a push consumer for SQS-triggered Lambda) |
| Upstash Redis Streams | @heximon/queue/upstash | poll — 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.
- Idempotency —
QueueIdempotencyInterceptor, 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
QueueEventHandlerend to end:POST /orderspublishes and returns202, the handler fulfils the order off the queue,GET /ordersreads 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.
Events
Decouple features with events across three tiers — the in-process EventBus, EventHandler and NotificationHandler, and domain events on aggregates with DomainEvents.emitFrom.
Scheduled Work
Run work on a wall-clock cron schedule with ScheduledHandler — a process-local timer on Node, the platform cron trigger on Cloudflare, validated at build time.