QUESTPIE
Build Your BackendBusiness Logic

Jobs

Background tasks with typed payloads — send emails, process data, schedule work.

Jobs are background tasks that run outside the request lifecycle. They're ideal for sending emails, processing data, generating reports, or any work that shouldn't block an API response.

Defining a Job

jobs/send-appointment-confirmation.ts
import { job } from "questpie/services";
import z from "zod";

export default job({
	name: "sendAppointmentConfirmation",
	schema: z.object({
		appointmentId: z.string(),
		customerId: z.string(),
	}),
	handler: async ({ payload, email, collections }) => {
		const customer = await collections.user.findOne({
			where: { id: payload.customerId },
		});
		if (!customer) return;

		const appointment = await collections.appointments.findOne({
			where: { id: payload.appointmentId },
			with: { barber: true, service: true },
		});
		if (!appointment) return;

		await email.sendTemplate({
			template: "appointmentConfirmation",
			input: {
				customerName: customer.name,
				appointmentId: appointment.id,
				barberName: appointment.barber.name,
				serviceName: appointment.service.name,
				scheduledAt: appointment.scheduledAt.toISOString(),
			},
			to: customer.email,
		});
	},
});

Place job files in jobs/. The filename becomes the job key: send-appointment-confirmation.tssendAppointmentConfirmation.

Publishing Jobs

Publish jobs from hooks, routes, or other jobs via the queue context:

collections/appointments.ts
.hooks({
  afterChange: async ({ data, operation, queue }) => {
    if (operation === "create") {
      await queue.sendAppointmentConfirmation.publish({
        appointmentId: data.id,
        customerId: data.customer,
      });
    }
  },
})

The queue object is fully typed — autocompletion shows all available jobs and their expected payloads.

Running Workers

Publishing a job only writes it to the configured queue adapter. It does not execute the handler inside the current request. Start a separate worker process that imports the generated app and listens for queued jobs:

src/worker.ts
import { app } from "#questpie";

await app.queue.listen({
	teamSize: 5,
	batchSize: 3,
});

Add a script next to your web app scripts:

package.json
{
	"scripts": {
		"dev": "bun --bun vite dev",
		"dev:worker": "bun --bun run --watch ./src/worker.ts",
		"worker": "bun --bun run ./src/worker.ts"
	}
}

Run the app and the worker as two processes:

bun run dev
bun run dev:worker

In production, deploy the HTTP app and the worker separately with the same environment variables and database connection. The HTTP app publishes jobs. The worker process consumes them and runs the job handlers. You can run multiple worker processes for horizontal scaling.

listen() registers long-running consumers and installs graceful shutdown handlers for SIGINT and SIGTERM by default. Use teamSize for per-process concurrency and batchSize for how many jobs the adapter fetches at once.

One-Off Processing

For serverless cron, tests, or maintenance scripts, process a bounded batch with runOnce() instead of starting a long-running worker:

scripts/process-jobs-once.ts
import { app } from "#questpie";

const result = await app.queue.runOnce({
	batchSize: 10,
	jobs: ["sendAppointmentConfirmation"],
});

await app.queue.stop();

console.log(`Processed ${result.processed} jobs`);

The jobs filter accepts either the generated registration key (sendAppointmentConfirmation) or the job definition name (sendAppointmentConfirmation, send-appointment-confirmation, etc.).

Handler Context

Job handlers receive the same AppContext as routes:

PropertyDescription
payloadValidated data matching the Zod schema
collectionsTyped collection API
emailEmail service
queuePublish other jobs
servicesUser-defined services
dbDatabase instance
loggerLogger service

Scheduled Jobs

Use startAfter when publishing a single delayed job:

await queue.sendAppointmentReminder.publish(
	{ appointmentId: "appt_123" },
	{ startAfter: new Date("2026-05-01T08:00:00Z") },
);

Use options.cron on the job definition for recurring work:

jobs/sync-inventory.ts
import { job } from "questpie/services";
import z from "zod";

export default job({
	name: "syncInventory",
	schema: z.object({}),
	options: {
		cron: "*/15 * * * *",
		retryLimit: 3,
	},
	handler: async ({ services, logger }) => {
		const result = await services.inventory.sync();
		logger.info({ result }, "Inventory synced");
	},
});

app.queue.listen() registers cron schedules automatically before starting workers. If you need a schedule-only setup step, call await app.queue.registerSchedules() and then await app.queue.stop(). Cron jobs must accept an empty payload because schedule registration validates schema.parse({}).

Email Templates

Jobs often send emails using templates defined in emails/:

emails/appointment-confirmation.ts
import { email } from "questpie/services";
import z from "zod";

export default email({
	name: "appointmentConfirmation",
	schema: z.object({
		customerName: z.string(),
		appointmentId: z.string(),
		barberName: z.string(),
		serviceName: z.string(),
		scheduledAt: z.string(),
	}),
	handler: ({ input }) => ({
		subject: "Appointment Confirmed",
		html: `
      <h1>Hi ${input.customerName}!</h1>
      <p>Your appointment has been confirmed.</p>
      <table>
        <tr><td>Barber</td><td>${input.barberName}</td></tr>
        <tr><td>Service</td><td>${input.serviceName}</td></tr>
        <tr><td>When</td><td>${input.scheduledAt}</td></tr>
      </table>
    `,
	}),
});

Queue Adapter

Configure the queue adapter in your runtime config. QUESTPIE uses pg-boss by default:

questpie.config.ts
import { runtimeConfig } from "questpie/app";
import { pgBossAdapter } from "questpie/adapters/pg-boss";

export default runtimeConfig({
	queue: {
		adapter: pgBossAdapter({
			connectionString: process.env.DATABASE_URL,
		}),
	},
	// ...
});

Real-World Example

Notifying subscribers when a blog post is published:

jobs/notify-blog-subscribers.ts
import { job } from "questpie/services";
import z from "zod";

export default job({
	name: "notifyBlogSubscribers",
	schema: z.object({
		postId: z.string(),
		title: z.string(),
		excerpt: z.string(),
		slug: z.string(),
	}),
	handler: async ({ payload, email, collections }) => {
		const users = await collections.user.find({ limit: 1000 });

		await Promise.allSettled(
			users.docs.map((user) =>
				email.sendTemplate({
					template: "newBlogPost",
					input: {
						title: payload.title,
						excerpt: payload.excerpt,
						slug: payload.slug,
					},
					to: user.email,
				}),
			),
		);
	},
});
  • Hooks — Trigger jobs from collection hooks
  • Typed Routes — Server-side logic
  • Queue — Queue infrastructure setup
  • Email — Email adapter configuration

On this page