Someone in your server types /rank and nothing happens. They try again. Then they ping you. You check the member list and the bot has a green dot, so you assume it's fine and tell them to try later, and it's another hour before you discover the bot has been a ghost since 3 AM: process running, gateway session dead, presence cached as online. The people who run Discord bots and Telegram bots for communities, games, and small businesses all know this moment. The bot is the one thing in the server that's supposed to be reliable, and it fails in ways that don't look like failure.
This guide covers how bots actually stop working, and two ways to find out before your users do: an HTTP health endpoint the bot serves (which works on a free uptime monitoring plan) and a heartbeat the bot sends from inside its connection loop. The examples are discord.js and discord.py, with a section for Telegram bots in both polling and webhook mode. If you were looking for the other direction, receiving downtime alerts in Discord or Telegram, see Discord alerts and Telegram alerts.
How a bot fails without going offline
- Process alive, gateway dead. A bot talks to Discord over a persistent WebSocket. Networks blip, Discord rotates gateway nodes, and libraries reconnect automatically, most of the time. When a reconnect loop gets stuck, the process is alive, the host says it's running, memory is fine, and the bot receives no events. Presence can lag for minutes, so the green dot lies for a while.
- Token reset. Someone regenerated the token in the developer portal, or Discord reset it after the token leaked in a public repo (they scan for this). On the next reconnect the bot fails to authenticate. A libraries-and-hosts combination that restarts on crash will now crash-loop, which looks like "running" in a dashboard that only samples once a minute.
- A privileged intent was turned off. Message Content, Server Members, and Presence are privileged intents that must be enabled in the portal. If one is switched off (or a verification requirement kicks in as the bot passes 100 servers), the bot connects and is online, and either fails to start with a "disallowed intents" error or, for message content, sees empty strings and silently ignores every command. Nothing about this looks like downtime.
- The host went to sleep. Free tiers on most platforms idle a process that gets no inbound web traffic. A bot's gateway connection is outbound and doesn't count. Several hosts have removed "always on" from their free tiers over the past couple of years, which is why so many "my bot goes offline every night" threads exist. Sleeping is indistinguishable from crashing, from the outside.
- Out of memory, restart loop. A bot that caches every member of every server it's in grows until the container's memory limit kills it. Docker's
restart: alwaysbrings it back, it reconnects, replays the cache fill, dies again. Uptime, measured as "the container exists," is 100%. Uptime, measured as "the bot answers commands," is a few minutes an hour. - Rate limited or globally banned. Too many requests in a loop, especially after a bug, and Discord returns 429s or, in bad cases, temporarily bans the bot's IP from the API. The bot is connected and can't do anything.
- Discord or Telegram is down. It happens. This one isn't yours to fix, but you want to know it's them, fast, so you can tell your users instead of restarting things for an hour. Monitoring third-party dependencies covers the general case.
Every one of these has the same shape: the thing you can see (the process, the container, the green dot) is fine and the thing that matters (does the bot respond) is not. So the check has to ask the bot itself.
Option 1: a /health endpoint that tells the truth (free)
Most bot hosts already give your process a port and a URL. Add a tiny HTTP server to the bot with one route that returns 200 only when the bot is genuinely connected, and 503 otherwise. Then create an ordinary HTTP monitor for that URL on CronAlert. This fits entirely on the free plan: 25 monitors, checked every 3 minutes, with alerts by email, push notification, Slack, Discord, or webhook.
The key is what "genuinely connected" means. Don't return 200 because the process is up; that's the check that's been fooling you. Return 200 when the client reports ready and the gateway latency is a real number (it's -1 or NaN in discord.js when the heartbeat hasn't been acknowledged) and the bot has seen an event recently enough for the size of your servers.
// discord.js v14 — health.js
import http from "node:http";
export function startHealthServer(client, port = process.env.PORT ?? 3000) {
let lastEventAt = Date.now();
client.on("raw", () => { lastEventAt = Date.now(); });
http.createServer((req, res) => {
if (req.url !== "/health") { res.writeHead(404).end(); return; }
const ping = client.ws.ping;
const quietMs = Date.now() - lastEventAt;
const ok = client.isReady() && Number.isFinite(ping) && ping >= 0 && quietMs < 10 * 60_000;
res.writeHead(ok ? 200 : 503, { "content-type": "application/json" });
res.end(JSON.stringify({ ok, ping, quietMs, guilds: client.guilds.cache.size }));
}).listen(port);
} Call startHealthServer(client) once after you create the client. The ten-minute "quiet" window assumes a bot in servers that produce at least some events (messages, presence updates, member joins) every few minutes. For a bot in one small, sleepy server, drop the quiet check or lengthen it; a false alarm every quiet night trains you to ignore the alert. The same idea in discord.py:
# discord.py 2.x — inside your bot module
from aiohttp import web
import math, time, os
last_event = time.monotonic()
@bot.event
async def on_socket_event_type(event_type):
global last_event
last_event = time.monotonic()
async def health(request):
quiet = time.monotonic() - last_event
ok = bot.is_ready() and not bot.is_closed() and not math.isnan(bot.latency) and quiet < 600
return web.json_response({"ok": ok, "latency": bot.latency, "quiet": quiet}, status=200 if ok else 503)
async def start_health():
app = web.Application(); app.router.add_get("/health", health)
runner = web.AppRunner(app); await runner.setup()
await web.TCPSite(runner, "0.0.0.0", int(os.environ.get("PORT", 8080))).start()
# in setup_hook or before bot.start(): bot.loop.create_task(start_health()) Point a CronAlert monitor at https://your-bot-host/health expecting a 200. On the Pro plan you can add a keyword check for "ok":true as a belt-and-braces measure, but the status code alone does the job. If your host issues a URL that changes on redeploy, use a custom domain or the host's stable app URL, not the per-deploy one.
A side effect worth knowing: on hosts that sleep idle web processes, a monitor hitting /health every 3 minutes counts as inbound traffic and keeps some of them awake. Don't rely on it; hosts change the rules, and a bot you care about should be on something that runs a persistent process. But it's why you'll see "uptime monitor" recommended in every "bot keeps going offline" thread.
Option 2: a heartbeat from inside the connection loop (Pro)
If your bot runs somewhere without a public URL (a Raspberry Pi at home, a machine behind NAT, a host that doesn't expose ports on the plan you're on), flip the direction. A heartbeat monitor gives you a unique URL and expects a ping on a schedule; the bot pings it every few minutes from a loop that checks the same connection state as the health endpoint. No inbound access is needed. Heartbeats are on the Pro plan at $5 per month; the ping URL looks like https://cronalert.com/api/heartbeat/<token> and accepts GET or POST.
// discord.js — ping only when the gateway is really alive
const HEARTBEAT = process.env.CRONALERT_HEARTBEAT_URL;
setInterval(async () => {
const alive = client.isReady() && Number.isFinite(client.ws.ping) && client.ws.ping >= 0;
if (!alive) return; // skip the ping; the missed beat is the alert
try { await fetch(HEARTBEAT, { method: "POST", signal: AbortSignal.timeout(5000) }); } catch {}
}, 60_000); Set the monitor's expected interval to match the loop (one minute here). CronAlert alerts when no ping has arrived within twice the interval, so a gateway that dies at 3:00 produces an alert by about 3:02, and a process that crashes produces the same alert for a different reason. In discord.py, the same loop is a @tasks.loop(minutes=1) that checks bot.is_ready() and bot.latency before calling the URL with aiohttp.
The rule that makes both options work: never ping (or return 200) unconditionally. The whole failure catalogue above is "the process is running but the bot isn't working," and a check that fires whenever the process is running reproduces the problem you're trying to solve.
Telegram bots
Telegram bots receive updates one of two ways, and the monitoring differs.
Long polling
The bot calls getUpdates in a loop. There's no inbound HTTP, so use the heartbeat pattern: ping from the polling loop after each successful getUpdates call (or once a minute, if the loop is faster), and skip the ping when the call errors. A stuck loop, an invalid token (401 Unauthorized), or a dead process all produce the same missed beat. If you're using a framework like python-telegram-bot, grammY, or Telegraf, hang the ping off its polling or error hooks rather than wrapping the loop yourself.
Webhooks
Telegram POSTs updates to an HTTPS URL you own, so your server has to be up, reachable, and serving a valid certificate. That's exactly what an ordinary uptime monitor checks, so start with an HTTP monitor on a /health route on the same server (free plan). The subtle failure is a server that's up but rejecting Telegram's deliveries: a code path that throws on some update types, or a certificate Telegram doesn't accept. Telegram exposes this through getWebhookInfo, which returns last_error_date, last_error_message, and pending_update_count. Have /health call it and return 503 if the last error is recent or the pending count is climbing:
// Node — webhook-mode Telegram bot health route
app.get("/health", async (req, res) => {
const r = await fetch(`https://api.telegram.org/bot${process.env.BOT_TOKEN}/getWebhookInfo`);
const info = (await r.json()).result ?? {};
const recentError = info.last_error_date && Date.now() / 1000 - info.last_error_date < 300;
const ok = r.ok && info.url && !recentError && (info.pending_update_count ?? 0) < 50;
res.status(ok ? 200 : 503).json({ ok, pending: info.pending_update_count, lastError: info.last_error_message });
}); Note info.url in the condition: an empty webhook URL means someone (or a redeploy script) called deleteWebhook or a polling instance took over, and the bot is up but will never receive anything. The pending-count threshold depends on your traffic; the point is to catch it growing, not to pick a magic number.
Don't get the alert in the Discord that just died
If the bot is down because Discord is down, an alert sent to a Discord channel won't arrive. Same for Telegram. CronAlert's alert channels are team-wide, so add at least one channel that doesn't depend on the platform you're monitoring: email and push notifications are both on the free plan. Keeping a Discord alert channel too is fine, and useful for the common case where the bot died and Discord didn't; just don't make it the only one. Test the channels once when you set them up.
It also helps to have a monitor on the platform itself, for triage. An HTTP monitor on https://discord.com/api/v10/gateway (a public endpoint that returns the gateway URL, no auth needed) or on https://api.telegram.org tells you within a check whether the platform is reachable. When your bot alert and the platform alert fire together, it's them. When only the bot alert fires, it's you.
Hosting notes
- Docker: use
restart: unless-stopped, and set a memory limit so a leak kills the bot cleanly instead of taking the host down. The health endpoint doubles as a DockerHEALTHCHECK, but Docker only restarts on health failure if you add something like autoheal; CronAlert is the part that tells a human. See monitoring Docker and self-hosted apps. - systemd on a VPS:
Restart=on-failureplusRestartSec=10, and considerStartLimitBurstso a token error doesn't spin forever. The systemd guide covers the service-level details. - Free tiers: if the bot matters to more than a handful of people, budget a few dollars for a host that runs a persistent process. Monitoring a bot on a host that sleeps it on purpose produces an alert every night and nothing you can fix.
- Sharded bots: at scale, the health endpoint should report per-shard readiness and return 503 if any shard is disconnected, or you'll have a slice of servers with a dead bot and a green monitor.
Frequently asked questions
How do I know if my Discord bot is online?
Ask the bot, not the member list. A health route that checks isReady() and a finite gateway ping, monitored from outside, is the honest answer.
Can I monitor a bot for free?
Yes: the /health route plus an HTTP monitor is fully on CronAlert's free plan. Heartbeats (bot pings CronAlert) are Pro at $5 per month.
Why does my bot go offline every night?
Almost always the host sleeping an idle process. Move to a persistent-process host for a bot people rely on.
What about Telegram webhook bots?
HTTP-monitor the server, and have the health route surface getWebhookInfo errors and pending counts so "up but rejecting deliveries" isn't invisible.
Should the alert go to Discord?
Sure, but not only Discord. Add email or push so you still hear about it when the platform is the problem.
Find out before the /rank command does
A bot's green dot is a cached presence, not a health check. Give the bot one route or one loop that reports whether it's actually connected, put a monitor on it, and send the alert somewhere that isn't the platform being monitored. Twenty lines of code and a free monitor turn "the bot's been dead since 3 AM" into a push notification at 3:03. Create a free monitor for your bot's health route, or a heartbeat if it has no public URL. Related reading: monitoring background workers, designing health check endpoints, and free uptime monitoring tools compared.