Building Scalable Telegram Bots with Telethon

Most Telegram bots die at around ten thousand users, and almost never because Python was too slow. They die because one blocking call in an update handler stalls the entire event loop.
Why MTProto Instead of the Bot API
Telethon speaks MTProto directly, which is Telegram's native protocol. That buys you things the HTTP Bot API will not give you: full user-account automation, bulk history access, granular update types, and no per-request HTTP overhead. The tradeoff is that you manage the session, the connection, and the flood limits yourself.
The Rule That Governs Everything
One slow await in a handler delays every other user. Handlers should validate, enqueue, and return.
```python
from telethon import TelegramClient, events
import asyncio, jsonclient = TelegramClient('bot', API_ID, API_HASH) queue = asyncio.Queue(maxsize=10_000)
@client.on(events.NewMessage(pattern=r'^/convert')) async def handler(event): try: queue.put_nowait({'chat': event.chat_id, 'msg': event.id}) except asyncio.QueueFull: await event.reply('Busy right now, try again in a minute.') return await event.reply('Queued. I will reply when it is done.')
async def worker(n): while True: job = await queue.get() try: await do_heavy_work(job) except Exception: log.exception('job failed: %s', job) finally: queue.task_done() ```
A bounded queue is deliberate. An unbounded one converts a traffic spike into an out-of-memory kill, and the visible symptom is a bot that simply vanishes.
Respecting FloodWait
Telegram will tell you to slow down, and it expects you to listen. Never retry immediately inside an except block.
```python
from telethon.errors import FloodWaitErrorasync def safe_send(chat, text, attempts=3): for _ in range(attempts): try: return await client.send_message(chat, text) except FloodWaitError as e: await asyncio.sleep(e.seconds + 1) raise RuntimeError('gave up sending') ```
Also cap your own outbound rate. Roughly one message per second per chat, and around twenty per second globally, keeps you comfortably clear of restrictions.
State and Persistence
SQLite sessions are fine for one process and a disaster for several, because two processes writing the same session file corrupt it. Move to a shared session backend and put user state in Redis or Postgres with a connection pool.
- asyncpg pools, never single connections. A pool of ten handles far more throughput than one connection ever will.
- Cache resolved entities. get_entity is a network call. Store the access hash yourself.
- Make handlers idempotent. Reconnects replay updates, so a duplicate message id must be a no-op.
Scaling Past One Process
When a single event loop saturates, split by responsibility before splitting by shard: one process consuming updates, several stateless workers doing the work, one scheduler for cron-style jobs. Only when a single consumer cannot keep up should you shard by chat id modulo N, with each shard on its own bot token or session.
Most bots that feel unreliable are not under-provisioned. They are synchronous somewhere they should not be, and one file-download call is holding up ten thousand people.
Enjoyed this article?
Share it with your network and join the conversation.