· 8 min read
Building Reliable Telegram Bots with Webhooks, Queues, and Idempotent Handlers
Telegram bots feel simple at first: receive an update, send a response. In production, though, reliability depends on how you ingest updates, handle retries, and keep state consistent under concurrency. This guide shows a practical architecture Telegram developers can apply immediately.
Telegram bot development looks easy in demos and gets hard in production. The bot receives an update, executes some logic, and replies. That’s enough for a prototype, but once real users arrive you start seeing duplicate messages, slow responses, retry storms, and inconsistent state.
The good news: most of these problems are solvable with a small set of engineering patterns. If you build your bot around webhooks, background queues, and idempotent handlers, you get a system that is easier to scale and much harder to break.
This article focuses on a practical implementation for Telegram developers, especially if your bot does more than echo text.
The problem with a naive bot design
A common first implementation does everything inside the request handler:
- Telegram sends an update.
- Your server parses it.
- Your code talks to databases, third-party APIs, or payment providers.
- You send a response back to the user.
This works until one of these things happens:
- a handler takes too long and Telegram retries the update
- your process restarts midway through the work
- the same update is delivered more than once
- multiple updates for the same chat arrive at the same time
- an external API times out, but the action actually succeeded
If your code assumes “one update equals one execution,” you will eventually produce duplicate orders, duplicated notifications, or broken user state.
A production-friendly architecture
A simple, robust bot architecture has three layers:
- Ingress layer: receives Telegram updates via webhook
- Processing layer: places work onto a queue and acknowledges quickly
- Worker layer: performs business logic and sends outgoing messages
The key idea is to make the webhook endpoint fast and reliable. It should validate the request, record the update, enqueue work, and return 200 OK as quickly as possible.
Why webhooks instead of polling?
Polling is fine for development, but webhooks are usually better in production because:
- Telegram pushes updates to you instead of waiting for you to poll
- you can scale your app behind a load balancer
- you can separate request handling from heavy processing
- you can control retries more explicitly in your own system
For Telegram developers, webhooks also make it easier to integrate with normal web infrastructure, observability tools, and background workers.
Step 1: Accept updates and respond fast
Your webhook handler should do the minimum necessary work.
Typical responsibilities:
- verify the request source if you use a secret token
- parse the incoming update
- store the raw update or its identifier
- enqueue a job for asynchronous processing
- return success immediately
Example request flow
Telegram -> /webhook -> validate -> enqueue -> 200 OK
Do not call slow APIs in the webhook path unless the operation is guaranteed to be trivial. The webhook path is part of your availability boundary. If it slows down, Telegram can retry the same update, and now you are dealing with duplicates.
Step 2: Make handlers idempotent
Telegram may deliver the same update more than once. Your own retries can also re-run work. That means your processing logic must be idempotent: running it twice should produce the same final outcome as running it once.
Use an update ledger
The simplest pattern is to persist a record of processed updates.
Store at least:
update_idchat_idmessage_idor callback identifier when relevant- processing status
- timestamps
Before processing a job, check whether its update_id has already been handled.
CREATE TABLE processed_updates (
update_id BIGINT PRIMARY KEY,
status TEXT NOT NULL,
processed_at TIMESTAMP NOT NULL DEFAULT NOW()
);
When a worker picks up a job:
- attempt to insert
update_id - if insert fails because it already exists, skip the job
- otherwise proceed with the business logic
This gives you a basic deduplication guarantee.
Make side effects safe too
Deduplicating the update is only half the job. If your handler charges money, creates tickets, or sends notifications, those external actions must also be protected.
Examples:
- payment operations should use provider-side idempotency keys
- notification sends should be tracked with a unique internal event ID
- state transitions should be enforced with database constraints
If you only dedupe at the queue level but not at the side-effect level, a retry can still create duplicate external actions.
Step 3: Model bot state explicitly
Many bots get messy because they rely on implied state: “if the last message was X, then the user is probably in step 3.” That breaks as soon as the user sends two messages quickly or switches devices.
Instead, store explicit conversation state.
A practical state model
For each chat or user, store:
- current workflow name
- current step
- important payload fields
- last update handled
- version or revision number
Example:
CREATE TABLE chat_sessions (
chat_id BIGINT PRIMARY KEY,
flow_name TEXT,
step TEXT,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
version INT NOT NULL DEFAULT 0,
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
This lets you answer questions like:
- Is this user in onboarding?
- Did they already choose a plan?
- Are we waiting for a photo, a confirmation, or a payment callback?
Use optimistic concurrency
If updates can arrive close together, protect session writes with a version number.
Pattern:
- load session state with
version = N - compute the next state
- write it back only if
versionis stillN - if it changed, reload and retry
This prevents one update from silently overwriting another.
Step 4: Split fast responses from slow work
Telegram users expect quick interaction. If your bot needs to generate a report, call an AI service, or process a file, acknowledge the command first and finish the work in the background.
Example pattern
- user sends
/report - bot replies: “Got it, I’m generating your report.”
- worker builds the report
- worker sends the result when ready
This pattern keeps the UX responsive and reduces Telegram retry pressure.
For extra clarity, you can also edit the original message later or use a progress message if the workflow is long-running.
Step 5: Build a queue with retry control
A queue decouples the webhook from the worker. That matters because your worker may need to retry transient failures without holding the Telegram request open.
Queue requirements
Your queue should support:
- at-least-once delivery
- visibility timeout or retry delay
- dead-lettering for poison messages
- job metadata such as
update_id,chat_id, and flow type
Retry strategy
Not all failures are equal.
- Transient failures: network timeouts, temporary API outage, deadlock
- Permanent failures: malformed payload, invalid user input, missing required data
Retry only transient failures automatically. Permanent failures should be logged, marked failed, and, if needed, surfaced to the user with a helpful message.
Dead letters are not optional
A dead-letter queue is where failed jobs go after repeated retry attempts. Without it, a single malformed update can repeatedly poison your system.
Dead-letter records are valuable for:
- debugging production incidents
- replaying jobs after fixes
- identifying recurring integration errors
Step 6: Handle Telegram-specific edge cases
Telegram bots have a few quirks worth designing around.
Callback queries need quick acknowledgment
If you use inline keyboards, answer the callback query quickly, even if the real work continues in the background. Otherwise users can see a loading spinner for too long.
Typical flow:
- receive callback query
- immediately answer it
- enqueue the business action
- send updated message when done
Message edits are useful for stateful UX
Instead of sending many new messages, consider editing one message to reflect state changes. This keeps chats cleaner and helps users follow the flow.
Use this carefully:
- edits can fail if the message is too old or unchanged
- keep a fallback path for sending a fresh message
Remember that users can send unexpected input
If the bot is waiting for a photo and receives text instead, your handler should not crash or corrupt state. Treat every inbound update as potentially out of order or irrelevant.
Observability: know what your bot is doing
Once you use asynchronous processing, logs become essential. At minimum, log:
update_idchat_iduser_id- handler name
- flow step
- job status
- error type
Add structured metrics
Track metrics such as:
- webhook latency
- queue depth
- handler success rate
- retry count
- dead-letter count
- time from update received to user response
These metrics help you spot issues before users report them.
For example, if queue depth rises while webhook latency remains low, the ingestion layer is healthy but workers are lagging. If webhook latency spikes, Telegram may retry and amplify the problem.
A practical implementation sketch
Here is a simple end-to-end model you can implement in most stacks.
Webhook endpoint
onWebhookRequest(update):
verifyRequest()
if updateAlreadySeen(update.update_id):
return 200
enqueueJob({
update_id: update.update_id,
chat_id: update.message.chat.id,
type: classify(update),
payload: update
})
return 200
Worker
processJob(job):
if insertProcessedUpdate(job.update_id) fails:
return
session = loadSession(job.chat_id)
nextAction = route(job, session)
result = runBusinessLogic(nextAction)
persistSessionChanges(session, result)
sendTelegramMessage(job.chat_id, result.reply)
A few implementation notes
classify(update)decides whether the job is a message, callback query, command, or payment eventroute(job, session)chooses the handler based on current statepersistSessionChanges()should be transactional if it updates multiple tablessendTelegramMessage()should tolerate retry-safe sending patterns if your transport fails after the message was accepted
Common mistakes to avoid
1. Doing all work inside the webhook
This is the most common mistake. It creates retries, timeouts, and tight coupling.
2. Assuming updates arrive exactly once
They do not. Design for duplicates from day one.
3. Storing conversation state only in memory
A process restart should not erase user progress.
4. Using one giant handler for every update type
Separate commands, callback queries, and content-specific flows. It makes the code easier to test and less fragile.
5. Ignoring external idempotency
Database dedupe alone is not enough when you call payment gateways, email services, or other APIs.
Testing strategy that catches real bugs
A Telegram bot should be tested like a distributed system, not just like a script.
Unit tests
Test:
- command routing
- state transitions
- validation rules
- idempotency behavior
Integration tests
Test:
- webhook request parsing
- queue enqueueing
- database persistence
- callback query flows
Failure tests
Simulate:
- duplicate updates
- worker crashes after side effects
- API timeouts
- out-of-order messages
- repeated callback queries
These tests are especially valuable for bots that handle payments, reservations, or operational tasks.
A good default design
If you are starting a new Telegram bot project, this is a strong default:
- webhook ingestion with fast
200 OK - queue-based background processing
- update deduplication by
update_id - explicit session state in a database
- optimistic concurrency for session writes
- idempotency keys for all external side effects
- structured logs and metrics
- dead-letter queue for failures
This is not the simplest possible bot, but it is the simplest design that holds up once real users and real failures arrive.
Final takeaway
Telegram bots are distributed systems in disguise. The moment your bot does more than simple replies, you need to treat updates as unreliable input, side effects as retryable operations, and state as something that must be stored and versioned explicitly.
If you implement webhooks, queues, and idempotent handlers early, you avoid the painful refactor from “works in dev” to “reliable in production.” That investment pays off quickly for any Telegram bot with real business logic.