A Redis-free, PostgreSQL-native job queue for Node.js.
Most job queues require Redis as a broker. catqueue doesn't. If you're already running PostgreSQL, you have everything you need — one table, one migration, no broker to run.
In this README:
- Why catqueue?
- Benchmark
- Quick Start
- API Reference
- Automatic Cleanup
- Job Dependencies (DAG Execution)
- Job Lifecycle
- Retry Schedule
- Error Logging
- TypeScript
- When to use catqueue vs BullMQ
- Known Issues
- Requirements
npm install catqueue| Feature | catqueue | BullMQ |
|---|---|---|
| Broker required | PostgreSQL only | Redis required |
| Idempotency keys | enqueue() — see Known Issues |
❌ Manual |
| Per-attempt error log | ✅ JSON array in Postgres | ❌ |
| Dead-letter + replay | ✅ | ✅ |
| Atomic job locking | SELECT FOR UPDATE SKIP LOCKED |
Redis SETNX |
| Crash recovery | ✅ Visibility timeout | ✅ |
| Worker scheduling | ✅ Chunked prefetch + concurrency-windowed streaming | ❌ One job at a time per worker |
| TypeScript support | ✅ Full generics | ✅ |
| Queryable job history | ✅ Plain SQL forever | ❌ Redis expiry |
Four scenarios, three queues, two consecutive runs on the same code — shown side by side rather than averaged, so run-to-run variance is visible instead of hidden.
Run 1
| Scenario | catqueue | BullMQ | pg-boss |
|---|---|---|---|
| Sequential Add — 2,500 jobs (jobs/sec) | 4,095 | 2,641 | 1,507 |
| Parallel Add — 60,000 jobs, batch=1000 (jobs/sec) | 16,986 | 41,233 | 10,007 |
| Bulk Add — 60,000 jobs, chunk=2000 (jobs/sec) | 66,453 | 37,174 | 54,586 |
| Processing — 60,000 jobs, concurrency=30 (jobs/sec) | 1,679 | 17,423 | 1,298 |
Run 2
| Scenario | catqueue | BullMQ | pg-boss |
|---|---|---|---|
| Sequential Add — 2,500 jobs (jobs/sec) | 3,219 | 2,697 | 1,568 |
| Parallel Add — 60,000 jobs, batch=1000 (jobs/sec) | 17,005 | 38,553 | 9,667 |
| Bulk Add — 60,000 jobs, chunk=2000 (jobs/sec) | 62,424 | 36,499 | 57,426 |
| Processing — 60,000 jobs, concurrency=30 (jobs/sec) | 9,477 | 22,031 | 1,029 |
Reading this honestly:
- catqueue wins Sequential Add and Bulk Add, consistently, both runs — roughly 1.2–1.7x BullMQ and 1.1–2.6x pg-boss. Single-row and chunked inserts are catqueue's strongest case.
- BullMQ wins Parallel Add and Processing, consistently, both runs. Redis's per-op overhead is lower than a Postgres round trip once job counts and concurrency ramp up.
- catqueue beats pg-boss on every scenario, both runs — the closest apples-to-apples Postgres-native comparison.
- catqueue's Processing throughput has high run-to-run variance — 1,679 → 9,477 jobs/sec, a 5.6x swing on identical code. BullMQ and pg-boss don't show this. Not root-caused yet; likely candidates are connection-pool sizing relative to
concurrency=30and Postgres plan/buffer-cache warm-up between runs. Don't treat either Processing number as a stable figure yet.
Reproduce it yourself:
git clone https://github.com/karanrajsurya/CatQueue_npm_package
cd CatQueue_npm_package
npm install
# set DATABASE_URL, REDIS_URL in .env
node benchmark.jsRun migrations/001_init.sql against your PostgreSQL database once:
psql YOUR_CONNECTION_STRING -f node_modules/catqueue/migrations/001_init.sqlOr paste it into your database's SQL editor (Neon, Supabase, pgAdmin, etc).
import { CatQueue } from "catqueue";
const queue = new CatQueue({
connectionString: process.env.DATABASE_URL!,
});
// Register handlers
queue.register("send-email", async (payload) => {
await mailer.send({ to: payload.to, subject: payload.subject });
});
queue.register("resize-image", async (payload) => {
await sharp(payload.url).resize(800).toFile(payload.output);
});
// Start worker
queue.start();
// Enqueue from anywhere in your app
const jobId = await queue.enqueue("send-email", {
to: "user@example.com",
subject: "Welcome!",
});const queue = new CatQueue({
connectionString: string, // required
pollInterval?: number, // ms to sleep when a pass finds no work, default: 1000
lockDuration?: number, // seconds a job stays locked, default: 30
batchSize?: number, // max concurrent jobs in flight, default: 50
maxAttempts?: number, // maximum attempts per job, default: 5
maxPoolSize?: number, // pg Pool max connections, default: 20
});Inserts a job into the queue. Returns the job ID. Durable — job is committed to Postgres before this resolves.
const jobId = await queue.enqueue(
"send-email",
{ to: "user@example.com" },
{
priority: 1, // 1 = urgent, 5 = low. default: 3
maxAttempts: 3, // default: 5
runAt: new Date(Date.now() + 60_000), // delay 60 seconds
idempotencyKey: "welcome-email-user-123", // prevent duplicates
},
);Idempotency keys — if you enqueue a job with an idempotencyKey that already exists, the insert is rejected with a unique constraint violation. Prevents duplicate emails, charges, or webhook deliveries. Stale idempotency keys are cleared automatically once they're at least 1 minute old.
Known issue: in the current implementation,
enqueue()does not actually useoptions.idempotencyKey— it always inserts an internally generated, always-unique key instead. Duplicate calls with the sameidempotencyKeywill not currently be deduplicated.enqueueBatch()is not affected. See Known Issues.
Inserts many jobs in a single round trip via UNNEST. Returns an array of job IDs in insertion order.
const jobIds = await queue.enqueueBatch([
{ jobName: "send-email", payload: { to: "a@example.com" } },
{
jobName: "send-email",
payload: { to: "b@example.com" },
options: { priority: 1, idempotencyKey: "welcome-b" },
},
]);Prefer this over looping enqueue() when adding many jobs at once — it's the difference between the Sequential Add and Bulk Add numbers in the benchmark above.
Registers a handler for a job type. Must be called before queue.start().
queue.register<{ to: string; subject: string }>(
"send-email",
async (payload) => {
// payload is fully typed
await mailer.send({ to: payload.to, subject: payload.subject });
},
);Starts the worker loop. Three background timers run alongside the main loop:
- Stuck-job recovery — every 20 seconds,
PROCESSINGjobs whose lock has expired are reset toPENDING. - Stale idempotency-key cleanup — every 3 seconds, idempotency keys at least ~1 minute old are cleared.
- Weekly cleanup cron — see Automatic Cleanup.
The main loop runs processing passes back-to-back, and only sleeps pollInterval ms once a pass finds no work. Each pass:
- Claims a chunk, not one job at a time —
SELECT ... FOR UPDATE SKIP LOCKED, ordered by priority then creation time, sizedmax(concurrency × 10, 500). One query locks hundreds of jobs instead of one round trip per job. - Streams claimed jobs into a concurrency-bounded pool.
batchSizein the config is really a concurrency limit, not a fixed batch: as soon as one job finishes, the next queued job starts immediately — it doesn't wait for the whole chunk to finish before picking up more work. - Prefetches the next chunk in the background once the current one is close to drained, so workers rarely go idle waiting on a query.
- Batches successful completions. Job IDs that succeed are buffered and flushed as one
UPDATE ... WHERE id = ANY(...)once 100 accumulate (or when the chunk fully drains) — success doesn't cost a round trip per job. - Failures are updated individually — attempt count, backoff
run_at, and the appended error-log entry differ per job, so these can't be batched the same way. See Retry Schedule. - A job with no registered handler is reset to
PENDING(not counted as a failed attempt) and a warning is logged — it waits for a handler to be registered instead of retrying or dying.
Gracefully stops the worker, the cron, and closes the database pool.
process.on("SIGINT", async () => {
await queue.stop();
process.exit(0);
});stats is a method, not a property — call it, then chain:
await queue.stats().overview(); // job counts grouped by status
await queue.stats().failureRate("6 min"); // DEAD / total ratio, e.g. "6 min", "2 hour", "3 day"
await queue.stats().retryCount(jobId); // attempts made so far for a job
await queue.stats().deadJobs(); // all jobs currently DEADcatqueue runs a built-in weekly cron job every Monday at 5:00 AM IST that automatically deletes COMPLETED jobs older than 7 days — preventing unbounded table growth without any configuration.
-- what the cron runs internally
DELETE FROM catqueue_jobs
WHERE status = 'COMPLETED'
AND completed_at < NOW() - INTERVAL '7 days'This runs automatically when you call queue.start() and stops when you call queue.stop(). No configuration needed.
If you want to keep completed jobs longer for auditing, query them before they're cleaned up:
SELECT * FROM catqueue_jobs
WHERE status = 'COMPLETED'
ORDER BY completed_at DESC;More of this exists in the codebase than before, but it isn't safe to use yet. Current state:
catqueue_jobs.dependenciesand ajob_dependenciesedge table exist and are read by the live claim query.claimRunnableJobsalready excludes a job from being claimed if it has an edge injob_dependenciespointing at a dependency that isn'tCOMPLETED.- But edges are only written by
insertDependencyEdges, which runs after a batch is already claimed. On a job's very first claim attempt, no edge rows exist for it yet, so the dependency check passes vacuously — a job can be markedPROCESSINGand start running before its dependencies are even represented in the graph. Edge insertion needs to happen before the claim query, not after. dependenciesis currently set once per queue instance (new CatQueue({ dependencies: [...] })), not per job — every job enqueued viaenqueue()on that instance gets the same list.enqueueBatch()doesn't attach dependencies at all; the column isn't in that insert.- A topological-sort implementation (Kahn's algorithm, in
GraphProcess.ts) exists for computing safe execution order and quarantining cyclic jobs, but it isn't called anywhere from the live processing path (processNextBatch) — currently dead code.
Do not rely on this feature yet. Treat it as a preview of the direction, not a usable feature. Landing it end-to-end needs: edge insertion moved before the claim query, dependencies moved to a per-job enqueue()/enqueueBatch() option, and GraphProcess either wired into processNextBatch or removed.
PENDING → PROCESSING → COMPLETED
↓
(on failure)
↓
attemptCount++
errorLog.push({ attempt, error, at })
runAt = now + 2^attemptCount seconds
↓
back to PENDING
↓
(after maxAttempts exceeded)
↓
DEAD
Dead jobs are removed from the database after 7 days through cron. Before that, they are queryable, replayable, auditable.
Exponential backoff, so no job retries at the same instant twice.
| Attempt | Retry after |
|---|---|
| 1 | 2 seconds |
| 2 | 4 seconds |
| 3 | 8 seconds |
| 4 | 16 seconds |
| 5 | → DEAD |
Every failed attempt is appended to error_log as a JSON array:
[
{ "attempt": 1, "error": "Connection timeout", "at": "2026-06-26T10:00:00Z" },
{ "attempt": 2, "error": "Connection timeout", "at": "2026-06-26T10:00:02Z" },
{ "attempt": 3, "error": "Null pointer", "at": "2026-06-26T10:00:06Z" }
]Full generic support for typed payloads:
interface EmailPayload {
to: string;
subject: string;
body: string;
}
queue.register<EmailPayload>("send-email", async (payload) => {
// payload.to, payload.subject, payload.body — all typed
});
await queue.enqueue<EmailPayload>("send-email", {
to: "user@example.com",
subject: "Hello",
body: "Welcome!",
});Use catqueue when:
- You already have PostgreSQL and don't want to manage Redis
- You need durable, queryable job history (compliance, auditing, billing)
- Your workload is dominated by inserts (Sequential/Bulk Add) rather than raw processing throughput
- You want a simple stack: one database, zero brokers
Use BullMQ when:
- You need the highest possible Parallel Add or Processing throughput (see Benchmark)
- You already have Redis in your stack
- You need sub-10ms job pickup latency (Redis pub/sub vs polling)
- You need advanced features: rate limiting, job flows, repeatable jobs
vs pg-boss: across every scenario benchmarked above, catqueue is faster, in both runs. pg-boss remains a longer-established, actively maintained option — worth it if you're already invested in it or need functionality catqueue doesn't reliably have yet (working DAG support, for instance, until the issues above are fixed).
enqueue()ignoresoptions.idempotencyKey. It always generates its own unique key internally, so duplicate-key deduplication does not currently work throughenqueue().enqueueBatch()is unaffected. Fix is one line: useoptions.idempotencyKey ?? <generated fallback>instead of always generating.- Dependency edges are inserted after jobs are claimed, so a job's first claim never sees its own dependency edges. See Job Dependencies.
GraphProcess.ts(topological sort) is not called anywhere inprocess.ts. Either wire it intoprocessNextBatchor remove it — right now it implies more DAG support than actually ships.- Processing throughput shows ~5.6x run-to-run variance in the current benchmark suite. Not yet root-caused. See Benchmark.
- Node.js 18+
- PostgreSQL 13+ (
gen_random_uuid()andSKIP LOCKEDsupport)
MIT © Karan Raj Surya