Running Persistent Scheduled Tasks on Discloud-Hosted Bots
Replace volatile in-memory timers with a database-backed queue so scheduled Discord messages survive Discloud container restarts.
26 Mar 2026, 05:59 UTC

The problem: timers vanish when Discloud restarts your container
A Discord bot that schedules messages for later delivery often uses in-memory timers. On Discloud, those timers live only inside the running container. If the platform restarts your instance for an update, scaling event, or crash recovery, every pending setTimeout or setInterval is lost. Messages scheduled for the future never send.
The useful takeaway is to move scheduling out of memory and into a persistent database table. The bot then polls that table on a fixed interval and delivers any task whose run time has passed. This pattern survives restarts because the schedule is stored outside the bot process.
Prerequisites
- An active Discloud account with a deployed bot instance.
- A persistent database reachable from the Discloud container, such as PostgreSQL, MongoDB, or Redis.
- A Node.js project using Discord.js or a comparable Discord library.
- Environment variables configured in Discloud for the database connection string and bot token.
Store scheduled tasks in a database table
When a user requests a future message, insert a row instead of starting a timer. Each row needs the destination channel, message body, execution time in UTC, and delivery status.
{
"task_id": "550e8400-e29b-41d4-a716-446655440000",
"channel_id": "123456789012345678",
"content": "The server maintenance starts now.",
"scheduled_at": "2026-11-11T14:10:00Z",
"status": "pending",
"attempts": 0
}
Always store scheduled_at in UTC. If a user picks a local time through your bot command, convert that time to UTC before inserting the row. Storing local time without an offset causes messages to fire at the wrong moment when the Discloud container runs in a different time zone than the user.
Poll the table with a single scheduler loop
Run one scheduled job every minute. It selects pending tasks where scheduled_at is earlier than or equal to the current time, then attempts delivery.
// Example: Node.js pseudo-code. Adapt to your database driver and Discord library.
import { Client } from 'discord.js';
const client = new Client({ intents: [] });
client.login(process.env.DISCORD_TOKEN);
async function runScheduler() {
const now = new Date().toISOString();
const tasks = await db.query(
'SELECT * FROM scheduled_tasks WHERE status = $1 AND scheduled_at <= $2',
['pending', now]
);
for (const task of tasks.rows) {
try {
const channel = await client.channels.fetch(task.channel_id);
await channel.send(task.content);
await db.query(
'UPDATE scheduled_tasks SET status = $1 WHERE task_id = $2',
['delivered', task.task_id]
);
} catch (error) {
await db.query(
'UPDATE scheduled_tasks SET attempts = attempts + 1 WHERE task_id = $2',
[task.task_id]
);
console.error(`Delivery failed for task ${task.task_id}:`, error);
}
}
}
// Run every minute. Use a process manager or Discloud worker entry point.
setInterval(runScheduler, 60 * 1000);
Marking a task as delivered immediately after sending prevents the loop from delivering the same message twice on the next poll. If delivery fails, increment the attempt counter instead of deleting the row so you can inspect failures later.
Discloud-specific operational notes
- Discloud restarts containers during platform updates. A database-backed queue survives those restarts; in-memory timers do not.
- Run the scheduler loop from the same bot process or a separate Discloud service, but not from multiple instances at once. Multiple pollers can deliver the same task more than once unless you add row-level locking.
- Keep poll intervals short enough for your latency requirements. A one-minute interval means messages can arrive up to one minute late.
- Store database credentials in Discloud environment variables, not in source code.
Verify the result
- Insert a task with
scheduled_atset five minutes in the future. - Wait without restarting the bot.
- Confirm the message appears in the target channel near the expected time.
- Confirm the task row status changed from
pendingtodelivered. - Optionally restart the Discloud service and confirm a pending task inserted before the restart still delivers after the container comes back.
Limitations
This design guarantees at-least-once delivery only if the status update succeeds after the message sends. If the process crashes between sending and updating the database, the next poll may send the message again. Make your message handling idempotent where possible, or use a database transaction that marks the task as claimed before delivery.
A single poller becomes a bottleneck if you schedule thousands of messages per minute. For high volume, partition the table or use a dedicated queue system instead of a simple SQL table.
Discloud does not guarantee a native cron service for every plan. Verify your current plan allows background loops or long-running processes before depending on this pattern.
Recovery options
- If a task fails repeatedly, set its status to
failedand alert the operator rather than retrying forever. - To cancel a scheduled message before it sends, update its status to
cancelled. The poller ignores non-pending rows. - If the scheduler loop stops, pending tasks remain in the database. Restarting the loop resumes delivery without rebuilding the schedule.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.