---
title: Drizzle ORM + pg-boss Atomic Transactions Tutorial (2026)
description: Send Postgres-backed pg-boss 12 jobs inside a Drizzle ORM transaction with the fromDrizzle adapter, retries, dead letters, and cron schedules.
url: https://nerdleveltech.com/drizzle-orm-pg-boss-atomic-transactions-tutorial
language: en
date: '2026-05-31'
category: backend
tags:
  - pg-boss
  - drizzle-orm
  - postgres
  - typescript
  - background-jobs
  - transactions
  - node
  - tutorial
markdown_url: https://nerdleveltech.com/drizzle-orm-pg-boss-atomic-transactions-tutorial.md
attribution: 'Source: NerdLevelTech — https://nerdleveltech.com/drizzle-orm-pg-boss-atomic-transactions-tutorial'
terms: https://nerdleveltech.com/llms.txt
---

# Drizzle ORM + pg-boss Atomic Transactions Tutorial (2026)

Wire Drizzle ORM 0.45.2 to pg-boss 12.18.2 through pg-boss's built-in `fromDrizzle` adapter so a row insert and the matching job send share one Postgres transaction. Commit together, roll back together. End-to-end TypeScript, retry policies, dead-letter queues, cron schedules, runnable against `postgres:18-alpine`.

## TL;DR

The "send an email after creating a row" pattern is almost always written with two unrelated calls: `INSERT INTO orders ...` then `boss.send('email', ...)`. If the process dies between them, you ship an order with no email — or worse, an email for an order that was rolled back. pg-boss 12 ships a `fromDrizzle(tx, sql)` adapter that routes the job insert through your Drizzle transaction object, so both writes share one Postgres transaction. Commit together, roll back together, no glue code. This post builds the full stack in 8 steps.

## What you'll learn

- How `pg-boss.fromDrizzle` makes the queue insert share a transaction with your Drizzle writes
- How to scaffold a Postgres 18 + pg-boss 12 + Drizzle 0.45.2 project with end-to-end TypeScript types
- How to send a job atomically with a row insert so both commit or both roll back
- How to set retry limits, exponential backoff, and a dead-letter queue per queue
- How to schedule recurring jobs with cron expressions and a timezone
- How to write a worker that sends downstream jobs inside its own transaction
- How to verify the rollback behavior with a deliberately failing transaction
- How to shut down the queue and pool cleanly on `SIGTERM`

## Prerequisites

You need Node.js 24 (Active LTS as of May 2026, supported through April 2028 — Node 22 also works because pg-boss 12 requires Node ≥22.12.0)[^node-lts]. You need Docker (or another way to run Postgres 18) and a terminal with `npm` and `psql` available locally is helpful but not required. Familiarity with TypeScript generics and async/await is assumed.

All commands are tested on macOS and Linux. Windows users should run everything from WSL2 so Docker, Node, and `psql` share one Linux userspace.

## Why "atomic enqueue" matters

Background jobs that fire after a database write are everywhere: order receipts, post-signup welcome emails, downstream webhooks, search-index updates, fulfillment kickoffs. The naive pattern looks like this:

```ts
await db.insert(orders).values(input);     // commit 1
await boss.send('order.receipt-email', { ... });  // commit 2
```

There are four failure modes between commit 1 and commit 2 that hose your data:

1. Process crash after commit 1 → order exists, no email. Customer paid, got nothing.
2. Process crash after commit 2, before responding to the HTTP caller → caller retries, you create a second order, the customer gets two emails.
3. `boss.send` rejects with a transient Postgres connection error → order exists, no email.
4. A later step in the same handler throws → you `try/catch` and rollback the row, but the job is already in the queue, and the worker emails a customer about an order that no longer exists.

The fix is to put both writes in the same Postgres transaction. pg-boss's `boss.send` accepts a `{ db }` option that overrides where the queue insert goes. If you point that `db` at your Drizzle transaction's `execute` method, the job row lands inside the same transaction as the row insert.[^pgboss-readme]

## Step 1 — Scaffold the project

Make a fresh directory and install pinned versions:

```bash
mkdir orders-api && cd orders-api
npm init -y
npm pkg set type=module
npm install --save-exact pg-boss@12.18.2 drizzle-orm@0.45.2 pg@8.21.0
npm install --save-exact --save-dev typescript@6.0.3 \
  drizzle-kit@0.31.10 tsx@4.22.3 dotenv@17.4.2 \
  @types/pg@8.20.0 @types/node@22.19.17
```

The `--save-exact` flag is critical: `npm install pg-boss@12.18.2` without it writes `^12.18.2`, so a future `npm install` may upgrade to 12.19.x and quietly change the queue option schema. Lock the patch level.[^npm-save-exact]

Add a `tsconfig.json` with the strict-most options you can stomach. The combo below is the production baseline used in the rest of the post:

```json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "lib": ["ES2022"],
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "resolveJsonModule": true,
    "verbatimModuleSyntax": true,
    "allowImportingTsExtensions": true,
    "isolatedModules": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*.ts"]
}
```

`allowImportingTsExtensions: true` lets you import files as `'./schema.ts'`. Node 22.18+ and Node 24 strip types natively, and `tsx` accepts the same syntax, so the same source runs in both[^node-strip-types].

Add a `.env` with the connection string you'll point everything at:

```bash
echo 'DATABASE_URL=postgres://orders:orders@127.0.0.1:5432/orders' > .env
echo '.env' >> .gitignore
echo 'node_modules' >> .gitignore
```

## Step 2 — Boot Postgres 18

Use the official `postgres:18-alpine` image — the floating tag currently aliases `18-alpine3.23` (Alpine 3.23 base)[^pg-docker]:

```bash
docker run --rm -d --name orders-pg \
  -e POSTGRES_USER=orders \
  -e POSTGRES_PASSWORD=orders \
  -e POSTGRES_DB=orders \
  -p 5432:5432 \
  postgres:18-alpine
```

Verify it boots:

```bash
docker exec orders-pg pg_isready -U orders -d orders
# Expected: /var/run/postgresql:5432 - accepting connections
```

`pg-boss` will create its own schema (`pgboss` by default — we'll lock that explicitly in Step 4) and run its migrations on first start. You don't need to pre-create anything.

## Step 3 — Define the Drizzle schema

Create `src/db/schema.ts` with the single business table this tutorial cares about — an `orders` table with a status enum:

```ts
// src/db/schema.ts
import {
  pgTable,
  uuid,
  text,
  integer,
  timestamp,
  index,
  pgEnum,
} from 'drizzle-orm/pg-core';

export const orderStatus = pgEnum('order_status', [
  'pending',
  'paid',
  'fulfilled',
  'failed',
]);

export const orders = pgTable(
  'orders',
  {
    id: uuid('id').defaultRandom().primaryKey(),
    customerEmail: text('customer_email').notNull(),
    amountCents: integer('amount_cents').notNull(),
    currency: text('currency').notNull().default('USD'),
    status: orderStatus('status').notNull().default('pending'),
    createdAt: timestamp('created_at', { withTimezone: true })
      .notNull()
      .defaultNow(),
  },
  (t) => [
    index('orders_email_idx').on(t.customerEmail),
    index('orders_status_idx').on(t.status),
  ],
);

export type Order = typeof orders.$inferSelect;
export type OrderInsert = typeof orders.$inferInsert;
```

`$inferSelect` and `$inferInsert` give you typed row shapes for free — no separate DTO file[^drizzle-types].

Wire the Drizzle client to a `pg.Pool` in `src/db/client.ts`:

```ts
// src/db/client.ts
import 'dotenv/config';
import { Pool } from 'pg';
import { drizzle } from 'drizzle-orm/node-postgres';
import * as schema from './schema.ts';

const url = process.env.DATABASE_URL;
if (!url) throw new Error('DATABASE_URL is required');

export const pool = new Pool({
  connectionString: url,
  max: 10,
  idleTimeoutMillis: 30_000,
  connectionTimeoutMillis: 5_000,
});

pool.on('error', (err) => {
  console.error('pg pool error', err);
});

export const db = drizzle({ client: pool, schema, casing: 'snake_case' });
export type Database = typeof db;
```

The `casing: 'snake_case'` option is a safety default. The schema above passes the explicit column names (`text('customer_email')`), so casing has no effect there — but any column you add later without an explicit name (`text()` alone) will still be emitted as snake_case in the generated SQL, matching the rest of your tables.

Add a Drizzle Kit config and generate the migration:

```ts
// drizzle.config.ts
import 'dotenv/config';
import type { Config } from 'drizzle-kit';

const url = process.env.DATABASE_URL;
if (!url) throw new Error('DATABASE_URL is required');

export default {
  schema: './src/db/schema.ts',
  out: './drizzle',
  dialect: 'postgresql',
  dbCredentials: { url },
} satisfies Config;
```

Add scripts to `package.json`:

```json
{
  "scripts": {
    "db:generate": "drizzle-kit generate",
    "db:push": "drizzle-kit push",
    "typecheck": "tsc --noEmit",
    "dev": "tsx watch src/index.ts"
  }
}
```

Then push the schema:

```bash
npm run db:push
# [✓] Pulling schema from database...
# [✓] Changes applied
```

## Step 4 — Wire up pg-boss

Create `src/queue.ts`:

```ts
// src/queue.ts
import 'dotenv/config';
import { PgBoss } from 'pg-boss';

const url = process.env.DATABASE_URL;
if (!url) throw new Error('DATABASE_URL is required');

export const boss = new PgBoss({
  connectionString: url,
  schema: 'pgboss',
  max: 5,
  monitorIntervalSeconds: 30,
  application_name: 'orders-api',
});

boss.on('error', (err: Error) => {
  console.error('pg-boss error', err);
});

export const QUEUES = {
  receiptEmail: 'order.receipt-email',
  fulfillment: 'order.fulfillment',
} as const;
```

Two non-obvious things here:

- The import is `import { PgBoss } from 'pg-boss'` — named, not default. pg-boss 12 is pure ESM (Node ≥22.12.0) and the class is exported as a named export only[^pgboss-esm].
- `application_name` flows through to Postgres's `pg_stat_activity.application_name`, which makes it trivial to find pg-boss's connections (`SELECT pid, query FROM pg_stat_activity WHERE application_name = 'orders-api';`) when triaging. Set it explicitly even though it's optional.

When you call `boss.start()` for the first time, pg-boss creates the `pgboss` schema and runs its migrations. The schema version on pg-boss 12.18.2 is 30 (you can check at runtime with `boss.schemaVersion()` or via the CLI: `npx pg-boss version`).

## Step 5 — The atomic create-order function

This is the heart of the tutorial. Create `src/orders/create.ts`:

```ts
// src/orders/create.ts
import { sql } from 'drizzle-orm';
import { fromDrizzle } from 'pg-boss';
import { db } from '../db/client.ts';
import { orders, type OrderInsert } from '../db/schema.ts';
import { boss, QUEUES } from '../queue.ts';

export interface CreateOrderInput {
  customerEmail: string;
  amountCents: number;
  currency?: string;
}

export interface ReceiptEmailJob {
  orderId: string;
  customerEmail: string;
  amountCents: number;
  currency: string;
}

export async function createOrderAtomic(
  input: CreateOrderInput,
): Promise<{ orderId: string; receiptJobId: string | null }> {
  return db.transaction(async (tx) => {
    const insert: OrderInsert = {
      customerEmail: input.customerEmail,
      amountCents: input.amountCents,
      currency: input.currency ?? 'USD',
      status: 'pending',
    };

    const [row] = await tx
      .insert(orders)
      .values(insert)
      .returning({ id: orders.id });

    if (!row) throw new Error('order insert returned no row');

    const receiptJobId = await boss.send(
      QUEUES.receiptEmail,
      {
        orderId: row.id,
        customerEmail: input.customerEmail,
        amountCents: input.amountCents,
        currency: input.currency ?? 'USD',
      } satisfies ReceiptEmailJob,
      { db: fromDrizzle(tx, sql) },
    );

    return { orderId: row.id, receiptJobId };
  });
}
```

Three things to internalize:

1. `db.transaction(async (tx) => {...})` opens a single Postgres transaction. Every query against `tx` runs on the same physical connection until the callback resolves or throws.
2. `boss.send(name, data, { db: fromDrizzle(tx, sql) })` is where the magic happens. The `db` option overrides pg-boss's default connection pool with the `IDatabase` adapter returned by `fromDrizzle`. The adapter's `executeSql(text, values)` rewrites the `$1`/`$2` placeholders to a Drizzle template-tag form and calls `tx.execute(sql(strings, ...reordered))` — same transaction, same connection, same commit boundary[^pgboss-drizzle-adapter].
3. The `satisfies` keyword on the job payload enforces shape without widening: if you later add a required field to `ReceiptEmailJob`, every call site fails type-checking until you fix it.

If anything inside the callback throws, Drizzle rolls back automatically — and the job send rolls back with it. There is no "pending" job in the queue for an order that doesn't exist.

## Step 6 — Configure retries and a dead-letter queue

The default retry budget is 2 with no delay, which is fine for hello-world but irresponsible for production[^pgboss-retries]. Override it per-queue. Create `src/orders/retry.ts`:

```ts
// src/orders/retry.ts
import { boss } from '../queue.ts';

export async function configureRetries(): Promise<void> {
  await boss.createQueue('order.receipt-email', {
    policy: 'standard',
    retryLimit: 5,
    retryDelay: 30,
    retryBackoff: true,
    expireInSeconds: 60,
    retentionSeconds: 60 * 60 * 24 * 14,
    deleteAfterSeconds: 60 * 60 * 24 * 7,
    deadLetter: 'order.receipt-email.dlq',
  });

  await boss.createQueue('order.receipt-email.dlq');
}
```

Field names worth memorizing because they're a common fact-sheet error:

| Field | Default | Notes |
|---|---|---|
| `retryLimit` | 2 | Max attempts after the initial run |
| `retryDelay` | 0 | Seconds between attempts |
| `retryBackoff` | `false` | When true, applies exponential backoff to `retryDelay` |
| `expireInSeconds` | 900 | Max seconds in `active` state before the job is marked failed |
| `retentionSeconds` | 1209600 (14d) | How long unfinished jobs are kept |
| `deleteAfterSeconds` | 604800 (7d) | How long completed jobs are kept |
| `deadLetter` | none | Name of another queue that receives the payload after final failure |

`retryLimit: 5` + `retryDelay: 30` + `retryBackoff: true` gives the simplified backoff `30 * 2^n` seconds between attempts — roughly 30s, 60s, 120s, 240s, 480s, with jitter — then the payload lands in `order.receipt-email.dlq` for human review. The pg-boss source documents the formula as `Math.min(retryDelayMax, retryDelay * (2 ** Math.min(16, retryCount) / 2 + 2 * Math.min(16, retryCount) / 2 * Math.random()))`.

There is no `retentionDays` field on a queue. Trying to pass one is a TypeScript error against the typed `Omit<Queue, 'name'>` parameter; use the `*Seconds` fields above instead[^pgboss-types]. (Note that pg-boss does ship one `*Days` field — `warningRetentionDays` on the global `MaintenanceOptions` — but that controls the BAM warning log, not job retention.)

## Step 7 — Workers that send downstream jobs atomically

Workers run inside their own transactions. When a worker fans out to other jobs, you want the same rollback semantics. Create `src/orders/fulfill.ts`:

```ts
// src/orders/fulfill.ts
import { eq, sql } from 'drizzle-orm';
import { fromDrizzle } from 'pg-boss';
import { db } from '../db/client.ts';
import { orders } from '../db/schema.ts';
import { boss, QUEUES } from '../queue.ts';

export interface FulfillmentJob {
  orderId: string;
}

export async function registerFulfillmentWorker(): Promise<void> {
  await boss.work<FulfillmentJob, void>(
    QUEUES.fulfillment,
    {
      batchSize: 1,
      pollingIntervalSeconds: 1,
    },
    async (jobs) => {
      for (const job of jobs) {
        const { orderId } = job.data;
        await db.transaction(async (tx) => {
          const [row] = await tx
            .update(orders)
            .set({ status: 'fulfilled' })
            .where(eq(orders.id, orderId))
            .returning({ id: orders.id });

          if (!row) {
            throw new Error(`order ${orderId} not found`);
          }

          await boss.send(
            QUEUES.receiptEmail,
            { orderId, type: 'fulfilled' },
            { db: fromDrizzle(tx, sql) },
          );
        });
      }
    },
  );
}

export async function scheduleNightlyReconcile(): Promise<void> {
  await boss.schedule(
    'reconcile.orders',
    '0 2 * * *',
    {},
    { tz: 'UTC' },
  );

  await boss.work('reconcile.orders', async () => {
    console.log('[reconcile] running nightly check at', new Date().toISOString());
  });
}
```

Three takeaways:

- `boss.work<ReqData, ResData>(name, options, handler)` — the handler receives an **array** of `Job<ReqData>`, not a single job. `batchSize: 1` makes the array always length 1, which is what most people actually want when they first reach for the API.
- The handler opens a Drizzle transaction. Inside, both the row update and the receipt-email send share that transaction. If the receipt send fails after the row update, the row update rolls back — the next poll picks up the same fulfillment job and retries cleanly.
- `boss.schedule(name, cron, data, { tz })` registers a cron schedule. The `tz` option defaults to `'UTC'`; pass a different IANA zone (e.g. `'America/New_York'`) only when you actually need wall-clock scheduling.[^pgboss-cron]

## Step 8 — Prove rollback works

A demo function that deliberately throws after the job send, so you can see the queue stay empty when the transaction rolls back. Create `src/orders/rollback-demo.ts`:

```ts
// src/orders/rollback-demo.ts
import { sql } from 'drizzle-orm';
import { fromDrizzle } from 'pg-boss';
import { db } from '../db/client.ts';
import { orders } from '../db/schema.ts';
import { boss, QUEUES } from '../queue.ts';

export class FraudCheckFailed extends Error {
  override readonly name = 'FraudCheckFailed';
}

export async function createOrderWithFraudCheck(input: {
  customerEmail: string;
  amountCents: number;
}): Promise<{ orderId: string } | { rolledBack: true }> {
  try {
    return await db.transaction(async (tx) => {
      const [row] = await tx
        .insert(orders)
        .values({
          customerEmail: input.customerEmail,
          amountCents: input.amountCents,
          status: 'pending',
        })
        .returning({ id: orders.id });

      if (!row) throw new Error('insert returned no row');

      await boss.send(
        QUEUES.receiptEmail,
        { orderId: row.id, customerEmail: input.customerEmail },
        { db: fromDrizzle(tx, sql) },
      );

      if (input.amountCents > 1_000_000) {
        throw new FraudCheckFailed('amount exceeds $10,000 limit');
      }

      return { orderId: row.id };
    });
  } catch (err) {
    if (err instanceof FraudCheckFailed) {
      console.warn('fraud check failed; transaction rolled back', err.message);
      return { rolledBack: true };
    }
    throw err;
  }
}
```

Wire everything into an entry point in `src/index.ts`:

```ts
// src/index.ts
import 'dotenv/config';
import { boss } from './queue.ts';
import { createOrderAtomic } from './orders/create.ts';
import { configureRetries } from './orders/retry.ts';
import { registerFulfillmentWorker, scheduleNightlyReconcile } from './orders/fulfill.ts';
import { createOrderWithFraudCheck } from './orders/rollback-demo.ts';
import { pool } from './db/client.ts';

async function shutdown(): Promise<void> {
  console.log('shutting down...');
  await boss.stop({ close: true, graceful: true, timeout: 30_000 });
  await pool.end();
  process.exit(0);
}

process.on('SIGTERM', () => void shutdown());
process.on('SIGINT', () => void shutdown());

async function main(): Promise<void> {
  await boss.start();
  await configureRetries();
  await registerFulfillmentWorker();
  await scheduleNightlyReconcile();

  const ok = await createOrderAtomic({
    customerEmail: 'alice@example.com',
    amountCents: 4999,
  });
  console.log('committed order:', ok);

  const rb = await createOrderWithFraudCheck({
    customerEmail: 'mallory@example.com',
    amountCents: 2_500_000,
  });
  console.log('fraud demo:', rb);
}

main().catch((err) => {
  console.error('fatal', err);
  process.exit(1);
});
```

`boss.stop({ close: true, graceful: true, timeout: 30_000 })` lets active jobs finish for up to 30 seconds before forcing the shutdown. All three options are also the pg-boss 12.18.2 defaults — `close = true, graceful = true, timeout = 30000` — so passing them explicitly here just documents intent at the call site. When `close: true`, pg-boss closes its own internal `pg.Pool` (the one it created from your connection string) so the process can exit cleanly. Note that `stop` does **not** accept a `wait` flag; that field lives on `OffWorkOptions` (used by `offWork`), which is a common source of `TS2353` errors when adapting tutorials from older versions.

## Verification

Boot the app:

```bash
npm run dev
```

You should see something like:

```text
committed order: { orderId: '8f3a...', receiptJobId: 'a9c1...' }
fraud check failed; transaction rolled back amount exceeds $10,000 limit
fraud demo: { rolledBack: true }
```

(No worker is registered for `order.receipt-email` in this minimal demo, so the receipt job lands in the queue and waits there. That's exactly what we want for the rollback check.) Now confirm the rollback in Postgres directly:

```bash
docker exec -it orders-pg psql -U orders -d orders \
  -c "SELECT count(*) FROM orders WHERE customer_email='alice@example.com';"
# count: 1
docker exec -it orders-pg psql -U orders -d orders \
  -c "SELECT count(*) FROM orders WHERE customer_email='mallory@example.com';"
# count: 0
docker exec -it orders-pg psql -U orders -d orders \
  -c "SELECT count(*) FROM pgboss.job WHERE name='order.receipt-email' AND data->>'customerEmail'='mallory@example.com';"
# count: 0
```

The successful order is committed, but mallory's row never existed and her job was never enqueued. That's the atomic-enqueue guarantee. (`pgboss.job` is the partitioned parent table that every queue's rows go into; non-`partition` queues all share the `pgboss.job_common` partition by default.)

## Troubleshooting

**`TS2613: Module 'pg-boss' has no default export.`**
Change `import PgBoss from 'pg-boss'` to `import { PgBoss } from 'pg-boss'`. pg-boss 12 is pure ESM and exports `PgBoss` as a named export only — the published `dist/index.d.ts` declares it as `export declare class PgBoss`.

**`TS2353: Object literal may only specify known properties, and 'retentionDays' does not exist in type 'Omit<Queue, "name">'.`**
There is no `retentionDays` field on `QueueOptions`. Use `retentionSeconds: 60 * 60 * 24 * 14` for the same effect[^pgboss-types]. (pg-boss does ship `warningRetentionDays` on `MaintenanceOptions`, but that controls the BAM warning log — different scope.)

**`error: relation "pgboss.job" does not exist`**
You called `boss.send` or queried the schema before `boss.start()`. The first `start()` call runs the migrations that create the schema, the partitioned parent table `pgboss.job`, and the default partition `pgboss.job_common`. Always await `boss.start()` before any other pg-boss method.

**Worker handler types out to `any` for `jobs`.**
Provide both generics: `boss.work<ReqData, ResData>(name, options, handler)`. With only one, the handler param falls through to the default `any` signature. The `jobs` parameter is always `Job<ReqData>[]`, not a single `Job<ReqData>` — see the README's "batch size" note[^pgboss-readme].

**`fromDrizzle` is undefined at runtime.**
You're on pg-boss <12.17.0. The `fromDrizzle`/`fromKnex`/`fromKysely`/`fromPrisma` adapters all landed in **pg-boss 12.17.0** (published 2026-04-24); earlier 12.x releases shipped only the manual `IDatabase` interface. Upgrade with `npm install pg-boss@>=12.17.0`. If you can't upgrade, write a small `IDatabase` wrapper that calls `tx.execute(sql.raw(...))` with the placeholders already interpolated — `sql.raw` accepts only a string, so you have to substitute `$1`/`$2` values yourself, which is exactly the wheel the official adapter reinvents for you.

## Next steps

This stack covers the atomic-enqueue case end-to-end, but production systems usually grow three more things:

- **Observability**: emit OpenTelemetry spans per `send`/`work` invocation so the queue shows up next to your HTTP traces. The [Fail-open vs fail-closed Hono middleware tutorial](https://nerdleveltech.com/fail-open-vs-fail-closed-hono-middleware-redis-tutorial) covers the same pattern for middleware fan-out.
- **Rate-limiting the publisher**: pair this queue with a sliding-window limiter so a single user can't flood the receipt-email queue. See the [Upstash sliding-window rate-limit tutorial](https://nerdleveltech.com/api-rate-limiting-upstash-redis-sliding-window-tutorial) for the limiter; replace the in-memory store with a Postgres counter table to stay in one database.
- **Partitioning the job table**: pg-boss 12 already partitions `pgboss.job`, with a dedicated `partition: true` opt-in per high-volume queue (the partition table name is `j<sha224hash>`). For organic time-based cleanup of any large companion tables you run alongside it, the [pg_partman + pg_cron Postgres 18 tutorial](https://nerdleveltech.com/pg-partman-pg-cron-postgres-18-partition-automation-tutorial) shows how to partition by `created_at` so cleanup is a `DROP PARTITION` instead of a `DELETE`.

## Related reads

- [tRPC v11 End-to-End Type Safety: Server + Client (2026)](https://nerdleveltech.com/trpc-v11-end-to-end-type-safety-server-client-tutorial)

[^node-lts]: Node.js release schedule, [`nodejs.org/en/about/previous-releases`](https://nodejs.org/en/about/previous-releases). Node 24 entered Active LTS on 2025-10-28 (codename "Krypton") and is supported through April 2028; Node 22 transitioned to Maintenance LTS around the same window.
[^pgboss-readme]: pg-boss 12 README, "ORM Transaction Adapters" section, [`github.com/timgit/pg-boss`](https://github.com/timgit/pg-boss). The README documents the `fromKnex`, `fromKysely`, `fromDrizzle`, and `fromPrisma` exports and their usage inside ORM transactions. Note: per `npm view pg-boss time --json`, the adapters first shipped in **pg-boss 12.17.0** (2026-04-24); they are NOT in 12.0.0–12.16.0.
[^npm-save-exact]: npm docs, "install — save-exact flag", [`docs.npmjs.com/cli/v10/commands/npm-install`](https://docs.npmjs.com/cli/v10/commands/npm-install). The default is a caret range; `--save-exact` writes the exact version. Set `save-exact=true` in `.npmrc` to make it global.
[^node-strip-types]: Node.js docs, "Type stripping", [`nodejs.org/api/typescript.html#type-stripping`](https://nodejs.org/api/typescript.html#type-stripping). Native `.ts` execution arrived as `--experimental-strip-types` in Node 22.6.0 and was promoted to enabled-by-default in Node 22.18.0 (and Node 24).
[^pg-docker]: Postgres official Docker image, [`hub.docker.com/_/postgres`](https://hub.docker.com/_/postgres). The `postgres:18-alpine` tag currently aliases `18-alpine3.23` (Alpine 3.23 base); a pinned `18.4-alpine3.23` variant is also available if you need to lock the Postgres patch level.
[^drizzle-types]: Drizzle ORM docs, "Type API", [`orm.drizzle.team/docs/goodies#type-api`](https://orm.drizzle.team/docs/goodies). `$inferSelect` and `$inferInsert` are exported from every `pgTable` instance.
[^pgboss-drizzle-adapter]: pg-boss source, `dist/adapters/drizzle.ts`. The adapter's `executeSql(text, values)` rewrites `$1`,`$2` placeholders to Drizzle's tagged-template form and calls `tx.execute(sql(strings, ...reordered))`. The JSDoc states "without a runtime dependency on `drizzle-orm`" — the `sql` import is supplied by the caller.
[^pgboss-esm]: pg-boss `package.json` declares `"type": "module"` and the published `dist/index.d.ts` exports `PgBoss` as a named class. Verified against the `pg-boss@12.18.2` npm tarball.
[^pgboss-retries]: pg-boss API docs, [`timgit.github.io/pg-boss/`](https://timgit.github.io/pg-boss/), and the `QueueOptions` JSDoc in `node_modules/pg-boss/dist/types.d.ts`. The `retryLimit` default is 2 and `retryDelay` is 0; the backoff formula is documented under `retryBackoff`.
[^pgboss-cron]: pg-boss `timekeeper.js` source destructures `schedule()` options as `{ tz = 'UTC', key = '', ...rest }` — the default timezone is UTC, verified against the `pg-boss@12.18.2` tarball. Cron expressions are parsed by `cron-parser` 5.x (5-field standard cron, plus a 6-field seconds variant).
[^pgboss-types]: pg-boss type definitions, `node_modules/pg-boss/dist/types.d.ts`. The `QueueOptions` interface exports `expireInSeconds`, `retentionSeconds`, `deleteAfterSeconds`, `retryLimit`, `retryDelay`, `retryBackoff`, `retryDelayMax`, and `deadLetter`. No `retentionDays`.
