Work 09 of 9 · Playbook J
tern
A typed job queue for Postgres.
- Playbook
- J · Developer tools & docs
- Variant
- J1 · Blueprint light
- Loud idea
- The API call beside its response
- Type
- Geist 500 · Geist · Geist Mono
Background jobs, in the Postgres you already run.
tern keeps your queue in a table beside your data, so a job commits in the same transaction as the write that caused it. Typed payloads, retries, cron and priorities, with no Redis to run.
npm i @tern/queueMIT licensed · Postgres 12+ · Node 18+
import { Queue } from "@tern/queue";import { pool } from "./db"; type Welcome = { userId: number }; const emails = new Queue<Welcome>(pool, { name: "emails",}); // Runs in 15 minutes, up to 3 attemptsconst job = await emails.enqueue( { userId: 4812 }, { delay: "15m", maxAttempts: 3 },); console.log(job);{ "id": "job_01J8ZQ4T6M3K", "queue": "emails", "state": "scheduled", "payload": { "userId": 4812 }, "runAt": "2026-09-23T09:15:00.412Z", "attempts": 0, "maxAttempts": 3, "createdAt": "2026-09-23T09:00:00.412Z"}enqueue() inserts one row and resolves with the stored job: its id, the computed run time and its retry budget. Tinted lines show where each field comes from.Run your first job in three steps.
Everything runs inside your database: no broker to deploy and nothing new to monitor.
Step 1: Install the package
One dependency, with its own migrations and a typed client.
npm i @tern/queueStep 2: Create the tables
Adds a tern schema with two tables and an index. Safe to run on every deploy.
npx tern migrateStep 3: Start a worker
Workers claim jobs with SKIP LOCKED, so you can run as many as your pool allows.
emails.work(async (job) => { await send(job.payload); });
Note
tern needs Postgres 12 or later. It keeps everything in its own tern schema, so your tables and migrations stay untouched.
Numbers you can rerun.
Throughput and latency on one dedicated Postgres instance, compared with tern 1.3. Read the methodology.
Scroll the table sideways for latency and change.
| Workload | Jobs per second | p50 | p99 | Throughput vs 1.3 |
|---|---|---|---|---|
| Enqueue, one job per transaction | 9,840 | 0.9 ms | 3.1 ms | +12% |
| Enqueue, batches of 500 | 118,200 | 4.2 ms | 11.8 ms | +31% |
| Dequeue and complete, 8 workers | 21,400 | 1.8 ms | 6.9 ms | +84% |
| Dequeue and complete, 32 workers | 46,700 | 2.6 ms | 14.2 ms | +130% |
| Scheduled jobs coming due, 1M pending | 18,900 | 2.2 ms | 9.4 ms | +22% |
Methodology. Postgres 16.4 on a dedicated 8 vCPU, 32 GB instance with gp3 storage; Node 22.9; tern 1.4.0 against 1.3.2. Each workload ran for 10 minutes after a 2-minute warm-up with 512-byte JSON payloads, and every figure is the median of five runs. The scripts live in bench/ in the repository.
Brief
What it had to do.
- Subject
- An open-source job queue that runs inside your existing Postgres.
- Audience
- TypeScript backend developers.
- Job
- Install it and run the first job.
- Proof
- Real code, a real response and benchmark methodology.
Token plan
Written before any code.
design.md’s Agent Protocol asks for this table first. Ratios are WCAG 2, measured on the surface each color actually sits on.
| Color | Hex | Role | Contrast |
|---|---|---|---|
| Canvas | #F5F5F5 | Page | — |
| Ink | #0A0A0A | Headlines | 18.16:1 |
| Ink subtle | #626262 | Secondary text | 5.59:1 |
| Code | #0B0D10 | Code blocks | — |
| Code ink | #E6E6E6 | Code text | 15.59:1 |
| Comment | #7D8590 | Code comments | 5.22:1 |
Decisions
Why it looks this way.
- 01
The code is the screenshot: a real install command and a real call beside its JSON response.
- 02
Syntax uses five token colors; comments still pass AA (5.22:1).
- 03
Callouts use a 1px border and a light tint — never a thick side stripe.
- 04
The quickstart is numbered because it is a real sequence.