Step 7: Background Jobs
import { Steps, Aside } from ‘@astrojs/starlight/components’;
Your todos app works, but right now nothing happens when a new user signs up beyond creating their account. In a real app, you would send a welcome email, track analytics, provision defaults, or enqueue onboarding tasks.
In this step, you will:
- Listen to the built-in
UserRegisteredEventdomain event. - Create a background job handler that “sends” a welcome email.
- Wire the event listener to enqueue the job automatically.
- Verify the job runs in local development.
Why Background Jobs?
Section titled “Why Background Jobs?”You could send the welcome email directly inside the registration handler, but that creates problems:
- Slow responses — the user waits while your app talks to an email API (100-500ms).
- Fragile — if the email API is down, registration fails entirely.
- No retries — a transient network error means the email is lost forever.
Background jobs solve all three problems. The registration completes immediately, and the email is sent asynchronously with automatic retries.
Domain Events
Section titled “Domain Events”CruzJS fires domain events at key moments in the application lifecycle. Events are fire-and-forget notifications: the code that fires the event does not wait for listeners to finish.
Built-in events include:
| Event | Fires when |
|---|---|
UserRegisteredEvent | A new user completes registration |
UserLoggedInEvent | A user logs in successfully |
OrgCreatedEvent | A new organization is created |
MemberInvitedEvent | A user is invited to an organization |
You can also define custom events for your own features. For this tutorial, UserRegisteredEvent is all you need.
Step 1: Create the Job Handler
Section titled “Step 1: Create the Job Handler”Create the directory for the welcome feature:
mkdir -p src/features/welcomeCreate src/features/welcome/welcome-email.handler.ts:
import { injectable, inject } from 'inversify';import { JobHandler, JobResult, JOB_HANDLER } from '@cruzjs/core/jobs';
export const WELCOME_EMAIL_JOB = 'todos/welcome-email';
export type WelcomeEmailPayload = { userId: string; email: string; name: string | null;};
@injectable()export class WelcomeEmailHandler implements JobHandler { metadata = { type: WELCOME_EMAIL_JOB, description: 'Send welcome email to new user', };
async run(job: { payload: WelcomeEmailPayload }): Promise<JobResult> { const { email, name } = job.payload;
// In production, you would call your email service here: // await this.emailService.send({ // to: email, // template: 'welcome', // data: { name }, // }); // // For this tutorial, we log to the console to prove the job ran. console.log(`Welcome email sent to ${name ?? email} <${email}>`);
return { success: true, summary: { recipient: email } }; }}Anatomy of a Job Handler
Section titled “Anatomy of a Job Handler”A job handler is a class that implements the JobHandler interface:
metadata.type— a unique string that identifies this job type. Use a namespaced format (feature/job-name) to avoid collisions.metadata.description— a human-readable description shown in admin tools and logs.run(job)— the actual work. Receives the job payload and returns aJobResult.JobResult— an object withsuccess: booleanand an optionalsummaryfor logging. Ifsuccessisfalse, the job will be retried.
Step 2: Create the Event Listener
Section titled “Step 2: Create the Event Listener”The event listener bridges the gap between “a user registered” and “enqueue a welcome email job.”
Create src/features/welcome/welcome-email.listener.ts:
import { injectable, inject } from 'inversify';import { UserRegisteredEvent } from '@cruzjs/core/auth/events';import { JobService } from '@cruzjs/core/jobs';import { WELCOME_EMAIL_JOB } from './welcome-email.handler';import type { WelcomeEmailPayload } from './welcome-email.handler';
@injectable()export class WelcomeEmailListener { constructor( @inject(JobService) private readonly jobService: JobService, ) {}
async handle(event: UserRegisteredEvent): Promise<void> { await this.jobService.createJob<WelcomeEmailPayload>({ type: WELCOME_EMAIL_JOB, payload: { userId: event.userId, email: event.email, name: event.name, }, }); }}How This Works
Section titled “How This Works”- When a user registers, the auth system fires
UserRegisteredEventwith the user’s ID, email, and name. - CruzJS looks up all listeners registered for
UserRegisteredEventand calls their handler method. WelcomeEmailListener.handle()callsjobService.createJob(), which:- Inserts a row into the
Jobtable with statusPENDING. - In local dev, immediately picks up and runs the job in-process.
- In production, enqueues the job to a Cloudflare Queue for async processing.
- Inserts a row into the
- The
WelcomeEmailHandler.run()method executes and logs the message.
The event listener does not send the email directly. It creates a job, which is a durable record that will be processed with retries. If the job fails on the first attempt, it will be retried automatically.
Step 3: Create the Module
Section titled “Step 3: Create the Module”Create src/features/welcome/welcome.module.ts:
import { Module } from '@cruzjs/core/di';import { JOB_HANDLER } from '@cruzjs/core/jobs';import { UserRegisteredEvent } from '@cruzjs/core/auth/events';import { WelcomeEmailHandler } from './welcome-email.handler';import { WelcomeEmailListener } from './welcome-email.listener';
@Module({ providers: [WelcomeEmailHandler, WelcomeEmailListener], events: [ { event: UserRegisteredEvent, useClass: WelcomeEmailListener, method: 'handle', }, ], jobHandlers: [ { provide: JOB_HANDLER, useClass: WelcomeEmailHandler, multi: true }, ],})export class WelcomeModule {}Module Configuration Explained
Section titled “Module Configuration Explained”providers— registers both classes in the DI container so they can be instantiated and have their dependencies injected.events— tells CruzJS to callWelcomeEmailListener.handle()whenever aUserRegisteredEventfires. You can register multiple listeners for the same event.jobHandlers— registers the handler with the job processing system. Themulti: trueoption allows multiple handlers to be registered under the sameJOB_HANDLERtoken (each handling a different job type).
Step 4: Create a Barrel Export
Section titled “Step 4: Create a Barrel Export”Create src/features/welcome/index.ts:
export { WelcomeModule } from './welcome.module';Step 5: Register the Module
Section titled “Step 5: Register the Module”Open src/server.cloudflare.ts and add WelcomeModule:
import { createCruzApp } from '@cruzjs/core';import { StartModule } from '@cruzjs/start';import { TodosModule } from './features/todos';import { WelcomeModule } from './features/welcome'; // add thisimport * as schema from './database/schema';
export default createCruzApp({ schema, modules: [ StartModule, TodosModule, WelcomeModule, // add this ], pages: () => import('virtual:react-router/server-build'),});That is all the code you need. No queue configuration, no worker setup, no infrastructure changes for local development.
Step 6: Test It
Section titled “Step 6: Test It”-
Make sure the dev server is running:
cruz dev -
Open your browser and register a new user account (or use the registration page at
/auth/register). -
Check the terminal where
cruz devis running. You should see:Welcome email sent to Jane <jane@example.com> -
Verify the job was recorded in the database:
Terminal window cruz db query "SELECT id, type, status, summary FROM Job ORDER BY createdAt DESC LIMIT 5"You should see a row like:
id | type | status | summary------------|----------------------|-----------|-------------------clx1abc... | todos/welcome-email | COMPLETED | {"recipient":"jane@example.com"}
Job Lifecycle
Section titled “Job Lifecycle”Every job moves through a state machine:
PENDING → PROCESSING → COMPLETED ↘ FAILED → PENDING (retry)- PENDING — the job is created and waiting to be picked up.
- PROCESSING — a worker has claimed the job and is running it.
- COMPLETED — the handler returned
{ success: true }. The job is done. - FAILED — the handler threw an error or returned
{ success: false }. The job will be retried.
Retry Logic
Section titled “Retry Logic”Failed jobs are retried with exponential backoff:
| Attempt | Delay |
|---|---|
| 1st retry | 10 seconds |
| 2nd retry | 60 seconds |
| 3rd retry | 5 minutes |
After 3 failed attempts, the job is marked as DEAD and no further retries are attempted. You can monitor dead jobs in the database and investigate failures.
Production: Cloudflare Queues
Section titled “Production: Cloudflare Queues”In production, jobs are processed through Cloudflare Queues for true asynchronous processing. The transition is automatic — no code changes needed.
When you deploy with cruz deploy, CruzJS:
- Creates a Cloudflare Queue if one does not exist.
- Configures the queue consumer in your
wrangler.toml. - Routes
jobService.createJob()calls to the queue instead of in-process execution.
The queue consumer picks up messages and runs the same WelcomeEmailHandler.run() method. Your handler code is identical in both environments.
Adding a Real Email Service
Section titled “Adding a Real Email Service”To actually send emails in production, inject an email service into your handler:
import { injectable, inject } from 'inversify';import { JobHandler, JobResult } from '@cruzjs/core/jobs';import { EmailService } from '@cruzjs/core/email';import type { WelcomeEmailPayload } from './welcome-email.handler';
export const WELCOME_EMAIL_JOB = 'todos/welcome-email';
@injectable()export class WelcomeEmailHandler implements JobHandler { metadata = { type: WELCOME_EMAIL_JOB, description: 'Send welcome email to new user', };
constructor( @inject(EmailService) private readonly email: EmailService, ) {}
async run(job: { payload: WelcomeEmailPayload }): Promise<JobResult> { const { email, name } = job.payload;
await this.email.send({ to: email, subject: 'Welcome to My Todos!', template: 'welcome', data: { name: name ?? 'there' }, });
return { success: true, summary: { recipient: email } }; }}CruzJS supports Resend, Postmark, and SendGrid out of the box. See the Email documentation for setup instructions.
Creating Custom Events
Section titled “Creating Custom Events”If you want to fire events from your own features (for example, when a todo is completed), define a custom event class:
export class TodoCompletedEvent { constructor( public readonly todoId: string, public readonly orgId: string, public readonly completedById: string, ) {}}Fire it from your service:
import { EventBus } from '@cruzjs/core/events';
@Injectable()export class TodosService { constructor( @Inject(DRIZZLE) private readonly db: DrizzleDatabase, @Inject(EventBus) private readonly events: EventBus, ) {}
async complete(id: string, orgId: string, userId: string) { const [todo] = await this.db .update(todos) .set({ completed: true, updatedAt: new Date() }) .where(and(eq(todos.id, id), eq(todos.orgId, orgId))) .returning();
if (todo) { await this.events.emit(new TodoCompletedEvent(id, orgId, userId)); }
return todo; }}Then register listeners for TodoCompletedEvent in your module, exactly as you did for UserRegisteredEvent.
File Structure
Section titled “File Structure”After this step, your welcome feature looks like:
src/features/welcome/ index.ts # barrel export welcome.module.ts # module registration welcome-email.handler.ts # job handler welcome-email.listener.ts # event listenerThis is a clean, self-contained feature. The welcome module knows nothing about how registration works — it just listens for the event and enqueues a job. If you remove the module from server.cloudflare.ts, the registration flow continues to work without the welcome email.
Next: Testing →