Skip to content
DevOps AI ToolKit
Newsletter
All guides
AI for Slack By James Joyner IV · · 10 min read

Scheduling Slack Messages at Scale: Living With chat.scheduleMessage Limits

chat.scheduleMessage has a 30-per-channel cap, a 120-day horizon, and no update API. Learn to track scheduled IDs, handle cancellation races, and fall back when channels fill up.

  • #slack
  • #ai
  • #scheduling
  • #automation
  • #reminders

A maintenance-reminder bot I built kept paging a channel about a database upgrade that had already happened. The upgrade was done, the feature was decommissioned, the code was deleted — and the reminders kept arriving, because months earlier the bot had called chat.scheduleMessage and nobody had saved the returned IDs. Those messages were queued inside Slack, beyond our reach, and they fired on schedule into a channel full of confused engineers. We had no handle to cancel them. We had to wait them out.

This is a guide to operating chat.scheduleMessage like an adult: respecting its limits, tracking the IDs that are your only means of cancellation, and handling the races and quotas that bite at scale. It’s a deceptively simple API — schedule a message for later, done — and that simplicity is exactly what lets it strand orphaned reminders in a channel weeks after you thought you were finished.

The limits nobody documents loudly enough

Three constraints shape everything:

  • Roughly 30 scheduled messages per channel, per app. Exceed it and new schedule calls start failing. A busy reminder bot can hit this faster than you’d think.
  • A horizon of about 120 days. Schedule further out and Slack rejects it. Long-lead reminders need a different strategy.
  • No scheduling into the past, and no update API. You cannot edit a scheduled message. To change it, you delete and re-create — which, as we’ll see, races against the send time.

Before scheduling, check the current load with chat.scheduledMessages.list so you don’t blindly slam into the per-channel cap:

const { scheduled_messages } = await client.chat.scheduledMessages.list({
  channel: channelId,
});
if (scheduled_messages.length >= 28) {       // leave headroom under ~30
  // consolidate, batch, or fall back to send-time posting
}

If you’re scheduling messages and reminders for an ops team, treating these limits as design inputs rather than runtime surprises is what keeps the bot reliable under real volume.

Track every scheduled_message_id or lose control

This is the lesson the orphaned-reminders incident taught. Every chat.scheduleMessage call returns a scheduled_message_id, and that ID is your only handle to cancel the message later. Persist it, keyed to the domain entity it represents:

const res = await client.chat.scheduleMessage({
  channel: channelId,
  post_at: Math.floor(reminderTime.getTime() / 1000),
  text: `Reminder: ${windowName} maintenance window opens in 1 hour.`,
});

await db.scheduled.insert({
  scheduled_message_id: res.scheduled_message_id,
  channel: channelId,
  entity_type: 'maintenance_window',
  entity_id: windowId,                 // so you can cancel when the window is cancelled
  post_at: res.post_at,
});

Now when the maintenance window is cancelled, rescheduled, or the feature is torn down, you can look up the IDs and call chat.deleteScheduledMessage. Without this table, those messages are unreachable — they will fire, and there is nothing you can do about it.

The delete-and-recreate race

Because there’s no update API, changing a scheduled message means deleting the old one and creating a new one. The hazard is timing: between your list/delete and the moment you act, the message might already have fired. Handle the already-sent case explicitly rather than assuming the delete always succeeds:

async function reschedule(record, newPostAt) {
  try {
    await client.chat.deleteScheduledMessage({
      channel: record.channel,
      scheduled_message_id: record.scheduled_message_id,
    });
  } catch (err) {
    if (err.data?.error === 'invalid_scheduled_message_id') {
      // already fired or already deleted — accept it and move on
    } else {
      throw err;
    }
  }
  // then schedule the new one and update the DB record
}

The invalid_scheduled_message_id error is the signal that the message already left the building. Treat it as a normal outcome, not a crash.

Idempotency and recurrence

Slack has no native recurrence — every scheduled message is a one-shot. For recurring reminders, schedule the next occurrence when the current one fires (or run your own scheduler that posts at send time). And because redeploys and retries can cause you to schedule the same logical message twice, guard with an idempotency key in your store: before scheduling, check whether an active scheduled message already exists for that entity-and-time, and skip if so. This is what stops a restart from doubling every reminder.

When a channel approaches the 30-message cap, the cleanest fallback is to stop pre-scheduling and instead run your own scheduler (a cron, a queue with a delay) that calls chat.postMessage at the moment the message is due. You trade Slack-side durability for unlimited volume and full control — often the right trade once you’re fighting the cap.

Letting AI draft the tracking layer

An LLM handles the bookkeeping code well — the schedule call, the DB record, the delete-with-error-handling. A focused prompt produces a solid draft:

Write a Node module that schedules a Slack reminder with chat.scheduleMessage, persists the scheduled_message_id keyed to an entity, and provides a cancel function that deletes by entity id and treats invalid_scheduled_message_id as already-fired.

What the AI won’t volunteer is the operational discipline — the pre-flight scheduledMessages.list check, the idempotency guard, the orphan cleanup. Those come from having been burned. The AI drafts the CRUD; the human supplies the lessons. Keep the refined version in a prompt library and pair it with the related scheduled messages and reminders prompt so the operational guardrails travel with the code.

Wrapping Up

chat.scheduleMessage is convenient right up until it isn’t — when a channel hits its 30-message cap mid-incident, when a 120-day reminder gets rejected, or when an orphaned message fires into a channel weeks after you deleted the feature. The defenses are straightforward: check the per-channel load before scheduling, persist every scheduled_message_id so you can always cancel, handle the delete-and-recreate race by treating an already-fired message as normal, and fall back to send-time posting when a channel fills up. Let AI write the tracking layer, but bring your own operational discipline — because the scheduled message you can’t cancel is the one that embarrasses you in front of the whole channel.

Free download · 368-page PDF

Download the Free 500-Prompt DevOps AI Toolkit

500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.

  • 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
  • Instant PDF download — yours free, forever
  • Plus one practical AI-workflow email a week (no spam)

Single opt-in · unsubscribe anytime · no spam.