Background jobs in the Postgres you have.
Hopwire is a job queue for Node that stores jobs in your existing database. No Redis, no broker, and a job only exists once the transaction that created it commits.
- Commits with your data
- Enqueue inside the same transaction as the order. If it rolls back, the job never ran.
- Retries you can read
- Exponential backoff, a dead-letter table, and every attempt kept with its error.
- Plain SQL to inspect
- Jobs are rows. Query them with the tools you already use.
Installation
Hopwire needs Node 20 or later and PostgreSQL 13 or later.
npm install hopwirepnpm add hopwireyarn add hopwirebun add hopwireQuickstart
Four steps from an empty project to a job running in a worker. Each takes a minute or two.
-
Create the tables
Hopwire adds three tables in a
hopwireschema. Run the migration once per database.DATABASE_URL=postgres://localhost/shop npx hopwire migrate -
Define a job
A job is a name and an async function. The payload is stored as JSON, so keep it small: pass IDs, not whole records.
import { defineJob } from "hopwire"; import { mailer } from "../mailer"; export const sendReceipt = defineJob("send-receipt", { queue: "emails", retries: 5, async run({ orderId }: { orderId: string }) { await mailer.receipt(orderId); // throw to retry }, }); -
Enqueue in a transaction
Pass the transaction you already have. The job becomes visible to workers only when it commits.
await db.transaction(async (tx) => { const order = await tx.orders.create(cart); await sendReceipt.enqueue({ orderId: order.id }, { tx }); });Rolled back means never sent. If the order insert fails, the transaction rolls back and the receipt job disappears with it. There is no window where a worker sees a job for an order that doesn't exist.
-
Run a worker
Start one or more workers next to your app. They lock jobs with
SKIP LOCKED, so you can run as many as your database allows.npx hopwire work --queues default,emails --concurrency 10
Retries and failures
When run throws, Hopwire records the error and schedules the next attempt with exponential backoff and jitter. After the last retry the job moves to hopwire.dead, where it stays until you retry or discard it.
| Attempt | Delay before it | Note |
|---|---|---|
| 1 | 0 s | First run |
| 2 | 15 s | Most network errors clear here |
| 3 | 1 min | |
| 4 | 5 min | |
| 5 | 30 min | Then moved to hopwire.dead |
Scheduled jobs
Run a job later with runAt, or on a repeating schedule with a cron expression. Schedules are stored in the database, so only one worker fires each run.
// once, in an hour
await remindCart.enqueue({ cartId }, { runAt: "+1h" });
// every weekday at 07:00, in the shop's time zone
schedule(nightlyReport, { cron: "0 7 * * 1-5", tz: "Asia/Jakarta" });Configuration
Pass options to createHopwire(), or set the matching environment variable. Options win over the environment.
| Option | Type | Default | What it does |
|---|---|---|---|
connection | string | Pool | DATABASE_URL | Where jobs are stored. Reuse your app's pool to share connections. |
schema | string | "hopwire" | Postgres schema for the three Hopwire tables. |
concurrency | number | 5 | Jobs one worker runs at the same time. |
pollInterval | ms | 1000 | Fallback poll when LISTEN notifications are unavailable. |
retention | duration | "7d" | How long finished jobs are kept before cleanup. |
onFailure | function | none | Called after the last retry fails, for alerts. |
Command line
Every command reads DATABASE_URL unless you pass --connection.
hopwire migrate- Create or upgrade the tables. Safe to run on every deploy.
hopwire work- Start a worker. Stops cleanly on
SIGTERMafter the running jobs finish. hopwire status- Counts per queue and state, and the oldest waiting job.
hopwire retry <id>- Move a dead job back to its queue. Use
--allwith a queue name for a batch.
Changelog
Every release, newest first. Hopwire follows semantic versioning; breaking changes only ship in a new major version.
-
2.4.0
Latest- AddedTime zones for cron schedules with the
tzoption. - Added
hopwire status --jsonfor health checks. - FixedA worker no longer holds a connection while it waits for
LISTEN.
- AddedTime zones for cron schedules with the
-
2.3.2
- FixedRetry delays now include jitter on the first retry too.
- FixedThe migration works when the schema name contains a dash.
-
2.3.0
- AddedThe
onFailurehook, called once after the last retry. - ChangedFinished jobs are kept for seven days by default, down from thirty.
- AddedThe
-
2.0.0
- BreakingNode 20 or later is required.
- Changed
enqueuetakes the transaction as{ tx }instead of a third argument.