· 4 min read

Telegram Open Source Tutorial: Avoid the Most Common Handler Leak

A lot of Telegram bots and clients become slow, duplicate work, or crash for reasons that look mysterious at first. In many cases, the real issue is simple: developers register listeners, observers, or callbacks more than once and never remove them.

When developers work with Telegram’s open source ecosystem—whether building a bot, a TDLib-based client, or a custom integration—the most common mistake is not a Telegram API bug. It is accidentally registering the same event handler multiple times.

This usually shows up as:

  • duplicate replies
  • repeated callback execution
  • memory usage creeping up over time
  • “phantom” updates after reconnects
  • harder-to-debug race conditions in long-running processes

If you have ever seen a bot answer the same command twice after a restart, this tutorial is for you.

The mistake: registering listeners inside a hot path

A typical pattern looks harmless at first:

function setupHandlers(client) {
  client.on('message', handleMessage);
}

async function onStart() {
  await connectClient();
  setupHandlers(client);
}

This works once. But if onStart() runs again after a reconnect, hot reload, worker restart, or configuration refresh, the handler is added again.

Now every incoming message triggers handleMessage() twice. After a few cycles, it may trigger three or four times.

The same mistake happens in many Telegram projects:

  • calling bot.on(...) inside a request handler
  • attaching client.addEventHandler(...) on every reconnect
  • recreating a bot instance without tearing down old listeners
  • subscribing to updates inside a loop or retry block

Why Telegram projects hit this often

Telegram apps are event-driven and long-lived. That means handlers are usually attached once, then left running for hours or days.

Open source Telegram stacks make this easy to get wrong because they often expose flexible event APIs:

  • Bot frameworks often let you attach message, callback query, or command handlers dynamically.
  • TDLib-based clients may expose update callbacks that look cheap to register, so developers do it in multiple initialization paths.
  • Custom gateway services sometimes wrap Telegram updates in app-level pub/sub systems, which can multiply listeners if the wrapper reconnects.

The bug hides because the app still “works.” It just works more and more incorrectly over time.

How to detect it quickly

Before changing code, verify that this is the issue.

1. Log handler registration

Add a one-line log where the listener is attached:

console.log('Registering message handler');
client.on('message', handleMessage);

If that log appears multiple times during one process lifetime, you found the problem.

2. Count active listeners

In Node.js-style APIs, inspect listener counts if available:

console.log(client.listenerCount('message'));

If the count increases after reconnects or config reloads, the handler is being added repeatedly.

3. Add a unique marker to the handler

Sometimes duplicate execution comes from several code paths calling the same function. Put a stable label in the log:

function handleMessage(msg) {
  console.log('[message-handler-v1]', msg.id);
}

If the same message ID appears more than once from the same marker, the handler is duplicated rather than the update being resent.

The correct pattern: initialize once, attach once

The safest approach is to separate initialization from runtime event handling.

Bad

async function start() {
  const client = await createClient();
  client.on('message', handleMessage);
  await client.run();
}

This is dangerous if start() can run multiple times.

Better

let handlersAttached = false;

function attachHandlers(client) {
  if (handlersAttached) return;
  handlersAttached = true;

  client.on('message', handleMessage);
  client.on('callback_query', handleCallback);
}

That guard is a simple fix, but it should not be your only line of defense. A boolean flag prevents double registration, yet it can also hide lifecycle bugs if you reuse the same process object across multiple sessions.

Better still: tie handler lifecycle to the client lifecycle

Instead of relying on global state, bind setup and cleanup to the Telegram client instance.

function createTelegramRuntime() {
  const client = createClient();

  const onMessage = (msg) => handleMessage(client, msg);
  const onCallback = (query) => handleCallback(client, query);

  client.on('message', onMessage);
  client.on('callback_query', onCallback);

  return {
    client,
    stop() {
      client.off('message', onMessage);
      client.off('callback_query', onCallback);
      client.close();
    }
  };
}

This pattern matters because it makes cleanup explicit. If the process reloads, you can stop the old runtime before creating a new one.

Common places the bug appears in Telegram code

1. Reconnect handlers

Developers often add a reconnect callback that re-registers everything:

client.on('reconnect', () => {
  client.on('message', handleMessage);
});

This is almost always wrong unless you first remove the old handlers or rebuild the client from scratch.

2. Webhook setup code

When Telegram webhook servers boot, people sometimes wire routes and bot events together inside the same startup function. If the server reloads or the app uses multiple workers, the registration may happen more than once.

3. Middleware composition

In frameworks that support middleware chains, adding middleware inside a per-chat or per-request function can create a silent listener explosion.

4. Module reload during development

Hot reload tools can re-import modules while leaving the old process state alive. If your handler registration lives at module top level and the framework does not fully dispose the old instance, duplicate handlers follow.

Practical debugging checklist

When a Telegram bot behaves as if every event happens twice, check these in order:

  1. Search for .on(, .addEventHandler(, or equivalent registration calls.
  2. Look for registration inside loops, retries, or reconnect callbacks.
  3. Confirm whether startup code can run more than once.
  4. Inspect listener counts if the library supports it.
  5. Make teardown code remove all handlers before reconnecting.

If you are using a library that supports named handlers, prefer named functions over anonymous callbacks. That makes removal easier and reduces accidental duplication.

A safer template for Telegram projects

Here is a simple structure you can adapt:

class TelegramApp {
  constructor(client) {
    this.client = client;
    this.boundOnMessage = this.onMessage.bind(this);
    this.boundOnCallback = this.onCallback.bind(this);
    this.started = false;
  }

  start() {
    if (this.started) return;
    this.started = true;

    this.client.on('message', this.boundOnMessage);
    this.client.on('callback_query', this.boundOnCallback);
  }

  stop() {
    if (!this.started) return;
    this.started = false;

    this.client.off('message', this.boundOnMessage);
    this.client.off('callback_query', this.boundOnCallback);
  }

  onMessage(msg) {
    // handle message
  }

  onCallback(query) {
    // handle callback
  }
}

This does three useful things:

  • prevents duplicate startup registration
  • makes cleanup explicit
  • keeps handler identity stable so removal works reliably

Rule of thumb

If a Telegram handler can be attached from more than one code path, it will eventually be attached twice.

The fix is to make handler attachment:

  • single-responsibility
  • idempotent
  • paired with cleanup

That one discipline will prevent a surprising number of production issues in Telegram bots and clients.

Final takeaway

Most Telegram open source bugs that look like networking issues, API instability, or race conditions are really lifecycle bugs. Start by checking whether handlers, observers, or callbacks are being registered more than once.

If you make registration explicit and teardown reliable, your bot will behave more predictably, scale better, and be much easier to debug.

← All posts