· 5 min read
Improving Telegram Bot Throughput with Bounded Concurrency and Backpressure
A practical tutorial for Telegram developers on increasing bot performance without melting your API quota or event loop. Learn how to identify the bottleneck, add bounded concurrency, and keep updates flowing smoothly under load.
Telegram bots often start out feeling fast: one update comes in, you reply, and everything looks fine. The trouble begins when message volume grows. A naive handler that does network I/O, database writes, and API calls sequentially can quickly turn into a queueing machine—updates pile up, latency rises, and retries make the problem worse.
This tutorial shows a performance improvement that matters in real Telegram workloads: replace unbounded or fully sequential processing with bounded concurrency plus backpressure. The goal is not to make everything parallel. The goal is to keep your bot responsive while preventing overload.
The problem
Consider a bot that:
- receives updates from Telegram,
- fetches user state from a database,
- calls an external service,
- sends one or more messages back.
A simplistic implementation might process each update like this:
async def handle_update(update):
state = await db.load_user_state(update.user_id)
result = await external_api.compute(state, update.text)
await bot.send_message(update.chat_id, result)
If your update loop awaits each step without parallelism, one slow request blocks the next. If you try to fix it by spawning a task for every update, you can create the opposite problem:
- hundreds or thousands of concurrent tasks,
- database pool exhaustion,
- Telegram API flood limits,
- increased memory usage,
- unpredictable latency spikes.
The right fix is usually bounded concurrency.
What bounded concurrency does
Bounded concurrency lets several updates run at once, but only up to a limit you choose. That limit should match your real bottlenecks:
- database connection pool size,
- rate limits of downstream APIs,
- CPU capacity if handlers do heavy computation,
- Telegram API request tolerance.
This gives you three benefits:
- Higher throughput than single-threaded sequential handling.
- Lower tail latency than an overloaded, unbounded task pile.
- Stability under traffic bursts.
Step 1: Measure before changing anything
Before tuning, measure the current behavior.
For Telegram bots, track at least:
- update processing time,
- queue wait time,
- message send latency,
- database query latency,
- error rate from Telegram and external services.
A useful metric is the difference between arrival time and completion time per update. If the gap keeps growing during traffic spikes, you have a throughput problem.
Example log fields:
update_id=18492 chat_id=1234 start=12:00:01.120 end=12:00:01.920 duration_ms=800 queued_ms=250
If queued_ms keeps increasing, your handlers are not keeping up.
Step 2: Put a concurrency limit in front of the worker
A simple and effective pattern is a worker pool with a semaphore.
Python example with asyncio
import asyncio
MAX_CONCURRENCY = 20
semaphore = asyncio.Semaphore(MAX_CONCURRENCY)
async def process_update(update):
async with semaphore:
state = await db.load_user_state(update.user_id)
result = await external_api.compute(state, update.text)
await bot.send_message(update.chat_id, result)
async def main():
async for update in telegram_updates():
asyncio.create_task(process_update(update))
This is better than sequential processing, but it still has a weakness: create_task can schedule too many pending tasks if updates arrive faster than they are consumed. The semaphore limits active work, but not task buildup.
For a real improvement, add a bounded queue.
Step 3: Add a bounded queue for backpressure
A bounded queue prevents unlimited memory growth and forces the system to slow down gracefully when overloaded.
import asyncio
QUEUE_SIZE = 500
MAX_CONCURRENCY = 20
queue = asyncio.Queue(maxsize=QUEUE_SIZE)
semaphore = asyncio.Semaphore(MAX_CONCURRENCY)
async def producer():
async for update in telegram_updates():
await queue.put(update)
async def worker():
while True:
update = await queue.get()
try:
async with semaphore:
state = await db.load_user_state(update.user_id)
result = await external_api.compute(state, update.text)
await bot.send_message(update.chat_id, result)
finally:
queue.task_done()
async def main():
workers = [asyncio.create_task(worker()) for _ in range(MAX_CONCURRENCY)]
await producer()
await queue.join()
This pattern gives you controlled ingestion and controlled execution.
Why this helps
- The queue caps memory use.
- The worker count caps active load.
- If Telegram delivers a burst, the queue absorbs it.
- If the burst is sustained, the queue fills and you can choose a policy.
Step 4: Decide what to do when the queue is full
Backpressure only works if you define an overload policy.
You have four common options:
1. Wait
Block the producer until space is available.
- Good for internal job pipelines.
- Risky for long-lived webhook handlers if it causes request timeouts.
2. Drop low-value updates
For bots with noncritical real-time features, you may discard stale or low-priority updates.
- Example: typing indicators, repeated status checks, or duplicate commands.
- Use cautiously; never drop essential user actions silently.
3. Coalesce updates
If users can send several messages that only the latest one matters for, merge them.
- Example: progress updates or search-as-you-type flows.
- This often cuts work dramatically.
4. Return a busy response
Let the user know the bot is under load and ask them to retry.
- Best for interactive command bots.
- Make the response brief and polite.
Example busy-message logic:
async def handle_incoming(update):
try:
queue.put_nowait(update)
except asyncio.QueueFull:
await bot.send_message(
update.chat_id,
"I'm busy right now. Please try again in a moment."
)
Step 5: Reduce per-update work
Concurrency helps only if each update is reasonably efficient. Before adding more workers, cut unnecessary work.
Cache user state
If you repeatedly load the same user preferences, cache them with a short TTL.
Batch writes
If your bot records analytics or audit events, buffer them and write in batches.
Avoid repeated Telegram calls
Some bots fetch chat or user metadata for every update even though the data rarely changes.
Reuse clients
Create HTTP and database clients once and reuse them. Recreating connections per update is a silent performance killer.
Step 6: Respect Telegram limits
Telegram bots do not exist in a vacuum. Even if your app can process more concurrently, Telegram API usage still needs discipline.
Watch for:
- send message burst limits,
- edits to the same message in rapid succession,
- callback query timeouts,
- long webhook request times.
If your handler performs multiple Telegram actions, wrap the outbound side in its own limiter.
send_semaphore = asyncio.Semaphore(10)
async def safe_send(chat_id, text):
async with send_semaphore:
return await bot.send_message(chat_id, text)
This prevents your compute concurrency from overwhelming your message-sending capacity.
Step 7: Measure the effect of the change
After adding bounded concurrency and backpressure, compare metrics before and after.
Look for:
- lower median and p95 latency,
- fewer timeout errors,
- stable memory usage,
- queue length that rises briefly during bursts and then drains,
- fewer retry storms.
A good sign is that latency stays predictable even when traffic doubles.
Common mistakes
Too much concurrency
If you set the limit too high, you may improve throughput on paper while causing database contention and Telegram API throttling.
No timeout handling
If a downstream API hangs, one stuck request can occupy a worker for too long. Add timeouts to network calls.
Ignoring per-chat ordering
Some Telegram bots need messages handled in order per chat. In that case, global concurrency is fine, but you may need a per-chat lock or per-chat queue.
Handling everything in one worker
If one stage is slow, the whole pipeline slows. Split expensive work into dedicated stages when necessary.
A practical tuning strategy
Start conservative:
- queue size: a few hundred updates,
- worker concurrency: roughly equal to DB pool size or a bit lower,
- outbound Telegram calls: separate smaller limit,
- timeouts: strict and explicit.
Then test with realistic load:
- burst updates from a few chats,
- steady traffic over several minutes,
- slow external API responses,
- partial failures.
Increase concurrency only if metrics improve and resource usage remains stable.
Final thoughts
For Telegram bots, performance is rarely about raw CPU speed. More often it is about controlling how work enters the system and how much work is allowed to run at once. Bounded concurrency plus backpressure is a simple pattern that can turn an unreliable bot into a stable one.
If you remember only one thing, make it this: don’t let incoming updates create unlimited work. Shape the load, cap concurrency, and measure the result. That is the performance improvement that pays off in production.