Back to home

Background Jobs for TanStack Start with pg-boss

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.

Why pg-boss?

A few options exist for background jobs in a Node/TypeScript app:

  • BullMQ — battle-tested, but requires Redis
  • Inngest / Trigger.dev — excellent developer experience, but hosted services with pricing
  • pg-boss — runs entirely on Postgres, self-hosted, no extra infra

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.

Installation

bun add pg-boss

A Typed Job Registry

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.

The Boss Singleton and Typed Helpers

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.

Starting pg-boss with a Nitro Plugin

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.
  • Retry policies live on the queue declaration, not in the handler. retryBackoff: true uses exponential backoff between attempts.
  • The close hook gives in-flight jobs a chance to finish before the process exits.

Writing Handlers

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.

Enqueuing a Job

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 Fan-out Pattern

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.

Adding a New Job

The pattern is always the same four steps:

  1. Add the job name and payload type to JobRegistry in src/jobs/registry.ts
  2. Write a handler (jobs: Job<T>[]) => Promise<void> in src/jobs/handlers/
  3. Register the queue and worker in src/plugins/pg-boss.ts
  4. Call sendJob("your/job", data) at the trigger point

TypeScript 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.

Wrapping Up

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.

Back to home