· 5 min read

Telegram Bot API Best Practices: Stop Treating Updates Like Guaranteed Delivery

A common Telegram Bot API mistake is assuming every update will arrive once and only once. In practice, network retries, webhook failures, and concurrent workers can all produce duplicates or gaps unless you design for at-least-once delivery. This article explains how to make your bot idempotent, resilient, and easier to operate in production.

One of the most common mistakes Telegram bot developers make is assuming that an incoming update is a clean, single-shot event. In other words: they code as if every message, callback query, or inline interaction arrives exactly once, in order, and with no retries.

That assumption works during early testing. It breaks quickly in production.

Telegram’s delivery model is much closer to at-least-once delivery than exactly-once delivery. Your bot may see the same update more than once, may process updates out of order across workers, and may experience gaps if your webhook or polling loop is unhealthy. If your code is not designed for that reality, you will get duplicate replies, repeated payments, double database writes, and user-facing inconsistency.

The mistake: writing handlers as if duplicates cannot happen

A lot of bot code looks like this:

if update.message.text == "/buy":
    create_order(user_id)
    send_confirmation(user_id)

This seems harmless until the same update is delivered again after a timeout, a restart, or a failed webhook response. Then create_order() runs twice.

The same issue appears in many places:

  • answering the same callback query twice
  • re-sending the same welcome message after a restart
  • charging a user twice because the payment flow is not idempotent
  • advancing conversation state more than once
  • writing duplicate rows to your database

If your bot changes state, sends money-related side effects, or triggers downstream workflows, duplicates are not a rare edge case. They are part of normal operation.

Treat each update as an event with an ID

Every Telegram update has an update_id. Use it.

Store processed update IDs, or better, design your handler logic so the business operation itself is idempotent. The best option is usually to make the operation safe to repeat rather than rely only on a global deduplication table.

Good pattern: deduplicate before side effects

if already_processed(update.update_id):
    return

mark_processed(update.update_id)
process_update(update)

This is a useful first layer, but it is not enough by itself if you do the following:

  • mark processed after sending a message
  • write the processed flag without a transaction
  • run multiple workers without shared storage

In those cases, two workers can race and process the same update simultaneously.

Make the side effect idempotent too

Deduplication at the update level helps, but your real protection should be on the business action.

For example:

  • Use a unique order key per user action.
  • Use database constraints to prevent duplicate inserts.
  • Tie payment records to a unique external reference.
  • Check the current state before moving to the next conversation step.

Instead of:

create_order(user_id)

Prefer something like:

create_order_if_missing(user_id, request_id)

Where request_id is a unique token you generate when the interaction starts. If the update is replayed, the operation becomes a no-op.

Webhooks: always assume retries

If you use webhooks, Telegram will retry when it does not get a proper response. That means your endpoint must be prepared for:

  • temporary network issues
  • slow application startup
  • reverse proxy timeouts
  • application crashes after partial work

A dangerous anti-pattern is to perform the full business action and then return a response only at the very end, with no checkpointing.

Better approach

  1. Validate the update quickly.
  2. Persist the intent or enqueue work.
  3. Return 200 OK as soon as the update is safely accepted.
  4. Process the heavy work asynchronously.

This reduces retries caused by slow responses and keeps Telegram from resending updates unnecessarily.

If you must do work inline, keep it short and durable. Make sure the state change that matters is committed before you acknowledge the webhook.

Polling: do not assume perfect ordering across restarts

Long polling has its own failure modes.

If your bot restarts and resumes incorrectly, you may re-read old updates or skip some depending on how you manage offsets. The correct offset handling is essential, but it still does not eliminate duplicate processing if your own code retries work after a crash.

A robust polling loop should:

  • persist the last successfully handled update offset
  • update the offset only after processing is safely recorded
  • avoid shared mutable in-memory state as the source of truth
  • recover cleanly after restarts

If you process updates in parallel, remember that ordering can matter for a single chat. A /start followed by a button press may be delivered to different workers, and the callback may run before the conversation state is ready unless you serialize per chat or per user.

Use per-chat or per-conversation locking when state matters

Another subtle mistake is processing all updates concurrently with no coordination. That can be fine for stateless commands, but it is risky for flows that depend on sequence.

Examples:

  • multi-step forms
  • inline button wizards
  • moderation queues
  • shopping carts
  • authentication flows

A practical pattern is to lock on a conversation key such as chat_id or user_id while the state machine advances. That does not mean global serialization; it means only related actions should be ordered together.

Callback queries need special care

Callback queries often expose duplicate-handling bugs because developers focus on the visible button action and ignore backend state.

When a user taps a button:

  • answer the callback query promptly
  • update the message only if the current state still matches
  • make the action idempotent
  • disable or replace buttons after use when appropriate

If your button means “approve,” do not let repeated taps create multiple approvals. If it means “claim reward,” ensure the reward can be claimed once only.

Logging should make replay issues obvious

When updates are processed more than once, good logs save hours.

Log at least:

  • update_id
  • chat_id
  • user_id
  • handler name
  • operation ID or idempotency key
  • whether the update was skipped, processed, or retried

Without these fields, duplicates are hard to diagnose because the bot may appear to “randomly” send extra replies.

A simple checklist for production bots

Before shipping, verify that your bot:

  • does not rely on exactly-once delivery
  • deduplicates updates or business actions
  • uses unique keys for side effects
  • acknowledges webhooks quickly
  • persists processing state transactionally
  • handles restarts without losing context
  • serializes stateful conversations where necessary
  • makes callback actions idempotent

Bottom line

The biggest Telegram Bot API mistake is not using the wrong method or missing an edge case in formatting. It is treating update delivery as if it were perfect. Telegram bots run in the real world, where networks fail, processes restart, and retries happen.

If you design for duplicates and partial failures from the beginning, your bot will be calmer in production, easier to scale, and much less likely to annoy users with repeated actions.

Build for replay. Build for retries. Build for idempotency.

← All posts