// 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 …

// 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.

// Contact

Need help?

Get in touch with me and I'll help solve the problem

Message on Telegram

Отвечаю в течение рабочего дня (03:00–13:00 GMT)

Или оставьте заявку здесь:

Send request
Write and get a quick reply