DEV Community

Boris Kl
Boris Kl

Posted on

Your Telegram bot replies twice? It's timing, not a logic bug

A Telegram bot replies to the same message twice. An n8n flow processes an order, then processes it again ten seconds later. The owner reads the handler code, finds nothing wrong, and assumes the logic is broken.

It usually isn't. These bugs are almost always about timing, not logic — and once you know the three places timing bites, they stop being mysterious.

1. The webhook you never answered

Telegram (and most webhook senders) wait for an HTTP 200. If your endpoint does the work first and answers afterward, a slow database call or a third-party API can push you past the timeout. The sender assumes delivery failed and sends the same update again. Now your "double reply" isn't a logic bug — it's the same event arriving twice because you were too slow to say "got it."

The fix is to acknowledge first, process second:

@app.post("/webhook")async def webhook(request):
    update = await request.json()
    queue.put_nowait(update)   # hand off
    return Response(status=200) # answer immediately
Enter fullscreen mode Exit fullscreen mode

Return 200 the moment you've safely stored the update. Do the real work in a background task or a worker. The sender stops retrying, and the duplicates dry up.

2. No dedup, so retries become real work

Answering fast helps, but retries still happen — network blips, restarts, a sender that's feeling anxious. The honest assumption is: every event can arrive more than once. So make handling it twice harmless.

Every Telegram update has an update_id. Every message has a message_id. Most webhook payloads have some stable id. Key on it:

if await seen.exists(update_id):
    return            # already handled, do nothing
await seen.add(update_id, ttl=86400)
await handle(update)
Enter fullscreen mode Exit fullscreen mode

seen can be Redis, a unique column in your database, anything that's shared across workers. The point is that "process this order" runs once even if the event shows up three times. People call this idempotency; it just means doing it again changes nothing.

3. Two messages, one piece of state, no lock

This is the one that looks the most like a logic bug and isn't. A user double-taps a button. Two updates arrive almost together. Both handlers read "balance: 100", both subtract 30, both write "70". You charged once for two actions, or booked the same slot twice.

Nothing in the logic is wrong. The two runs just overlapped. The fix is to stop them from overlapping on the same state:

async with lock(f"user:{user_id}"):
    balance = await get_balance(user_id)
    await set_balance(user_id, balance - 30)
Enter fullscreen mode Exit fullscreen mode

A per-user lock (Redis SET NX, a database row lock, whatever you have) means update B waits for update A to finish before it touches the same row. In n8n the same idea shows up as a queue or a "wait for previous execution" step instead of letting every webhook fire its own parallel run.

The part that saves you next time

Most of these never get diagnosed because they're invisible. The handler "works" when you test it by hand — you can't tap fast enough to cause the race, and your local webhook answers instantly. It only breaks under real traffic, at 3am, where you're not looking.

So log the timing, not just the errors. Log the update_id on the way in and the way out. Log when a lock is contended. The first time you see the same update_id logged twice, the whole thing stops being a mystery and becomes a one-line fix.

I run Telegram bots and n8n in production every day, and I've hit all three of these. None of them were in the logic. They were in the gaps between events — and that's almost always where to look first.

Top comments (0)