// Python Dev
Two days on a task that seemed trivial: asynchronous loading in Telegram bots
Published on 2026-05-30
There’s a class of tasks that look like fifteen minutes of work. Then you sit down to them and discover it’s not about the code — it’s about how the system is built under the hood. This is a story about one such task.
Context
I was developing a Telegram bot with logic that seemed simple at first glance: the user sends photos, the bot collects them, waits for the last one and asks — “Are there any more?”. There are limits: minimum 5 photos, maximum 30. If fewer than five — stay silent, wait for more. If more than thirty arrive — stop and say that that’s enough. Then the FSM moves to the next state and the main processing begins.
It would seem: catch the event, count the photos, after the last one send a button. A couple of hours of work with reading specs and tests, tops.
But something went wrong and the work stretched into two days.
How Telegram works with photos
The first thing I didn’t take into account: Telegram does not transmit photos one by one when there are several. It groups them into media groups, assigning each a media_group_id — and sends them in chunks of 10. This behavior is built into the platform and is not configurable.
You might think: fine — catch events by media_group_id, wait for silence, send the button. But here comes the second surprise.
Telegram does not tell you in advance how many photos will be in a group.
You receive events one after another. Each next one may be the last — or may not. If the photos are large, pauses occur between events: Telegram servers process the uploads. And you face the classic problem of uncertainty: wait more or already send the message?
First attempts
The first idea — wait a fixed timeout after each event. Wait 1–2 seconds of silence — that means everything has arrived, send the button. The logic is clear, easy to implement: track the time of the last received photo, launch a background check without blocking the main thread. But you remember that photos can be large, networks are unstable, and waiting becomes a lottery.
Problem with timestamps
Here’s what happens in practice. A user sends 25 photos. The client starts uploading. Telegram servers accept the photos and process them — and it’s the processing moment on the server that is recorded as the event timestamp, not the moment of sending from the device.
This means: sendMediaGroup events can arrive at the bot with delay and in an unpredictable order relative to how they appear in the client interface.
We catch the last event, wait a second, send the message with the button. Everything seems logical. But in the user interface it looks like this: first our button appears, then — bam — several more photos arrive on top of it, which Telegram shows in the timeline according to the time of their server processing.
The button flies up. The user doesn’t see it and doesn’t understand what to do next.
I tried different timeouts, tracked incoming message IDs — nothing gave a stable result. Because the problem is not the timeout. The problem is that you don’t control the order of appearance of messages in the client interface: Telegram decides that on its side.
The solution that worked
In the end I came to a simple idea that solves the problem not by prevention, but by reacting to the fact.
The algorithm is:
After the last received event wait 1–2 seconds. Send a message with the button and save its message_id. If another photo arrives after that — delete the old button message and send a new one. Repeat until real silence occurs.
# This is roughly how it looks in aiogram
last_photo_time = {}
button_message_id = {}
async def handle_photo(message: Message, state: FSMContext):
user_id = message.from_user.id
last_photo_time[user_id] = time.time()
data = await state.get_data()
photos = data.get("photos", [])
photos.append(message.photo[-1].file_id)
await state.update_data(photos=photos)
# Delete the previous button if it existed
if user_id in button_message_id:
try:
await message.bot.delete_message(
chat_id=message.chat.id,
message_id=button_message_id[user_id]
)
except Exception:
pass
# Wait for silence without blocking
asyncio.create_task(check_and_send_button(message, state))
async def check_and_send_button(message: Message, state: FSMContext):
user_id = message.from_user.id
await asyncio.sleep(1.5)
# If a new photo arrived during this time — exit, a new task is already running
if time.time() - last_photo_time[user_id] < 1.4:
return
data = await state.get_data()
photos = data.get("photos", [])
if len(photos) < 5:
return # Keep quiet, wait for more
if len(photos) >= 30:
# Say that's enough, move on
await state.set_state(NextState.processing)
return
# Send the button and remember the id
sent = await message.answer(
"Is this all the photos?",
reply_markup=confirmation_keyboard()
)
button_message_id[user_id] = sent.message_id
The key idea: we don’t try to guess when the last photo will arrive. We’re simply ready to re-send the button if we were wrong. The user always sees the current button at the bottom of the feed, next to the latest photos. A visually disappearing button is not noticeable.
What matters here from an architectural point of view
This is a good example that asynchronous systems often cannot be designed based on assumptions about event ordering. Telegram is an asynchronous system with its own processing logic on servers. Our bot is also an asynchronous system. When they interact, event ordering in both interfaces is not guaranteed.
The first instinct is to predict the behavior and code for it. The second, more resilient instinct is to make a system that correctly reacts to unexpected states.
Deleting and re-sending a message looks like a crude tool. But this solution turned out to be reliable, predictable and understandable for the user. No magic with timestamps, no blind tuning of timeouts.
Sometimes a simple solution is not a compromise. It is the right solution.
Stack
Python, aiogram, asyncio. FSM for dialog state management.
// Python Dev
Другие статьи Python Dev
2026-05-15
n8n: a pretty wrapper that ate up two days
The client came with an idea: they have access to the level.travel API, hundreds of Telegram channels for travel agents, and a desire to automatically publish …
2026-05-14
How to integrate an LLM with memory: store the facts yourself
LLMs reason very well. Their memory is poor. Ask an AI assistant about something you mentioned earlier in a long dialogue — and it might get confused, mix up …
2026-05-13
Data parsing in 2026: don't run every page through an LLM!
Among parser developers a strange approach is spreading: send every downloaded page to an LLM asking it to find the needed data. Sounds convenient — you …
// Python Projects
Проекты Python Dev
2026-05-28
Robot collector: automatic debtor calls
An automated voice-calling system for debt collection with Google Sheets integration, speech synthesis, response recognition, and repeated call attempts.
2026-05-27
Automatic management of a Telegram channel network for a travel agent
An automated publishing system for 150 Telegram channels with tour and flight selection, image generation, and scheduled posting.
2026-04-29
Automated call transcript: from recording to a structured document
Automatic call minutes: from recording to a structured document Distributed teams spend a lot of time on calls. They discuss tasks, make decisions, assign …
// Contact
Need help?
Get in touch with me and I'll help solve the problem
Message on TelegramОтвечаю в течение рабочего дня (03:00–13:00 GMT)
Или оставьте заявку здесь: