Crash-proof your Discord bot

One unhandled error shouldn't take your whole bot offline. Three layers of protection — try/catch in handlers, process-level safety nets, and an error channel — for discord.js and discord.py.

A bot that crashes on the first unexpected error is a bot that's offline more than it's online. One command hits a value it didn't expect, throws, and — if nothing catches it — the whole process can go down, taking every other command with it. This guide adds three layers of protection so a single failure stays a single failure: try/catch around your handlers, process-level safety nets that log instead of dying, and an optional error channel that pings you in Discord. The main examples are discord.js; the discord.py equivalents are at the end.

At a glance
You need a working bot (see the discord.js or discord.py guide)
Plan Free or premium — the console shows your errors either way
Time about twenty minutes

Everything your bot prints lands on the Console page, so the goal isn't to hide errors — it's to survive them and make sure they get logged where you'll see them.

Layer 1: catch errors inside each handler

The first and most important layer wraps the code that runs your commands. When a command's work throws, you want two things: the user gets a clean reply instead of a silent failure, and the error reaches your console instead of the process. In the command-handler pattern this lives in one place — the interactionCreate handler:

client.on(Events.InteractionCreate, async (interaction) => {
  if (!interaction.isChatInputCommand()) return;
  const command = client.commands.get(interaction.commandName);
  if (!command) return;

  try {
    await command.execute(interaction);
  } catch (err) {
    console.error(`Error in /${interaction.commandName}:`, err);
    const reply = { content: '⚠️ Something went wrong running that command.', ephemeral: true };
    // Reply, or follow up if we already replied/deferred:
    if (interaction.replied || interaction.deferred) {
      await interaction.followUp(reply);
    } else {
      await interaction.reply(reply);
    }
  }
});

Two details make this robust:

  • The reply is ephemeral: true — only the person who ran the command sees the error notice, so a broken command doesn't spam the channel.
  • The replied || deferred check matters because you can only reply() to an interaction once. If the command already replied (or called deferReply()) before throwing, you must followUp() instead — calling reply() twice throws its own error inside your catch.

🎯 Good to know: Wrapping the handler means one command's bug can't stop the others. The failure is caught, logged, and answered — the bot stays up and the next command works normally.

Layer 2: process-level safety nets

try/catch covers the code you remembered to wrap. The safety net covers everything you didn't. Two Node events catch errors that escape all the way to the top of the process — put them near the top of your entry file, before client.login:

process.on('unhandledRejection', (reason) => {
  console.error('[unhandledRejection]', reason);
});

process.on('uncaughtException', (err) => {
  console.error('[uncaughtException]', err);
});
  • unhandledRejection fires when a Promise rejects and nothing .catches it — the classic await you forgot to wrap. Without this handler, recent Node versions treat it as fatal.
  • uncaughtException fires for a synchronous throw that no try/catch caught.

With both handlers in place, an escaped error is logged instead of fatal — the process keeps running and your bot stays connected.

⚠️ Heads up: These are a safety net, not a strategy. After an uncaughtException the process is technically in an unknown state — the right long-term fix is to wrap the code that actually threw (Layer 1). Use these handlers to keep the bot alive and to see what's slipping through, then go fix the source. Never use them to knowingly ignore a bug.

Layer 3: an error channel in Discord

The console is the source of truth, but you won't be watching it at 3am. Have the bot post serious errors to a private Discord channel so failures find you. Put the channel's ID in .env as ERROR_CHANNEL_ID (right-click the channel → Copy Channel ID, with Developer Mode on), then:

async function reportError(client, context, err) {
  console.error(context, err); // always log to the console first
  try {
    const channel = await client.channels.fetch(process.env.ERROR_CHANNEL_ID);
    const trace = String(err?.stack || err).slice(0, 1800); // stay under Discord's 2000-char limit
    await channel.send(`⚠️ **${context}**\n\`\`\`\n${trace}\n\`\`\``);
  } catch (sendErr) {
    console.error('Could not send error to the error channel:', sendErr);
  }
}

Call reportError(client,/${interaction.commandName}, err) from your catch block. The console log runs first, unconditionally, so you never lose the record even if the send fails.

⚠️ Heads up: This layer only works while the bot is online and logged inchannels.fetch and send go through Discord, so if the failure is the bot losing its connection, the message can't get out. That's exactly why console.error comes first and stays: the Falix console always has the error, even when Discord doesn't. Treat the channel as a convenience on top of the console, never a replacement.

The discord.py equivalents

Python's structure is the same three ideas with different names.

Layer 1 — wrap command bodies in try/except, and register a global handler for anything a command raises. On a commands.Bot:

@bot.tree.error
async def on_app_command_error(interaction: discord.Interaction, error):
    print(f"Command error: {error}", flush=True)
    msg = "⚠️ Something went wrong running that command."
    if interaction.response.is_done():
        await interaction.followup.send(msg, ephemeral=True)
    else:
        await interaction.response.send_message(msg, ephemeral=True)

Layer 2 — the client-wide safety net is on_error, which fires for any exception raised inside an event handler:

@client.event
async def on_error(event_method, *args, **kwargs):
    import traceback
    print(f"[on_error] in {event_method}", flush=True)
    traceback.print_exc()

For errors in background tasks you started yourself (a loop, a create_task), set an asyncio exception handler — the equivalent of Node's unhandledRejection:

def handle_async_exception(loop, context):
    print(f"[asyncio] {context.get('message')}: {context.get('exception')}", flush=True)

# inside async setup, e.g. setup_hook:
asyncio.get_running_loop().set_exception_handler(handle_async_exception)

Layer 3 — the error channel is bot.get_channel(id).send(...) (or await bot.fetch_channel(id)), and like the JavaScript version it only works while the bot is connected — so print(..., flush=True) to the console stays your always-on log. (The flush=True matters on the Python application, exactly as in the discord.py guide.)

Verify it works

The point of this whole setup is that a thrown error changes from "bot goes offline" to "a line in the console (and maybe a ping) while the bot keeps running." To check it, add a deliberately broken command — throw new Error('test') inside an execute — run it, and confirm three things: you get the ephemeral "Something went wrong" reply, the stack trace appears in the Console, and every other command still works. Then delete the test command.

Troubleshooting

  • Interaction has already been acknowledged — your catch called reply() on an interaction that was already replied to or deferred. Use the replied || deferred check and followUp() when it's true.
  • Errors still crash the whole bot — the process.on(...) handlers aren't registered, or they're added after client.login. Put them at the very top of the entry file so they're in place before anything can throw.
  • Nothing arrives in the error channel — the bot can't see or post in that channel. Check the ID is right, the bot is in that server, and it has Send Messages there. The console.error fallback confirms the error itself was caught.
  • unhandledRejection prints but names no useful line — reject with an Error object (throw new Error('...')), not a string; the stack trace comes from the Error, not the reason value.

Next steps

Was this guide helpful?