← back to blog

Duplicate Telegram bot messages: where the duplicates actually come from

Why duplicate replies happen at all

Telegram’s Bot API guarantees at-least-once delivery, not exactly-once. That single design choice explains almost every duplicate-message ticket we’ve ever seen on hosting. Telegram will keep trying to hand an update to your bot until something acknowledges it. If two things are listening at once, or if your bot takes too long to acknowledge, you get the same update processed twice, and your bot dutifully sends two replies for one incoming message.

This isn’t a bug in your reply logic. Nine times out of ten it’s a plumbing problem: something on the hosting side is running more copies of the bot process than you think, or your acknowledgment step is slower than Telegram’s patience.

Long polling: two processes fighting over getUpdates

If your bot uses long polling, it calls getUpdates in a loop, and Telegram hands back an array of new updates each time. The catch is that only one process can hold that polling connection for a given bot token at a time. If a second process starts polling with the same token, Telegram is supposed to kick the older one out with a 409 Conflict error.

In practice, that handoff isn’t instant. There’s a short window during a deploy, a cron restart, or a crash-and-respawn cycle where both the old and new process are alive and both successfully pull the same batch of updates before the older one gets cut off. Each process replies. The user sees two answers to one message.

We see this most often with cron-based restarts: a scheduled job runs the bot process every few minutes as a “keep it alive” measure without checking whether the previous instance already died or is still running. On a slow VPS, or one under memory pressure, the old process can take a while to actually exit, so you end up with two or three pollers stacked on top of each other for a few minutes at a time.

Webhooks and Telegram’s retry behavior

Webhooks avoid the polling collision problem, but they trade it for a different one. When Telegram delivers an update to your webhook URL, it expects an HTTP 200 back within a short window. If your handler is slow, waiting on a database write, an outbound call to a language model, or a proxy hop that’s timing out, Telegram doesn’t just give up. It retries the same update later, sometimes more than once, until it either gets a 200 or the retry window expires.

If your handler already sent the reply before returning that 200, and then gets the same update again on retry, it will send the reply a second time unless you’ve explicitly stopped it from doing so. This is one of the most common causes we run into on managed hosting: the bot logic itself is fine, but a slow outbound leg, often a proxy or a rate-limited external API, pushes the response time past what Telegram is willing to wait for.

Process managers that restart without killing the old process

Systemd, pm2, supervisor, and Docker restart policies are all built around the assumption that when a process dies, it’s actually gone. That assumption breaks down in a few specific ways that cause duplicate bots.

A process that’s hung but not crashed is one case. If your bot is stuck on a blocked network call rather than actually exiting, a supervisor watching for exit codes won’t restart it, but a separate health check or a manual restart might start a second instance anyway, and now you have two.

A container that’s still finishing its shutdown when a new one starts is another. Rolling deploys that don’t wait for the old container to fully release its polling connection before the new one starts polling will have a brief overlap window, the same problem as the cron case above but with Docker instead of a shell script.

A crash loop combined with slow update acknowledgment causes a third version. If the bot crashes after sending a reply but before marking the update as handled, whatever tracks the last seen update_id doesn’t get updated, and the next process to start reprocesses that same message.

None of this is exotic. It’s the ordinary failure mode of anything that automatically restarts a network client without a clean handoff.

Proxy and network layers that hide the real cause

Because telegramvault.org runs proxy configuration alongside hosting, we get a specific version of this question a lot: “I only have one bot process running, why is it still duplicating?” Usually the answer is that the process count is right, but the network path isn’t.

If a bot’s outbound requests go through a proxy that occasionally times out or drops a connection mid-request, the bot’s HTTP client may retry the request on its own, separate from anything Telegram is doing. Depending on how that retry is written, it can resend the same outgoing reply. This has nothing to do with Telegram’s delivery model. It’s your own client library or your own retry wrapper firing twice because the first attempt looked like it failed when it actually succeeded on the far side.

The fix here isn’t a Telegram setting at all. It’s making sure outbound retries are idempotent, or at minimum checking whether a message was already sent before resending it after a timeout.

How to dedupe properly with update_id

Every update Telegram sends carries an update_id that increases for a given bot. The only reliable fix for duplicate replies, regardless of which of the causes above is at play, is to track the highest update_id you’ve fully processed and refuse to act on anything at or below it a second time.

For polling setups, this is mostly handled for you if you call getUpdates with the offset parameter set correctly, since that tells Telegram which updates it can drop from the queue. The mistake we see most is code that reads updates but doesn’t advance the offset until after the reply is sent, which means a crash between sending the reply and advancing the offset causes the same update to be replayed on the next start.

For webhook setups, you need your own dedupe check, even if it’s just a small key-value store with the last few hundred update_ids you’ve seen. Check it before you act on an update, not after. This is the one piece of defensive code that will save you from every cause listed above at once, because it doesn’t matter why the duplicate arrived, only that you recognize it as one you’ve already handled.

Why hosting setup matters more than bot code

Our advice to hosting customers has shifted over time to focus less on bot code and more on process hygiene, because that’s where almost every real duplicate-message case has come from. A bot with sloppy error handling but a single, cleanly supervised process rarely duplicates. A bot with clean code running under a restart policy that doesn’t guarantee a single instance duplicates constantly.

The practical checklist we actually use when a customer reports this: confirm only one process holds the polling connection or webhook endpoint at a time, confirm the deploy or restart process waits for the old instance to fully exit before starting a new one, confirm the bot acknowledges webhook updates fast enough that Telegram doesn’t retry, and confirm there’s an update_id check somewhere in the code path before a reply gets sent.

A quick way to check if you have more than one instance running

If you’re on long polling and suspect duplication, watch for 409 Conflict responses in your logs. That error only appears when Telegram detects a second process trying to poll with the same token, which is a direct signal that something is stacking instances rather than replacing them cleanly.

If you’re on webhooks, log the update_id on every incoming request before you do anything else with it. If you see the same update_id arrive more than once in your logs, you’ve confirmed it’s a Telegram retry rather than something wrong with your reply logic, and the fix is a dedupe check, not a rewrite of your bot.

If your bot keeps sending doubled replies and you want a second pair of eyes on the hosting and proxy setup behind it, that’s exactly what we do at telegramvault.org.

Get new guides and videos first — join the Telegram channel.

need infra for this today?