Some commands shouldn't run every second — a daily reward, an expensive API call, anything that would be abused by mashing. A cooldown limits how often each user may run a command. This recipe builds it two ways: a dead-simple in-memory Map for a single bot, and a Redis version for when the cooldown must outlive restarts or be shared across processes.
| At a glance | |
|---|---|
| You need | a working bot from the discord.js guide; for the Redis half, a Redis server |
| Plan | free or premium — free runs while your session timer lasts |
| Time | about fifteen minutes |
The in-memory version
Keep a Map from command:user to the timestamp when they're allowed again. One function does everything — it returns the seconds still to wait, or 0 if the user is clear (and starts a fresh cooldown):
const cooldowns = new Map(); // "command:userId" -> epoch ms when it's free again
function checkCooldown(command, userId, seconds) {
const key = `${command}:${userId}`;
const now = Date.now();
const until = cooldowns.get(key);
if (until && now < until) {
return Math.ceil((until - now) / 1000); // still cooling down: seconds left
}
cooldowns.set(key, now + seconds * 1000);
return 0; // clear — cooldown now started
}
Wire it into a command. A non-zero return means "block and tell them how long":
client.on(Events.InteractionCreate, async (interaction) => {
if (!interaction.isChatInputCommand() || interaction.commandName !== 'daily') return;
const left = checkCooldown('daily', interaction.user.id, 60 * 60 * 24); // 24h
if (left > 0) {
const hours = Math.ceil(left / 3600);
await interaction.reply({ content: `⏳ Already claimed. Try again in ~${hours}h.`, ephemeral: true });
return;
}
await interaction.reply('🎁 You claimed your daily reward!');
});
That's a complete, working cooldown. The key includes the command name so /daily and /work cool down independently, and the user ID so each person has their own clock.
💡 Tip: Put the cooldown check first, before any real work. Return early when
left > 0so the expensive part never runs for someone who's rate-limited.
What the in-memory version can't do
The Map lives in your bot's memory, and that's its ceiling:
- A restart wipes it. Every cooldown resets to zero — fine for a 30-second anti-spam, bad for a 24-hour daily people could farm by making your bot restart.
- It doesn't cross processes. If you ever run your bot as multiple shards or processes, each has its own
Mapand its own view of the cooldown.
For a short anti-spam cooldown on a single bot, the Map is the right call — simple and instant. When the cooldown is long or must be shared, move it to Redis.
The Redis version
Redis stores the cooldown outside your bot, so a restart doesn't clear it, and every process sees the same clock. It's a natural fit because Redis keys can expire themselves — set a key with a time-to-live and it vanishes on its own when the cooldown ends.
First run a Redis server and connect to it — Caching with Redis covers both. Keep the connection details in .env:
const { createClient } = require('redis');
const client = createClient({
url: `redis://:${process.env.REDIS_PASSWORD}@${process.env.REDIS_HOST}:${process.env.REDIS_PORT}`,
});
client.on('error', (err) => console.error('Redis error:', err));
The cooldown check uses TTL (time-to-live). If the key still exists, its remaining TTL is the seconds left; if it's gone, the user is clear and you set a fresh key with EX:
// seconds remaining (>0 = still cooling down), or 0 if clear (and starts the cooldown)
async function checkCooldown(command, userId, seconds) {
const key = `cooldown:${command}:${userId}`;
const ttl = await client.ttl(key);
if (ttl > 0) return ttl;
await client.set(key, '1', { EX: seconds });
return 0;
}
Same shape as the in-memory function, so the command handler barely changes — just await the check because it talks to Redis:
const left = await checkCooldown('daily', interaction.user.id, 60 * 60 * 24);
if (left > 0) { /* ...block as before... */ }
{ EX: seconds } is what makes this clean: Redis deletes the key when the cooldown expires, so there's no cleanup code and no growing Map — the cooldown simply stops existing when it's over.
⚠️ Heads up: The Redis cooldown is only as available as the Redis server. On a free plan that server stops when its session timer runs out, and cooldown checks start erroring. Keep Redis on an always-on server if real behaviour depends on it — see Keeping apps online. If the app and Redis are both your servers, connect over the private Internal Network and never expose Redis publicly.
Which one?
In-memory Map |
Redis | |
|---|---|---|
| Setup | none — a variable | a Redis server to run |
| Survives a restart | no | yes |
| Shared across processes | no | yes |
| Best for | short anti-spam, single bot | long cooldowns, sharded/multi-process bots |
| Cleanup | manual (grows until restart) | automatic (keys expire) |
Verify it works
Run the command twice in a row. The first goes through; the second is blocked with the "try again" message. In the Redis version, restart the bot between the two runs — the cooldown still holds, which is the whole point. (The in-memory version resets on that restart.)
Troubleshooting
- Cooldown never blocks — you're not returning early when
left > 0, or the key differs between calls. Log the key and make sure command name and user ID are stable. - Redis cooldown resets after a restart anyway — you're still using the
Map, or the Redis server itself restarted/expired. Confirm the handlerawaits the RedischeckCooldown. NOAUTH/WRONGPASS— the Redis URL is missing the password. The shape isredis://:PASSWORD@host:port— note the leading colon.- The
Mapgrows forever — expected; it only clears on restart. For long-lived bots with many one-off cooldowns, prefer Redis so keys expire themselves.