Scheduled tasks
Post to a channel at a set time every day, from one process however the bot is sharded, and stop cleanly.
Before this
A digest posted to a channel every day at 09:00 UTC. A service starts the schedule once the bot is online and stops it before the bot shuts down, through its lifecycle hooks, and only one process posts, however the bot is sharded.
The code
A function works out how long it is until the next run:
/** Milliseconds from `now` until the next `hour:minute` UTC, today or tomorrow. */
export function untilNext(hour: number, minute: number, now: Date): number {
const next = new Date(now)
next.setUTCHours(hour, minute, 0, 0)
if (next <= now) next.setUTCDate(next.getUTCDate() + 1)
return next.getTime() - now.getTime()
}The service sets one timeout at a time, from onReady, and clears it in onShutdown:
// Posts a digest to one channel every day at 09:00 UTC
@Service()
export class DailyDigest implements OnReady, OnShutdown {
private readonly channelId = process.env.DIGEST_CHANNEL_ID ?? ''
private timer?: ReturnType<typeof setTimeout>
onReady(client: Client<true>, { primary }: ReadyInfo) {
// With a process per shard, every process runs this hook; only one should post
if (primary) this.scheduleNext(client)
}
onShutdown() {
clearTimeout(this.timer)
}
// A timeout to the next 09:00, set again after each run, stays on time where a 24-hour interval
// would drift after a slow post or a machine that slept
private scheduleNext(client: Client<true>) {
this.timer = setTimeout(
async () => {
try {
await this.post(client)
} finally {
this.scheduleNext(client)
}
},
untilNext(9, 0, new Date()),
)
}
private async post(client: Client<true>) {
const channel = await client.channels.fetch(this.channelId)
if (channel?.isSendable()) await channel.send({ content: `Today is ${new Date().toDateString()}.` })
}
}Nothing injects the service, so list it in the app's services; see
Services nothing injects.
How it works
- Once the bot is online.
onReadyreceives the client, ready to fetch the channel, and{ primary }. - One process posts. With process sharding, every shard's process
runs the hook, and
primaryistrueonly in the one running shard 0. A bot in one process is always primary. - On time every day. Each run schedules the next from the clock, so the digest stays at 09:00. A 24-hour interval would drift after a slow post or a machine that slept.
- Stopping.
onShutdownclears the timeout, so a stopping bot starts no new post. - A quick hook.
onReadyonly sets the timeout and returns. The hooks run one at a time, so a hook that awaited the schedule would hold up every hook after it.
Testing it
Vitest's fake timers move the clock to the minute before 09:00, and on through a day:
describe('untilNext', () => {
it('counts to today’s time, or tomorrow’s once it has passed', () => {
expect(untilNext(9, 0, new Date('2026-09-25T08:30:00Z'))).toBe(30 * 60_000)
expect(untilNext(9, 0, new Date('2026-09-25T09:00:00Z'))).toBe(24 * 60 * 60_000)
})
})
describe('DailyDigest', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-09-25T08:59:00Z'))
})
afterEach(() => vi.useRealTimers())
// A client whose channel lookup finds a text channel the digest can post in
function clientWithChannel() {
const channel = createMockInteraction(TextChannel)
channel.isSendable.mockReturnValue(true)
const client = createMockClient()
client.channels.fetch.mockResolvedValue(channel as never)
return { client, channel }
}
it('posts at 09:00 UTC every day, from the primary process, until shutdown', async () => {
const { client, channel } = clientWithChannel()
const digest = new DailyDigest()
digest.onReady(client, { primary: true })
await vi.advanceTimersByTimeAsync(60_000)
expect(channel.send).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(24 * 60 * 60_000)
expect(channel.send).toHaveBeenCalledTimes(2)
digest.onShutdown()
await vi.advanceTimersByTimeAsync(24 * 60 * 60_000)
expect(channel.send).toHaveBeenCalledTimes(2)
})
it('posts nothing from any other process', async () => {
const { client, channel } = clientWithChannel()
new DailyDigest().onReady(client, { primary: false })
await vi.advanceTimersByTimeAsync(24 * 60 * 60_000)
expect(channel.send).not.toHaveBeenCalled()
})
})Variations
Cron expressions
For schedules more complex than a time of day, a library such as croner works out the next run. Start it in
onReady and stop it in onShutdown the same way.
Work that must not be lost
A timer lives in memory, so a bot that is down at 09:00 misses that day. Record the last run in
a database, and in onReady catch up on a run that was missed.
Many schedules
Many schedules, such as a time of each server's own, fit one timer that wakes every minute and runs what is due.
Next steps
- Lifecycle hooks: the order hooks run in, and what happens when one fails.
- Sharding: which process does what with a process per shard.
- A database: somewhere to keep the last run.