Skip to content

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:

  1. Listen to the built-in UserRegisteredEvent domain event.
  2. Create a background job handler that “sends” a welcome email.
  3. Wire the event listener to enqueue the job automatically.
  4. Verify the job runs in local development.

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.

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:

EventFires when
UserRegisteredEventA new user completes registration
UserLoggedInEventA user logs in successfully
OrgCreatedEventA new organization is created
MemberInvitedEventA user is invited to an organization

You can also define custom events for your own features. For this tutorial, UserRegisteredEvent is all you need.

Create the directory for the welcome feature:

Terminal window
mkdir -p src/features/welcome

Create 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 } };
}
}

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 a JobResult.
  • JobResult — an object with success: boolean and an optional summary for logging. If success is false, the job will be retried.

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,
},
});
}
}
  1. When a user registers, the auth system fires UserRegisteredEvent with the user’s ID, email, and name.
  2. CruzJS looks up all listeners registered for UserRegisteredEvent and calls their handler method.
  3. WelcomeEmailListener.handle() calls jobService.createJob(), which:
    • Inserts a row into the Job table with status PENDING.
    • In local dev, immediately picks up and runs the job in-process.
    • In production, enqueues the job to a Cloudflare Queue for async processing.
  4. 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.

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 {}
  • providers — registers both classes in the DI container so they can be instantiated and have their dependencies injected.
  • events — tells CruzJS to call WelcomeEmailListener.handle() whenever a UserRegisteredEvent fires. You can register multiple listeners for the same event.
  • jobHandlers — registers the handler with the job processing system. The multi: true option allows multiple handlers to be registered under the same JOB_HANDLER token (each handling a different job type).

Create src/features/welcome/index.ts:

export { WelcomeModule } from './welcome.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 this
import * 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.

  1. Make sure the dev server is running: cruz dev

  2. Open your browser and register a new user account (or use the registration page at /auth/register).

  3. Check the terminal where cruz dev is running. You should see:

    Welcome email sent to Jane <jane@example.com>
  4. 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"}

Every job moves through a state machine:

PENDING → PROCESSING → COMPLETED
↘ FAILED → PENDING (retry)
  1. PENDING — the job is created and waiting to be picked up.
  2. PROCESSING — a worker has claimed the job and is running it.
  3. COMPLETED — the handler returned { success: true }. The job is done.
  4. FAILED — the handler threw an error or returned { success: false }. The job will be retried.

Failed jobs are retried with exponential backoff:

AttemptDelay
1st retry10 seconds
2nd retry60 seconds
3rd retry5 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.

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:

  1. Creates a Cloudflare Queue if one does not exist.
  2. Configures the queue consumer in your wrangler.toml.
  3. 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.

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.

If you want to fire events from your own features (for example, when a todo is completed), define a custom event class:

src/features/todos/events/todo-completed.event.ts
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.

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 listener

This 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 →