
Type-Safe Background Jobs with `pg-boss`, Zod, and TypeScript
If you’re anything like me, background jobs are one of those things that never get old. Whether it’s queueing email notifications, offloading expensive tasks, or scheduling reminders, there’s something deeply satisfying about systems that hum quietly in the background while doing the heavy lifting. Background jobs are like the stage crew of web applications — invisible but indispensable.
Recently, I put together a lightweight abstraction over pg-boss
to streamline how I manage jobs in TypeScript. It’s inspired by many patterns I’ve used over the years but refined to take full advantage of TypeScript’s type system and the runtime validation power of Zod.
You can view the full implementation in this GitHub Gist, but in this post, I’ll walk you through how and why this came together.
Why Wrap pg-boss?
pg-boss
is fantastic out of the box. It gives you persistence, scheduling, and retries — all backed by PostgreSQL. But when building TypeScript apps, especially at scale, I like to enforce structure. I want:
- Schema validation for job inputs.
- Typed handlers with clear contracts.
- A unified interface for defining, registering, and executing jobs.
And that’s exactly what this tiny abstraction gives us.
Introducing defineJob
: A Fluent API for Defining Jobs
We start with a defineJob()
function that returns a JobBuilder
instance. This builder pattern makes it easy to define what a job expects and how it behaves.
Here’s an example:
import z from "zod";
import { defineJob } from "/your-job-class-file";
const welcomeEmailJob = defineJob("welcome_email")
.input(z.object({ email: z.string().email() }))
.options({ retryLimit: 5 })
.work(async (jobs) => {
const job = jobs[0];
if (!job) throw new Error("No job data provided");
console.log(`[welcome_email] Sending email to ${job.data.email}`);
});
What’s happening here?
- We define a job called
"welcome_email"
. - We use Zod to enforce that the input includes a valid email.
- We configure retry logic.
- We attach a handler function.
All with full IntelliSense and type-safety. And if invalid data sneaks in at runtime? Zod will catch it.
Emitting Jobs
Once you’ve defined your job, sending it into the queue is just one method call away:
await welcomeEmailJob.emit({ email: "user@example.com" });
You can also schedule it for later:
await welcomeEmailJob.emitAfter({ email: "user@example.com" }, 60); // in 60s
Or trigger it via a cron expression:
await welcomeEmailJob.schedule({ email: "user@example.com" }, "0 8 * * *"); // every day at 8AM
A Better Way to Manage Jobs: JobManager
Defining individual jobs is nice. But if you have many, you’ll want a centralized way to manage them. That’s where JobManager
comes in.
import PgBoss from "pg-boss";
import { JobManager } from "./jobs";
const boss = new PgBoss(process.env.DATABASE_URL);
const jobs = new JobManager(boss).register(welcomeEmailJob);
await jobs.start();
Under the hood, JobManager
:
- Registers your jobs with
pg-boss
. - Starts all workers with proper handlers.
- Validates input data before queuing.
No boilerplate, no repeated code.
Runtime Safety Meets Developer Experience
One of my favorite things about this pattern is how it balances safety and simplicity:
- Zod ensures data is valid before the job ever hits the queue.
- TypeScript ensures you can’t accidentally pass the wrong data to the wrong job.
- The fluent API makes job definitions declarative and clean.
This approach also encourages creating small, focused jobs — each with its own schema, behavior, and configuration. You don’t need to guess what a job expects or how it works — it’s all there in one place.
Error Handling Done Right
By default, each job uses retry logic (retryLimit: 3
, retryDelay: 1000ms
) and wraps its handler with a try/catch block that logs failures. You can override this per job, but the goal is to make sure a failure doesn’t go silent.
work(async (job) => {
// your handler logic here
})
If something goes wrong, you’ll see a helpful error in your logs, tagged with the job name.
One Final Touch: Developer Ergonomics
This pattern scales surprisingly well. You can drop new jobs into your codebase without touching existing ones. Just:
- Define it with
defineJob()
- Register it with
JobManager
- Done.
Your dev tools will autocomplete everything, and you’ll get schema validation at the boundary.
Integrating with Nitro
If you’re using Nitro, you can wire up your job manager with a plugin like so:
import PgBoss from "pg-boss";
import { welcomeEmailJob } from "./welcome-email-job";
import { JobManager } from "./your-job-class-file";
async function setupJobs() {
console.log("Setting up jobs");
const boss = new PgBoss(process.env.DATABASE_URL ?? "");
boss.on("error", (error) => {
console.error("[PG BOSS] Error", error);
});
const jobs = new JobManager(boss).register(welcomeEmailJob);
await jobs.start().then(() => {
console.log("Jobs started");
});
}
export default defineNitroPlugin(() => {
void setupJobs();
});
This ensures your background jobs are registered and running as part of your server setup.
Wrap-Up
You don’t need a massive framework to build reliable job queues. With pg-boss
, TypeScript, and Zod, you can roll your own system that’s type-safe, resilient, and easy to extend.
👉 Grab the full code here on GitHub Gist.
I hope this gives you a solid foundation (or inspiration) for building a job system that fits your needs. Whether you’re queuing emails, sending reminders, or running data syncs — may your jobs run smoothly and your retries stay low.
Happy queueing!