Type-Safe Background Jobs with `pg-boss`, Zod, and TypeScript

Type-Safe Background Jobs with `pg-boss`, Zod, and TypeScript

4 min read

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:

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?

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: "[email protected]" });

You can also schedule it for later:

await welcomeEmailJob.emitAfter({ email: "[email protected]" }, 60); // in 60s

Or trigger it via a cron expression:

await welcomeEmailJob.schedule({ email: "[email protected]" }, "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:

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:

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:

  1. Define it with defineJob()
  2. Register it with JobManager
  3. 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!

back