"Remind me in two hours to take the pizza out." A reminder bot does exactly that: /remindme 2h check the oven, and two hours later it pings you. The catch is the same as any timer bot — if the process restarts before the reminder fires, an in-memory timer is gone. This recipe stores reminders in SQLite, so a restart doesn't lose them: the bot re-arms every pending reminder when it boots.
| At a glance | |
|---|---|
| You need | A working bot from Host a discord.js bot |
| Plan | Free or premium — no premium features required |
| Time | About twenty-five minutes |
New here? Build the base bot with Host a discord.js bot first.
Add the package
This recipe uses better-sqlite3 — a single-file database that installs cleanly on the Node.js application (it ships prebuilt binaries). Open the Packages page in your server menu, install better-sqlite3, and restart.
The whole bot
Here's the complete index.js.
require('dotenv').config();
const Database = require('better-sqlite3');
const { Client, GatewayIntentBits, Events, SlashCommandBuilder } = require('discord.js');
const db = new Database('reminders.db');
db.pragma('journal_mode = WAL');
db.exec(`
CREATE TABLE IF NOT EXISTS reminders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
channel_id TEXT NOT NULL,
text TEXT NOT NULL,
remind_at INTEGER NOT NULL
);
`);
const addReminder = db.prepare('INSERT INTO reminders (user_id, channel_id, text, remind_at) VALUES (?, ?, ?, ?)');
const getReminder = db.prepare('SELECT * FROM reminders WHERE id = ?');
const deleteReminder = db.prepare('DELETE FROM reminders WHERE id = ?');
const allReminders = db.prepare('SELECT * FROM reminders');
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
function parseDuration(text) {
const m = /^(\d+)(s|m|h|d)$/.exec(text.trim());
if (!m) return null;
return Number(m[1]) * { s: 1000, m: 60000, h: 3600000, d: 86400000 }[m[2]];
}
async function fire(id) {
const row = getReminder.get(id);
if (!row) return;
deleteReminder.run(id);
const channel = await client.channels.fetch(row.channel_id).catch(() => null);
if (channel) await channel.send(`⏰ <@${row.user_id}>, you asked me to remind you: ${row.text}`);
}
function schedule(id, remindAt) {
setTimeout(() => fire(id), Math.max(0, remindAt - Date.now()));
}
client.once(Events.ClientReady, async (c) => {
await c.application.commands.set([
new SlashCommandBuilder()
.setName('remindme')
.setDescription('Set a reminder')
.addStringOption((o) => o.setName('when').setDescription('e.g. 10m, 2h, 1d').setRequired(true))
.addStringOption((o) => o.setName('text').setDescription('What to remind you about').setRequired(true))
.toJSON(),
]);
// Restore reminders saved before the last restart.
for (const row of allReminders.all()) schedule(row.id, row.remind_at);
console.log(`Listening as ${c.user.tag}`);
});
client.on(Events.InteractionCreate, async (interaction) => {
if (!interaction.isChatInputCommand() || interaction.commandName !== 'remindme') return;
const ms = parseDuration(interaction.options.getString('when'));
if (!ms) {
await interaction.reply({ content: 'Time must look like `10m`, `2h`, or `1d`.', ephemeral: true });
return;
}
const remindAt = Date.now() + ms;
const info = addReminder.run(interaction.user.id, interaction.channelId, interaction.options.getString('text'), remindAt);
schedule(info.lastInsertRowid, remindAt);
await interaction.reply({ content: `Okay! I'll remind you <t:${Math.floor(remindAt / 1000)}:R>.`, ephemeral: true });
});
client.login(process.env.DISCORD_TOKEN);
Restart and try /remindme when:30s text:"say hi".
How it works
- One table. Each reminder is a row: who set it, which channel to post in, the text, and
remind_at(a Unix timestamp in milliseconds).AUTOINCREMENTgives each one anid;addReminder.run(...).lastInsertRowidhands that ID straight to the timer. - Fire, then delete. When the timer runs out,
firereads the row, deletes it (so it never fires twice), and posts the ping in the original channel. - Restarts are survivable. On
ClientReady, the bot reads every row and re-schedules it. A reminder whose time already passed while the bot was down fires immediately, because the timer delay clamps to0.
💡 Tip: Want the reminder in the user's DMs instead of the channel? Replace the channel fetch with
client.users.fetch(row.user_id)and send there — wrap it in acatch, since users can block DMs from server bots.
⚠️ Heads up:
setTimeoutmaxes out at about 24.8 days, so schedulingremindme 60din one shot won't fire. For far-future reminders, run a periodic sweep that fires anything past due instead — a scheduled task every minute does it.
Where the data lives
reminders.db lives in /home/container. It survives restarts, which is the point — but a reinstall or switching the application wipes it, like any server file. Keep it out of Git (add it to .gitignore). For data that must outlive a reinstall, use a managed database.
Verify it works
Set a 30-second reminder and wait for the ping. Then the real test: set a two-minute reminder, restart the server, and confirm it still fires. If it does, the SQLite persistence is working.
Troubleshooting
| Symptom | Fix |
|---|---|
Cannot find module 'better-sqlite3' |
Install it from the Packages page and restart. |
| Reminders lost after a restart | Confirm the restore loop runs on ClientReady, and that reminders.db still exists (a reinstall wipes it). |
| Bot doesn't ping in the channel | It needs permission to send messages there. Reminders in a deleted channel are silently skipped. |
| "Time must look like…" | Use a number plus s, m, h, or d — e.g. 10m, 2h, 1d. |