Cron jobs, delayed messages, and scheduled tasks for Discord.NET bots.
Because scheduling should be easy, not a project in itself.
dotnet add package Discord.Net.SchedulerOr for persistent storage:
dotnet add package Discord.Net.Scheduler.Redis
dotnet add package Discord.Net.Scheduler.EntityFrameworkCoreEvery Discord.NET bot eventually needs to send a message at a specific time —
be it a daily announcement, a reminder 30 minutes after someone joins a voice
channel, or a weekly digest. Most people just throw in a Timer or add Quartz.NET
and fight with it for a day. This library is the middle ground: it understands
Discord.NET types natively, doesn't require you to learn a whole new job system,
and still gives you the serious stuff (persistent stores, middleware, telemetry).
Register the scheduler in your DI container:
builder.Services.AddDiscordScheduler(options =>
{
options.PollingIntervalMs = 1000;
options.RecoverJobsOnStart = true;
});Get the scheduler and start scheduling:
var scheduler = host.Services.GetRequiredService<JobScheduler>();
// Send a message in 30 seconds
await scheduler.ScheduleAsync(job => job
.SendMessage(channelId, "⏰ This fires in 30 seconds!")
.In(TimeSpan.FromSeconds(30))
.WithName("welcome-message"));
// Daily at 8 AM, timezone-aware
await scheduler.ScheduleRecurringAsync(job => job
.SendMessage(channelId, "🌅 Good morning!")
.WithCron("0 8 * * *")
.WithTimezone(TimeZoneInfo.FindSystemTimeZoneById("FLE Standard Time")));That's it. The scheduler runs as a BackgroundService, checks for due jobs
every second (configurable), handles retries, and logs everything.
await scheduler.ScheduleAsync(job => job
.SendMessage(channelId, "Hello from the past!")
.At(new DateTimeOffset(2026, 12, 25, 10, 0, 0, TimeSpan.Zero)));var embed = new EmbedBuilder()
.WithTitle("Scheduled Announcement")
.WithDescription("This was planned in advance!")
.WithColor(Color.Purple)
.Build();
await scheduler.ScheduleAsync(job => job
.SendEmbed(channelId, embed, "Check this out!")
.WithDelay(TimeSpan.FromHours(2)));await scheduler.ScheduleAsync(job => job
.EditMessage(channelId, messageId, "Updated content!")
.At(new DateTimeOffset(2026, 6, 15, 12, 0, 0, TimeSpan.Zero)));When you need something custom:
await scheduler.ScheduleAsync(job => job
.Execute(async (ctx, ct) =>
{
var logger = ctx.GetService<ILogger<Program>>();
logger.LogInformation("Running maintenance at {Time}", DateTimeOffset.UtcNow);
var guild = ctx.Client.GetGuild(guildId);
var channels = guild.Channels;
return JobResult.Success(TimeSpan.Zero);
})
.WithCron("0 3 * * *")
.WithName("maintenance-task"));The ctx gives you access to the Discord client and DI container —
ctx.GetService<T>() and ctx.GetOptionalService<T>().
RecurringJobBuilder has helpers so you don't always have to type cron:
await scheduler.ScheduleRecurringAsync(job => job
.SendMessage(channelId, "it's midnight")
.Daily());
await scheduler.ScheduleRecurringAsync(job => job
.SendMessage(channelId, "top of the hour")
.Hourly());
await scheduler.ScheduleRecurringAsync(job => job
.SendMessage(channelId, "sunday morning")
.Weekly());
await scheduler.ScheduleRecurringAsync(job => job
.SendMessage(channelId, "new month!")
.Monthly());Or write your own cron:
.WithCron("*/15 * * * *") // every 15 minutes
.WithCron("0 9-17 * * 1-5") // 9 AM to 5 PM, weekdays
.WithCron("@hourly") // named shortcuts work tooJobs can react to Discord events, not just time. Mix and match with cron or delay.
await scheduler.ScheduleAsync(job => job
.SendMessage(channelId, $"Welcome <@123456789>!")
.WhenUserJoins(123456789));await scheduler.ScheduleAsync(job => job
.Execute(async (ctx, ct) =>
{
// reply logic
return JobResult.Success();
})
.WhenMessageSent(channelId: 987654321, pattern: "!help"));Without a pattern, every message in that channel triggers the job.
await scheduler.ScheduleAsync(job => job
.WithName("cleanup")
.Execute(async (ctx, ct) => { /* cleanup */ return JobResult.Success(); })
.AfterJob("backup-job"));
// "backup-job" runs first, then "cleanup" runs automatically
// The dependent job won't execute until its prerequisite completesChain jobs together. A dependent job waits until all its prerequisites succeed.
await scheduler.ScheduleAsync(job => job
.WithId("backup-db")
.Execute(/* backup database */));
await scheduler.ScheduleAsync(job => job
.WithId("backup-files")
.Execute(/* backup files */));
// Runs only after both backups complete
await scheduler.ScheduleAsync(job => job
.WithId("upload-s3")
.Execute(/* upload to S3 */)
.After("backup-db", "backup-files"));await scheduler.ScheduleAsync(job => job
.SendMessage(channelId, "Market is open!")
.WithCron("0 9-17 * * 1-5")
.RunIf(sp =>
{
var calendar = sp.GetRequiredService<IMarketCalendar>();
return Task.FromResult(calendar.IsTodayTradingDay());
}));The job only runs when the delegate returns true. The delegate receives the DI
container so you can resolve services without static singletons.
Numbers from benchmarks/Discord.Net.Scheduler.Benchmarks on .NET 10, in-memory store:
| Jobs | GetAllAsync | GetPendingAsync | Lookup by ID | Serialize | Deserialize | Allocated / 1000 jobs |
|---|---|---|---|---|---|---|
| 100 | ~2 µs | ~2 µs | ~0.05 µs | ~2 µs | ~1 µs | ~150 KB |
| 1 000 | ~12 µs | ~12 µs | ~0.05 µs | ~2 µs | ~1 µs | ~1.5 MB |
| 10 000 | ~110 µs | ~110 µs | ~0.05 µs | ~2 µs | ~1 µs | ~15 MB |
| 50 000 | ~540 µs | ~540 µs | ~0.05 µs | ~2 µs | ~1 µs | ~75 MB |
- Lookup is O(1) — the
InMemoryJobStoreandRedisJobStoreboth use dictionary-based indexing. - GetAllAsync / GetPendingAsync is O(n) — scales linearly with job count.
- Serialization is per-job, not affected by total count.
- Memory depends on job complexity (embeds are heavier than plain messages).
- The scheduler polls every
PollingIntervalMs(default 1 s) — even at 50 000 jobs, each poll completes in under a millisecond. The bottleneck is always your job's own work (Discord API calls, database writes), not the scheduler.
Run the benchmarks yourself:
dotnet run --project benchmarks/Discord.Net.Scheduler.Benchmarks -c Release// Schedule a one-time job
ScheduleAsync(Action<ScheduledJobBuilder> build)
// Schedule a recurring job
ScheduleRecurringAsync(Action<RecurringJobBuilder> build)
// Cancel a job
CancelAsync(string jobId)
// Move a job to a different time
RescheduleAsync(string jobId, DateTimeOffset newTime)
// Query jobs
GetJobAsync(string jobId)
GetPendingJobsAsync()
GetAllJobsAsync()
GetJobCountAsync()Every builder method returns the builder, so you chain as much or as little as you want:
.WithCron("0 8 * * *")
.WithTimezone(tz)
.WithRetries(5, TimeSpan.FromSeconds(30))
.ExpiresAt(DateTimeOffset.UtcNow.AddDays(7))
.WithMetadata("author", "ozotov");Standard 5-field cron. No 6th field for seconds, no year field — keeps it predictable.
| Shortcut | Expands to |
|---|---|
| @yearly / @annually | 0 0 1 1 * |
| @monthly | 0 0 1 * * |
| @weekly | 0 0 * * 0 |
| @daily / @midnight | 0 0 * * * |
| @hourly | 0 * * * * |
| Field | Range | Specials |
|---|---|---|
| Minute | 0-59 | * , - / |
| Hour | 0-23 | * , - / |
| Day of Month | 1-31 | * , - / |
| Month | 1-12 | * , - / |
| Day of Week | 0-7 (0 and 7 = Sunday) | * , - / |
Step values work: */15 for every 15 minutes, 0-30/5 for every 5 minutes
in the first half-hour. Named months/days are not supported — just use numbers.
By default jobs live in memory and die when your bot restarts. For production you'll want something that survives a crash.
services.AddDiscordScheduler();
services.AddRedisJobStore("localhost:6379");You can pass a ConnectionMultiplexer if you already have one:
services.AddRedisJobStore(multiplexer, keyPrefix: "mybot:jobs:");Works with any provider — SQLite, PostgreSQL, SQL Server:
services.AddDiscordScheduler();
services.AddEfCoreJobStore<SchedulerDbContext>(options =>
options.UseSqlite("Data Source=jobs.db"));Your context inherits from SchedulerDbContext:
public class MyDbContext : SchedulerDbContext
{
public MyDbContext(DbContextOptions options) : base(options) { }
}Implement IJobStore:
public class MyJobStore : IJobStore
{
public Task AddAsync(IScheduledJob job, CancellationToken ct) { /* ... */ }
// ... IJobStore has AddAsync, RemoveAsync, GetAsync, GetPendingAsync,
// GetAllAsync, UpdateAsync, MarkCompletedAsync, MarkFailedAsync,
// CountAsync, ClearAsync
}
services.AddJobStore<MyJobStore>();Jobs pass through a pipeline before execution. Middleware can log, measure, rate-limit, or short-circuit execution:
services.AddDiscordScheduler();
services.AddJobMiddleware<LoggingMiddleware>();
services.AddJobMiddleware<ErrorHandlingMiddleware>();Or add middleware inline:
var pipeline = host.Services.GetRequiredService<JobExecutionPipeline>();
pipeline.Use(async (ctx, next) =>
{
ctx.Metadata["started"] = DateTimeOffset.UtcNow.ToString("O");
var result = await next(ctx);
ctx.Metadata["finished"] = DateTimeOffset.UtcNow.ToString("O");
return result;
});Built-in middleware:
- LoggingMiddleware — logs execution start, duration, result
- ErrorHandlingMiddleware — catches exceptions, marks job failed
- You can write your own by implementing
IJobMiddleware
If you're targeting native AOT or just hate reflection, annotate your classes
with [CronJob]:
[CronJob("0 9 * * *")]
public partial class DailyDigestJob : IDiscordCronJob
{
private readonly DiscordSocketClient _client;
private readonly ILogger<DailyDigestJob> _logger;
public DailyDigestJob(DiscordSocketClient client, ILogger<DailyDigestJob> logger)
{
_client = client;
_logger = logger;
}
public async Task<JobResult> ExecuteAsync(JobContext context, CancellationToken ct)
{
var channel = await _client.GetChannelAsync(channelId) as IMessageChannel;
return JobResult.Success(TimeSpan.Zero);
}
}The source generator picks it up at compile time and generates
AddGeneratedCronJobs() — no runtime scanning, no reflection.
Every job execution emits metrics. Enabled by default via
SchedulerOptions.EnableMetrics = true.
| Metric | What it tracks |
|---|---|
| scheduler.job.scheduled | Counter — new jobs created |
| scheduler.job.completed | Counter — successful runs |
| scheduler.job.failed | Counter — jobs that errored out |
| scheduler.job.cancelled | Counter — cancelled before execution |
| scheduler.job.execution_time | Histogram — how long jobs took (ms) |
| scheduler.job.active | Gauge — currently executing |
services.AddOpenTelemetry()
.WithMetrics(metrics => metrics
.AddMeter("Discord.Net.Scheduler")
.AddConsoleExporter());| This | Quartz.NET | Hangfire | Manual Timer | |
|---|---|---|---|---|
| Discord-first API | yes | no | no | yes |
| Cron + timezones | yes | yes | yes | no |
| Persistent store | Redis, EF Core | many | SQL | no |
| Middleware pipeline | built-in | not really | no | no |
| AOT / source gen | yes | no | no | no |
| OpenTelemetry | yes | no | not built-in | no |
| Package size | ~200 KB | ~5 MB | ~3 MB | 0 |
| Setup time | 2 lines | ~50 lines | ~10 lines | you build it |
Quartz.NET is more battle-tested, but it's massive and has no idea what a Discord channel is. Hangfire is great for web apps but doesn't speak Discord either. If you just need to schedule Discord messages and you want it done today, this library is probably a better fit.
dotnet build
dotnet test # 43 tests, last I checked
dotnet pack # produces nuget packages in ./artifacts/The solution targets net8.0, net9.0, and net10.0 for the main library,
and netstandard2.0 for the source generator (so it works with any SDK).
src/
├── Discord.Net.Scheduler/ # core library
│ ├── Jobs/ # job types + JobWrapper for serialization
│ ├── Scheduling/ # CronParser, CronExpression, JobScheduler
│ │ └── JobStore/ # IJobStore + InMemoryJobStore
│ ├── Pipeline/ # JobExecutionPipeline, middleware
│ ├── Extensions/ # DI registration
│ └── Telemetry/ # SchedulerMetrics
├── Discord.Net.Scheduler.Redis/ # RedisJobStore
├── Discord.Net.Scheduler.EntityFrameworkCore/ # EfJobStore + SchedulerDbContext
└── Discord.Net.Scheduler.SourceGenerator/ # [CronJob] source generator
- No GUI dashboard yet (planned, but I wanted the core solid first)
- Redis serializer uses
JobWrapper— it works but if you have complex custom job types you'll need to handle deserialization yourself - The NuGet package has a NU1902 warning for OpenTelemetry.Api 1.11.2 (moderate severity — non-critical for Discord bots, but I'll bump it)
- XML doc comments are there for public API but some edge cases aren't documented yet — PRs welcome
MIT. Do what you want, just don't blame me when your bot sends a scheduled message at 3 AM to the wrong channel because you set the timezone wrong.