Skip to content

Jobs

Some tasks don’t belong in the request-response cycle of a slash command. Generating a moderation report across thousands of cases, syncing bans across multiple servers, cleaning up expired mutes — these are things that should happen in the background, not while a user stares at “Bot is thinking…”

@warden/jobs gives you persistent, reliable background processing with scheduled task support. Jobs survive bot restarts, can be retried on failure, and are prioritized, delayed, and monitored.

Terminal window
pnpm add @warden/jobs

Then register the plugin in your bootstrap file:

import {Bot} from '@warden/core';
import {JobsServiceProvider} from '@warden/jobs';
new Bot()
.discover(d => d
.interactions('./src/{commands,buttons,modals,menus}/**/*.ts')
.events('./src/events/**/*.ts')
.jobs('./src/jobs/**/*.ts'))
.plugins([JobsServiceProvider])
.intents(i => i.defaults())
.partials(p => p.defaults())
.start();

The plugin ships with three built-in adapters so you can pick the right backend for your environment. The default is in-memory — fine for development; for production you’ll want BullMQ or Database.

Pick your adapter in config/jobs.ts:

In-process queue with zero external dependencies. Jobs process immediately in the same process. Ideal for development and testing where you don’t want to run Redis.

config/jobs.ts
import {Config} from '@warden/core';
import {JobsServiceProvider, MemoryAdapter} from '@warden/jobs';
export default Config.define(JobsServiceProvider, {
adapter: new MemoryAdapter(),
});
AdapterPersistentShared across instancesExternal dependency
MemoryAdapterNoNoNone
BullMQAdapterYesYesRedis server
DatabaseAdapterYesYesDatabase (already have it)

The adapter field accepts any class that implements the QueueAdapter interface, so you’re free to write your own or use community adapters — see Building your own plugin for the adapter pattern.

A worker is a class that processes a specific type of background job. Define one with the @worker() decorator:

import {worker, Worker} from '@warden/jobs';
@worker({name: 'generate-mod-report'})
export default class GenerateModReportWorker implements Worker {
constructor(private caseService: CaseService, private logger: Logger) {}
public async process(payload: {guildId: string; requestedBy: string}): Promise<void> {
this.logger.info('Generating mod report', {guildId: payload.guildId});
const cases = await this.caseService.getAllCases(payload.guildId);
const report = await this.caseService.generateReport(cases);
await this.caseService.saveReport(payload.guildId, report);
this.logger.info('Mod report generated', {guildId: payload.guildId, caseCount: cases.length});
}
}

Workers are picked up by the .jobs(...) discovery bucket, just like other decorated classes. They have full dependency injection support — inject any service you need. The name must match what you use when dispatching the job.

ParameterTypeRequiredDescription
namestringYesUnique job name, used when dispatching

To push a job onto the queue, inject the Queue service and call dispatch():

import {command, Command, CommandInteraction, Params, reply} from '@warden/core';
import {Queue} from '@warden/jobs';
@command({parent: 'mod', name: 'stats', description: 'Generate moderation statistics'})
export default class ModStatsCommand extends Subcommand {
constructor(private queue: Queue) {}
public async execute(interaction: CommandInteraction, params: Params): Promise<void> {
await this.queue.dispatch('generate-mod-report', {
guildId: interaction.guildId!,
requestedBy: interaction.user.id,
});
await interaction.followUp({embeds: [
Embed.success('Your moderation report is being generated. You\'ll be notified when it\'s ready.')
]});
}
}

The command returns instantly, and the report generation happens in the background. No timeout worries, no “Bot is thinking…” spinner holding everyone up.

The dispatch() method accepts an optional third argument for fine-grained control:

await this.queue.dispatch('generate-mod-report', payload, {
delay: 5000, // wait 5 seconds before processing
attempts: 3, // retry up to 3 times on failure
backoff: 'exponential', // exponential backoff between retries
priority: 1, // higher priority jobs are processed first
});
OptionTypeDefaultDescription
delaynumber0Milliseconds to wait before the job becomes available
attemptsnumber1Maximum number of processing attempts
backoff'fixed' | 'exponential''fixed'Backoff strategy between retries
prioritynumber0Priority level (higher = processed sooner)

Sometimes you need a job to run on a recurring schedule — daily cleanup of expired mutes, weekly report generation, hourly stats aggregation. The @scheduled() decorator handles this:

import {scheduled, Worker} from '@warden/jobs';
@scheduled({name: 'cleanup-expired-mutes', schedule: '0 0 * * *'})
export default class CleanupExpiredMutesWorker implements Worker {
constructor(private muteService: MuteService, private logger: Logger) {}
public async process(payload: Record<string, never>): Promise<void> {
const removed = await this.muteService.removeExpired();
this.logger.info(`Cleaned up ${removed} expired mutes`);
}
}

The schedule field uses standard cron syntax. This job runs at midnight every day — and crucially, it survives bot restarts. If the bot is down when a scheduled job should have fired, it will catch up when it comes back online.

ParameterTypeRequiredDescription
namestringYesUnique job name
schedulestringYesCron expression (e.g. '0 0 * * *' for daily at midnight)
payloadobjectNoStatic payload passed to process() on each run

You may also pass a static payload to scheduled jobs:

@scheduled({
name: 'weekly-mod-digest',
schedule: '0 9 * * 1', // every Monday at 9am
payload: {type: 'weekly', includeCharts: true},
})
export default class WeeklyModDigestWorker implements Worker {
public async process(payload: {type: string; includeCharts: boolean}): Promise<void> {
// generate and send the weekly digest
}
}

For one-off scheduled tasks — things that aren’t recurring but need to happen at a specific time — inject the Scheduler service:

import {Scheduler} from '@warden/jobs';
@command({parent: 'mod', name: 'mute', description: 'Mute a user'})
export default class ModMuteCommand extends Subcommand {
constructor(private scheduler: Scheduler, private muteService: MuteService) {}
public async execute(interaction: CommandInteraction, params: Params): Promise<void> {
const target = interaction.options.getUser('user')!;
const duration = interaction.options.getInteger('duration')!;
await this.muteService.mute(interaction.guildId!, target.id);
// Schedule the unmute for later
await this.scheduler.in(duration * 60 * 1000, 'unmute-user', {
guildId: interaction.guildId!,
userId: target.id,
});
await interaction.followUp({embeds: [
Embed.success(`${target.username} has been muted for ${duration} minutes.`)
]});
}
}

The Scheduler provides two methods:

// Schedule for a specific date
await this.scheduler.at(new Date('2025-01-01T00:00:00'), 'new-year-announcement', {
message: 'Happy New Year!',
});
// Schedule after a delay (in milliseconds)
await this.scheduler.in(30 * 60 * 1000, 'unmute-user', {
guildId: '123',
userId: '456',
});

Both methods create a one-shot job that fires once and is then removed. The job is persisted in the queue backend, so it survives restarts.

You may have noticed that @warden/core already has a @job() decorator. So what’s the difference?

The core @job() is a simple, in-memory job runner. It’s great for fire-and-forget tasks where you don’t need persistence — logging, sending a quick webhook, updating a cache. But if the bot restarts, any pending in-memory jobs are lost.

@warden/jobs is the upgrade path for when you need:

  • Persistence — jobs survive restarts
  • Retries — automatic retry with configurable backoff
  • Scheduling — cron-based recurring jobs
  • Priority — process important jobs first
  • Monitoring — inspect queue depth, job status, failure rates
  • Clustering — multiple bot instances sharing the same queue

Think of @job() as setTimeout and @warden/jobs as a proper task queue. Start with @job(), and when you outgrow it, the migration is straightforward — your worker classes look almost identical.

When a moderator runs /mod stats, generate the report in the background and DM it when ready:

@worker({name: 'generate-mod-report'})
export default class GenerateModReportWorker implements Worker {
constructor(
private caseService: CaseService,
private bot: BotClient,
) {}
public async process(payload: {guildId: string; requestedBy: string}): Promise<void> {
const cases = await this.caseService.getAllCases(payload.guildId);
const report = await this.caseService.generateReport(cases);
const attachment = this.caseService.formatAsCSV(report);
const user = await this.bot.users.fetch(payload.requestedBy);
await user.send({
content: 'Your moderation report is ready.',
files: [attachment],
});
}
}

Run every day at midnight, remove expired mutes, and log the results:

@scheduled({name: 'cleanup-expired-mutes', schedule: '0 0 * * *'})
export default class CleanupExpiredMutesWorker implements Worker {
constructor(
private muteService: MuteService,
private modLog: ModLogService,
private logger: Logger,
) {}
public async process(): Promise<void> {
const expired = await this.muteService.findExpired();
for (const mute of expired) {
await this.muteService.unmute(mute.guildId, mute.userId);
}
if (expired.length > 0) {
await this.modLog.send(
Embed.info(`Scheduled cleanup: removed ${expired.length} expired mute(s).`)
.setTimestamp()
);
}
this.logger.info(`Mute cleanup completed`, {removed: expired.length});
}
}

When muting a user, schedule the unmute as a persistent job with retries in case the Discord API is unavailable:

@worker({name: 'unmute-user'})
export default class UnmuteUserWorker implements Worker {
constructor(private muteService: MuteService) {}
public async process(payload: {guildId: string; userId: string}): Promise<void> {
await this.muteService.unmute(payload.guildId, payload.userId);
}
}
// In your mute command:
await this.scheduler.in(duration * 60 * 1000, 'unmute-user', {
guildId: interaction.guildId!,
userId: target.id,
});

The unmute happens at the right time, even if the bot restarts in between. And with retry support, a temporary Discord outage won’t leave someone muted forever.