TanStack Start makes it easy to write backend logic with server functions. But some work doesn’t belong in a request/response cycle — sending welcome emails, syncing contacts to a CRM, kicking off onboarding flows. These tasks are slow, they call external services that can fail, and the user doesn’t need to wait for them.
The answer is a background job queue. This post walks through integrating pg-boss into a TanStack Start app. pg-boss uses your existing PostgreSQL database as the queue, so there’s no extra infrastructure to run. Jobs are durable, retryable, and fully typed.
A few options exist for background jobs in a Node/TypeScript app:
If you’re already running Postgres, pg-boss is the simplest path. It creates its own schema in your database, handles retries and backoff, and exposes a straightforward TypeScript API.
bun add pg-boss
Before writing any plumbing, define a central registry that maps job names to their payload shapes. This becomes the single source of truth — every send and work call flows through it, so TypeScript catches mismatches at compile time.
// src/jobs/registry.ts
export interface JobRegistry {
"user/created": { userId: string; email: string; name: string };
"user/welcome-email": { userId: string; email: string; name: string };
"user/resend-contact": { email: string };
}
export type JobName = keyof JobRegistry;
Adding a new job is one line here. Everything else — the helpers, the handlers, the enqueue call sites — picks up the types automatically.
pg-boss is stateful: it holds an internal connection pool and must be started before use. You want exactly one instance for the lifetime of the server process.
There’s a wrinkle in development: Vite’s HMR re-evaluates modules on every file save. Without a guard, each save would create a fresh, unstarted PgBoss instance and leak the old one. The fix is to cache the instance on globalThis.
// src/jobs/index.ts
import type { Job, SendOptions, WorkOptions } from "pg-boss";
import PgBoss from "pg-boss";
import { env } from "@/env";
import type { JobName, JobRegistry } from "./registry";
const g = globalThis as typeof globalThis & { __pgBoss?: PgBoss };
export const boss: PgBoss = g.__pgBoss ?? new PgBoss(env.DATABASE_URL);
g.__pgBoss = boss;
export async function sendJob<N extends JobName>(
name: N,
data: JobRegistry[N],
options?: SendOptions,
): Promise<string | null> {
return boss.send(name, data, options);
}
export async function workJob<N extends JobName>(
name: N,
handler: (jobs: Job<JobRegistry[N]>[]) => Promise<void>,
options?: WorkOptions,
): Promise<string> {
return options
? boss.work<JobRegistry[N]>(name, options, handler)
: boss.work<JobRegistry[N]>(name, handler);
}
sendJob and workJob are thin wrappers around boss.send and boss.work, but the generic constraints tie the job name to its payload type. Pass the wrong shape and TypeScript will tell you before the code runs.
Nitro plugins are async functions that run once when the server starts — the right place to initialise pg-boss, declare queues, and register workers.
// src/plugins/pg-boss.ts
import { definePlugin } from "nitro";
import { boss, workJob } from "@/jobs";
import {
handleUserCreated,
handleUserWelcomeEmail,
handleUserResendContact,
} from "@/jobs/handlers/user";
export default definePlugin(async (nitroApp) => {
boss.on("error", console.error);
await boss.start();
await boss.createQueue("user/created");
await boss.createQueue("user/welcome-email", {
retryLimit: 3,
retryBackoff: true,
});
await boss.createQueue("user/resend-contact", {
retryLimit: 3,
retryBackoff: true,
});
await workJob("user/created", handleUserCreated);
await workJob("user/welcome-email", handleUserWelcomeEmail);
await workJob("user/resend-contact", handleUserResendContact);
nitroApp.hooks.hook("close", async () => {
await boss.stop({ graceful: true });
});
});
A few things worth noting:
boss.start() runs pg-boss’s schema migrations automatically on first boot, and is a no-op on subsequent starts.createQueue is idempotent — calling it on every startup is safe and ensures the queue exists with the right policy before any workers register.retryBackoff: true uses exponential backoff between attempts.close hook gives in-flight jobs a chance to finish before the process exits.Handlers receive a batch of jobs — pg-boss polls the queue and delivers them in groups. Process them sequentially (safer for rate-limited APIs) or in parallel depending on your needs.
// src/jobs/handlers/user.ts
import type { Job } from "pg-boss";
import type { JobRegistry } from "@/jobs/registry";
import { sendJob } from "@/jobs";
import { resend } from "@/mail";
import { WelcomeEmail } from "@/mail/transactional/welcome";
export async function handleUserCreated(
jobs: Job<JobRegistry["user/created"]>[],
): Promise<void> {
for (const job of jobs) {
const { userId, email, name } = job.data;
await Promise.allSettled([
sendJob("user/welcome-email", { userId, email, name }),
sendJob("user/resend-contact", { email }),
]);
}
}
export async function handleUserWelcomeEmail(
jobs: Job<JobRegistry["user/welcome-email"]>[],
): Promise<void> {
if (process.env.NODE_ENV !== "production") return;
for (const job of jobs) {
await resend.emails.send({
from: "onboarding@yourapp.com",
to: job.data.email,
subject: "Welcome!",
react: <WelcomeEmail name={job.data.name} />,
});
}
}
export async function handleUserResendContact(
jobs: Job<JobRegistry["user/resend-contact"]>[],
): Promise<void> {
if (process.env.NODE_ENV !== "production") return;
for (const job of jobs) {
await resend.contacts.create({ email: job.data.email });
}
}
If a handler throws, pg-boss marks the job as failed and schedules a retry according to the queue’s policy. You don’t need to catch and re-throw — just let errors propagate.
The early NODE_ENV guard on the email and contact handlers prevents hitting external APIs during local development. The jobs still flow through the queue, which is useful for testing the plumbing, but the side effects are suppressed.
From any server function, import sendJob and call it after the triggering event:
// src/auth.tsx
import { sendJob } from "@/jobs";
// Inside the sign-up handler, after the user record is created:
await sendJob("user/created", {
userId: user.id,
email: user.email,
name: user.name,
});
sendJob returns a job ID and resolves immediately. The server function completes, the response reaches the client, and the worker picks up the job in the background.
The example above only enqueues a single user/created job at the signup call site. The handleUserCreated handler is responsible for fanning out to the downstream jobs:
signup → "user/created"
↓
handleUserCreated
↙ ↘
"user/welcome-email" "user/resend-contact"
This is intentional. The signup code stays simple — it fires one event and moves on. All decisions about what post-signup work needs to happen live in handleUserCreated. Adding a new action (Slack notification, onboarding task creation, analytics event) means adding one more sendJob call in that handler, not touching the signup path.
Promise.allSettled is important here: it ensures all fan-out jobs are enqueued even if one of the sendJob calls throws. Each downstream job then has its own independent retry policy, so a failure in the welcome email doesn’t prevent the contact from being created.
The pattern is always the same four steps:
JobRegistry in src/jobs/registry.ts(jobs: Job<T>[]) => Promise<void> in src/jobs/handlers/src/plugins/pg-boss.tssendJob("your/job", data) at the trigger pointTypeScript will surface any mismatch between a job name and its payload before the code runs — including at every existing call site if you change the shape of a job.
pg-boss fits naturally into a TanStack Start app. The Nitro plugin lifecycle maps directly onto pg-boss’s start/stop API. A typed registry keeps payloads safe without ceremony. And the fan-out pattern keeps trigger sites clean as the set of post-event actions grows over time.
The only infrastructure requirement is the Postgres database you’re already running.
If you’re looking for a complete starter kit with pg-boss integrated, register your interest in Catalyst today.