Telegram bot developers often encounter situations where, as the user base grows, the system starts failing. The bot suddenly stops responding, 429 Too Many Requests errors appear in the logs, and new incoming Webhook requests form a huge queue. The reason lies in the strict limits of Telegram Bot API, which require competent architectural design for sending messages.
In this article, we will examine the platform's official and hidden limits in detail, learn how to correctly intercept the retry_after header via cURL, organize a safe sending queue in Yii2, and look at the pattern of grouping (aggregating) notifications for a single chat.
Official and Actual Telegram Bot API Limits
Telegram imposes several basic restrictions on sending outgoing messages. If they are exceeded, the API server returns an HTTP 429 code with a JSON body containing the parameters.retry_after field.
- Global limit per bot: no more than 30 messages per second across all chats combined.
- Limit per specific private chat: no more than 1 message per second. In rare cases, short bursts are allowed, but prolonged sending at intervals of less than 1000 ms leads to blocking.
- Limit for groups and channels: no more than 20 messages per minute to a single group/channel.
- Maximum text size: 4096 characters for a text message (and 1024 characters for media captions).
- Payload size in callback_data: no more than 64 bytes.
The main mistake of novice developers is attempting to send outgoing messages synchronously right during the processing of an incoming Webhook request or via a direct foreach loop from the database. This instantly leads to a cascading crash of the application.
Parsing 429 Retry-After via cURL in PHP
For correct API operation, sending should occur through a wrapper that analyzes the HTTP response code and JSON structure. When calling cURL, be sure to check request execution success, JSON validity, and the presence of the ok: false flag.
Below is a working transport component for Yii2 that implements safe API calls with rate limit handling:
<?php
namespace app\components;
use Yii;
use yii\base\Component;
use yii\base\Exception;
class TelegramTransport extends Component
{
private string $token;
public function init(): void
{
parent::init();
$this->token = (string) (getenv('TELEGRAM_BOT_TOKEN') ?: Yii::$app->params['telegramBotToken'] ?? '');
if (empty($this->token)) {
throw new Exception('TELEGRAM_BOT_TOKEN is not configured in environment or params.');
}
}
public function execute(string $method, array $params = []): array
{
$url = "https://api.telegram.org/bot{$this->token}/{$method}";
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($params),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
]);
$response = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($response === false) {
throw new Exception("Telegram cURL Error: {$curlError}");
}
$data = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new Exception("Invalid JSON response from Telegram API: {$response}");
}
if ($httpCode === 429) {
$retryAfter = (int) ($data['parameters']['retry_after'] ?? 3);
Yii::warning("Telegram Rate Limit 429 hit. Retry after {$retryAfter} sec.", 'telegram');
return [
'ok' => false,
'error_code' => 429,
'retry_after' => $retryAfter,
'description' => $data['description'] ?? 'Too Many Requests',
];
}
if ($httpCode !== 200 || !($data['ok'] ?? false)) {
Yii::error("Telegram API Error [{$httpCode}]: " . json_encode($data), 'telegram');
}
return $data;
}
}Outgoing Message Queue Architecture in Yii2
To comply with the limits of "1 message per second per chat" and "30 messages per second total", sending must be moved to background tasks (Yii Queue + Redis). Upon receiving a 429 error, the task should not be marked as failed — it should be delayed by the specified retry_after interval.
Let's create a queue task (Job) class that uses a Redis lock to prevent sending messages to the same chat too frequently:
<?php
namespace app\jobs;
use Yii;
use yii\base\BaseObject;
use yii\queue\JobInterface;
use app\components\TelegramTransport;
class SendTelegramMessageJob extends BaseObject implements JobInterface
{
public int $chatId;
public string $text;
public ?array $replyMarkup = null;
public function execute($queue): void
{
$redis = Yii::$app->redis;
$lockKey = "tg_rate_limit:chat:{$this->chatId}";
// Проверяем, не отправляли ли мы сообщение в этот чат менее 1 секунды назад
if ($redis->get($lockKey)) {
// Откладываем выполнение на 1 секунду
$queue->delay(1)->push($this);
return;
}
/** @var TelegramTransport $transport */
$transport = Yii::$app->get('telegramTransport');
$payload = [
'chat_id' => $this->chatId,
'text' => $this->text,
'parse_mode' => 'HTML',
];
if ($this->replyMarkup !== null) {
$payload['reply_markup'] = $this->replyMarkup;
}
$result = $transport->execute('sendMessage', $payload);
if (isset($result['error_code']) && $result['error_code'] === 429) {
$delay = (int) ($result['retry_after'] ?? 3);
// Возвращаем задачу в очередь с задержкой Retry-After + 1 секунда
$queue->delay($delay + 1)->push($this);
return;
}
if (($result['ok'] ?? false) === true) {
// Ставим блокировку на 1 секунду для защиты от частых вызовов
$redis->setex($lockKey, 1, '1');
}
}
}Grouping Messages into a Single Chat (Message Aggregation)
If your CRM or analytics system generates dozens of events per minute (for example, статус заказа changed, payment received, courier assigned), sending a separate message for every little thing is guaranteed to cause a 429 error. The solution is to accumulate notifications in a buffer (Redis) and send them as a single block.
<?php
namespace app\components;
use Yii;
use app\jobs\SendTelegramMessageJob;
class TelegramBufferManager
{
public static function pushNotification(int $chatId, string $messageLine): void
{
$redis = Yii::$app->redis;
$key = "tg_buffer:{$chatId}";
$redis->rpush($key, $messageLine);
// Задаем время жизни буфера 60 секунд
$redis->expire($key, 60);
}
public static function flush(int $chatId): void
{
$redis = Yii::$app->redis;
$key = "tg_buffer:{$chatId}";
$items = [];
while ($item = $redis->lpop($key)) {
$items[] = $item;
}
if (empty($items)) {
return;
}
// Объединяем сообщения через разделитель
$combinedText = implode("
---
", $items);
// Отправляем склеенный текст в очередь
Yii::$app->queue->push(new SendTelegramMessageJob([
'chatId' => $chatId,
'text' => $combinedText,
]));
}
}What Breaks During Mass Broadcasts and How to Avoid It
During promo broadcasts to a user base, you will inevitably encounter the following issues:
- Webhook handler freeze: If a user writes to the bot during a broadcast, and the backend attempts to respond immediately in synchronous mode, webhook processing times out (typically 5–10 seconds in Telegram). Telegram starts repeating Webhook requests, creating a "storm" effect. Solution: always return
HTTP 200 OKwithin 50 milliseconds, passing the receivedupdate_idto фоновый воркер. - Bot deletion or user block: During a broadcast, the API returns
403 Forbidden: bot was blocked by the user. Do not try to re-send to such users. Mark them in the database with anis_active = falseflag so as not to waste API limits. - Exceeding text length: When combining messages or generating dynamic texts, you may exceed 4096 characters. The text must be split into chunks before being placed into the queue.
To sum up: Never send API requests directly from controllers. A task queue with throttling, checking retry_after, and tracking user activity statuses is the only way to ensure stable project operation at scale with millions of users.If you need professional development of high-load bots or Mini Apps, contact the specialists at botservice.biz.