Per-user command cooldowns

Stop one person spamming an expensive command. Build cooldowns two ways — an in-memory Map that's three lines, and a Redis version that survives restarts and spans multiple processes — with the code for both.

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 > 0 so 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 Map and 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 handler awaits the Redis checkCooldown.
  • NOAUTH / WRONGPASS — the Redis URL is missing the password. The shape is redis://:PASSWORD@host:port — note the leading colon.
  • The Map grows forever — expected; it only clears on restart. For long-lived bots with many one-off cooldowns, prefer Redis so keys expire themselves.

Next steps

Was this guide helpful?