DEV Community

mrnoyy
mrnoyy

Posted on

A Discord Bot That Posts on a Schedule — Without a Cron Job Per Event

The naive version of a scheduled Discord bot is a setTimeout per upcoming event. It works in testing and falls apart in production for three separate reasons. Here is a design that does not.

Why setTimeout per event fails

Restarts wipe everything. Your host redeploys, the process restarts, and every pending timer is gone. The bot comes back up cheerful and silent, and nobody notices until the thing that should have been announced was not.

Long timers drift. setTimeout is not a scheduler, it is a "not before" hint. Over hours, under load or after the host suspends an idle process, drift becomes minutes.

Two instances mean two messages. A deploy that overlaps old and new, or a crash-restart loop, and every announcement goes out twice.

The two-layer design

Layer one: a single interval that wakes up on a fixed cadence.
Layer two: a database that holds what is due.

setInterval(checkDueTasks, 60 * 1000);

async function checkDueTasks() {
  const due = await db.getDueTasks(new Date());
  for (const task of due) {
    await runTask(task);
  }
}
Enter fullscreen mode Exit fullscreen mode

One timer for the whole process. State lives in the database, so a restart loses nothing — the next tick picks up whatever is due, including things that came due while the process was down.

Claim before you send

The duplicate problem is not solved by checking a flag. Two instances can both read sent = false before either writes. The claim has to be atomic:

update scheduled_tasks
set status = 'processing', claimed_at = now()
where id = $1 and status = 'pending'
returning *;
Enter fullscreen mode Exit fullscreen mode

If it returns a row, you own the task. If it returns nothing, someone else got there first — skip it. The database does the arbitration, which is what it is good at.

Add a sweeper for tasks stuck in processing past a timeout, so a crash mid-send does not strand them forever.

Rate limits are per-channel

Discord's limits are stricter per channel than globally. A burst of announcements to one channel gets throttled hard.

discord.js queues and retries internally, but it cannot make a burst not be a burst. If you have twenty messages due in the same minute, space them out yourself — a second or two between sends is invisible to users and keeps you well clear.

Never catch a 429 and immediately retry in your own code. You will fight the library's queue and make it worse.

Idempotency at the message level

Store the message id you sent for each task. Then editing an announcement later is an edit, not a second message, and a task that somehow runs twice can detect that it already produced output.

if (task.message_id) return; // already announced
const sent = await channel.send(payload);
await db.setMessageId(task.id, sent.id);
Enter fullscreen mode Exit fullscreen mode

Timezones will get you

Store everything in UTC. Convert only when rendering for humans.

Better still, use Discord's own timestamp markup and let each client render in the viewer's local zone:

<t:1725000000:F>
Enter fullscreen mode Exit fullscreen mode

That is a Unix timestamp in seconds. Everyone in the server sees their own time, and you never have to guess where your users are.

Failures need to be loud

A send can fail for reasons that have nothing to do with your code: the channel was deleted, permissions changed, the bot was removed from a role.

Log the failure with the task id and the channel id, and mark the task failed rather than retrying forever. A retry loop against a channel that no longer exists is a busy loop with extra steps.

Keep the command surface small

Slash commands are the interface, and every one you add is a thing that can be misused or misunderstood. Mine are: schedule, list, cancel. Everything else is derived. Permissions are checked in the handler, not just in Discord's UI, because UI-level restrictions can be misconfigured by a server admin who did not mean to.


The whole thing is about 200 lines plus a table. The value is entirely in the boring parts: one timer, atomic claims, UTC, and treating every send as something that might already have happened.

Top comments (0)