Imagine launching a flash sale for your Telegram-based e-commerce store. You have 5,000 active users who opted in for notifications. To announce the sale, you write a quick PHP script that loops through your database and sends a promotional message to everyone. Within the first three seconds, the script grinds to a halt. Half your users receive the message twice, others don't receive it at all, and your server logs are flooded with HTTP 429 Too Many Requests errors.
This is the reality of ignoring the Telegram Bot API rate limits. Telegram enforces strict limits on how fast your bot can send messages. If you exceed these limits, Telegram throttles your bot, returns a 429 status code, and expects you to back off. If you continue to flood their servers, your bot's API token can be temporarily or permanently restricted.
In this tutorial, we will build a production-ready, rate-aware Telegram message queue in PHP. We will explore why naive loops fail, how to parse and respect Telegram's retry_after parameter, and how to design a database-backed worker that guarantees message delivery without triggering rate limits.
The Naive Loop and Why It Fails
When developers first build a broadcast feature, they often write a simple synchronous loop. They fetch all user IDs from the database and send requests one after another using a standard HTTP client.
Here is an example of this dangerous pattern:
<?php
// naive-broadcast.php
// DO NOT USE THIS IN PRODUCTION
$botToken = getenv('TELEGRAM_BOT_TOKEN');
$users = $db->query("SELECT telegram_id FROM users WHERE notifications_enabled = 1")->fetchAll();
$message = "Our flash sale is live! Use code FLASH20.";
foreach ($users as $user) {
$url = "https://api.telegram.org/bot{$botToken}/sendMessage";
$payload = [
'chat_id' => $user['telegram_id'],
'text' => $message
];
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
$response = curl_exec($ch);
curl_close($ch);
// No rate limit checking, no error handling
}
This script will fail almost immediately due to Telegram's strict rate limits:
- Global Limit: A bot can send a maximum of 30 messages per second across all chats combined.
- Single Chat Limit: A bot can send only 1 message per second to a specific user or private chat.
- Group/Channel Limit: A bot can send a maximum of 20 messages per minute to a specific group or channel.
When you run the naive loop above, your server sends hundreds of requests per second. Telegram will block your requests and return a JSON response that looks like this:
{
"ok": false,
"error_code": 429,
"description": "Too Many Requests: retry after 9",
"parameters": {
"retry_after": 9
}
}
If your script does not read the retry_after value and pause execution, you will continue to hit the API, compounding the block duration. Furthermore, because PHP scripts have execution time limits (e.g., max_execution_time in php.ini), a synchronous loop that gets throttled will eventually time out, leaving your broadcast half-finished with no record of who received the message and who did not.
Building a Rate-Aware HTTP Client
To handle rate limits correctly, we must build an HTTP client wrapper that inspects every response from Telegram. If it encounters an HTTP status code 429, it must parse the retry_after parameter, pause execution for that exact number of seconds, and then retry the request.
Here is a robust PHP class that wraps cURL to handle Telegram rate limits dynamically:
<?php
// TelegramClient.php
class TelegramClient
{
private string $botToken;
private int $maxRetries = 3;
public function __construct(string $botToken)
{
$this->botToken = $botToken;
}
public function sendRequest(string $method, array $payload): array
{
$url = "https://api.telegram.org/bot{$this->botToken}/{$method}";
$attempt = 0;
while ($attempt < $this->maxRetries) {
$attempt++;
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
$responseBody = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_errno($ch);
curl_close($ch);
if ($curlError !== 0) {
// Handle network failures with a short backoff
sleep(2);
continue;
}
$data = json_decode($responseBody, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new RuntimeException("Invalid JSON response from Telegram API");
}
if ($httpCode === 429) {
$retryAfter = $data['parameters']['retry_after'] ?? 1;
// Safety margin of 1 second
sleep($retryAfter + 1);
// Retry the loop
continue;
}
if ($httpCode !== 200 || ($data['ok'] ?? false) === false) {
throw new RuntimeException(
"Telegram API error: " . ($data['description'] ?? 'Unknown error') . " (Code: {$httpCode})"
);
}
return $data;
}
throw new RuntimeException("Failed to execute Telegram API request after {$this->maxRetries} attempts due to rate limits or network issues.");
}
}
This client ensures that if Telegram tells us to wait, we wait. However, relying solely on this client inside a synchronous loop is still not enough for large-scale broadcasts. If you have 10,000 users and hit a 9-second block, your entire PHP process sleeps, which can cause web requests to time out. We must decouple message generation from message delivery using a queue.
Designing a Database-Backed Queue and Worker
To safely broadcast messages, we need a queue system. When you want to send a broadcast, you insert the messages into a database table with a pending status. A background worker script (running via CLI or a cron job) then processes these messages at a controlled rate, respecting both the global 30 messages/second limit and the 1 message/second per-user limit.
First, let's define the database schema for our queue. This schema tracks the target chat, the message payload, the status, and when the last message was sent to each specific chat to prevent violating the 1 message/second per-user rule.
CREATE TABLE telegram_message_queue (
id INT AUTO_INCREMENT PRIMARY KEY,
chat_id BIGINT NOT NULL,
payload JSON NOT NULL,
status VARCHAR(20) DEFAULT 'pending', -- pending, processing, sent, failed
retry_after_timestamp INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
processed_at TIMESTAMP NULL,
INDEX idx_status_chat (status, chat_id)
);
CREATE TABLE telegram_chat_throttle (
chat_id BIGINT PRIMARY KEY,
last_sent_at DECIMAL(14, 4) NOT NULL -- Unix timestamp with microsecond precision
);
Now, we will write a CLI worker script. This script runs continuously, fetching pending messages and sending them. It uses the telegram_chat_throttle table to ensure we never send more than one message per second to the same user. It also tracks the global rate to ensure we do not exceed 30 requests per second.
<?php
// queue-worker.php
require_once 'TelegramClient.php';
$db = new PDO('mysql:host=127.0.0.1;dbname=bot_db', 'db_user', 'db_password', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC
]);
$botToken = getenv('TELEGRAM_BOT_TOKEN');
$client = new TelegramClient($botToken);
$globalLimit = 30; // Max messages per second globally
$globalInterval = 1.0 / $globalLimit; // Time to wait between global requests (seconds)
while (true) {
$startTime = microtime(true);
// Fetch a batch of pending messages
$stmt = $db->prepare("
SELECT q.*
FROM telegram_message_queue q
WHERE q.status = 'pending'
AND q.retry_after_timestamp <= :now
LIMIT 50
");
$stmt->execute(['now' => time()]);
$batch = $stmt->fetchAll();
if (empty($batch)) {
// No messages to process, sleep for a bit and check again
usleep(500000); // 0.5 seconds
continue;
}
foreach ($batch as $message) {
$chatId = $message['chat_id'];
$payload = json_decode($message['payload'], true);
$now = microtime(true);
// Check the last time we sent a message to this specific chat
$throttleStmt = $db->prepare("SELECT last_sent_at FROM telegram_chat_throttle WHERE chat_id = :chat_id");
$throttleStmt->execute(['chat_id' => $chatId]);
$lastSent = $throttleStmt->fetchColumn();
if ($lastSent !== false && ($now - $lastSent) < 1.0) {
// We sent a message to this user less than 1 second ago. Skip for now.
continue;
}
// Mark as processing to prevent duplicate delivery by concurrent workers
$updateStmt = $db->prepare("UPDATE telegram_message_queue SET status = 'processing' WHERE id = :id AND status = 'pending'");
$updateStmt->execute(['id' => $message['id']]);
if ($updateStmt->rowCount() === 0) {
// Another worker picked it up
continue;
}
try {
$client->sendRequest('sendMessage', $payload);
// Update throttle timestamp
$db->prepare("
INSERT INTO telegram_chat_throttle (chat_id, last_sent_at)
VALUES (:chat_id, :now)
ON DUPLICATE KEY UPDATE last_sent_at = :now
")->execute([
'chat_id' => $chatId,
'now' => microtime(true)
]);
// Mark message as sent
$db->prepare("UPDATE telegram_message_queue SET status = 'sent', processed_at = NOW() WHERE id = :id")
->execute(['id' => $message['id']]);
} catch (RuntimeException $e) {
// If we hit a rate limit, the client handled the sleep, but we might need to retry later
// Reset status to pending and set a backoff timestamp
$db->prepare("
UPDATE telegram_message_queue
SET status = 'pending', retry_after_timestamp = :retry_at
WHERE id = :id
")->execute([
'id' => $message['id'],
'retry_at' => time() + 10 // Backoff for 10 seconds
]);
}
// Enforce global rate limit (30 requests per second max)
$elapsed = microtime(true) - $now;
if ($elapsed < $globalInterval) {
usleep(($globalInterval - $elapsed) * 1000000);
}
}
}
This worker architecture solves several critical issues:
-
Concurrency Safety: By using a
status = 'processing'transition check (rowCount() === 0), you can safely run multiple instances of this worker script in parallel without sending duplicate messages to users. -
Per-User Throttling: The
telegram_chat_throttletable prevents the worker from sending messages to the same user faster than once per second, even if there are multiple pending messages for that user in the queue. -
Global Rate Control: The
$globalIntervalcalculation ensures that the loop pauses slightly between requests, keeping the total request rate safely below the 30 requests/second threshold.
Production Notes
When running a Telegram bot in production, rate limits are not the only hazard. You must also handle webhook timeouts, message formatting, and callback queries correctly to maintain a stable system.
Webhook Execution Limits
When Telegram sends an update to your webhook URL, it expects your server to respond with an HTTP 200 OK status code within a few seconds. If your webhook script takes too long to respond (for example, because it is processing a database query or trying to send multiple API requests synchronously), Telegram will assume your server is down. It will terminate the connection and retry sending the same update repeatedly.
This creates a destructive loop: your server gets overloaded processing duplicate updates, which causes more timeouts.
Rule: Never process broadcasts or heavy operations inside the webhook execution thread. When a webhook receives a command to start a broadcast, it should insert a record into the database queue and immediately return an HTTP 200 OK to Telegram. Let your background CLI workers handle the actual API communication.
Idempotency and Duplicate Prevention
To prevent processing the same webhook update twice, you must track the update_id sent by Telegram. Store the processed update_id values in a database table with a unique constraint, or in Redis with an expiration time of 24 hours. If an incoming webhook contains an update_id that already exists in your store, discard it immediately.
Safe HTML Formatting
If you use parse_mode => 'HTML' in your payloads, any unescaped special characters (like <, >, or &) will cause Telegram to reject the message with an HTTP 400 Bad Request error. This will break your queue worker if it encounters a malformed message. Always wrap dynamic user input in htmlspecialchars() before inserting it into your message payloads:
$safeText = htmlspecialchars($userInput, ENT_QUOTES, 'UTF-8');
Handling Callback Queries
When a user clicks an inline keyboard button, Telegram sends a callback_query update to your webhook. The user's Telegram client will display a loading spinner on the button until your bot acknowledges the click. You must call the answerCallbackQuery method immediately after receiving a callback query, even if you do not need to display an alert. This stops the loading spinner and provides a smooth user experience.
To learn more about optimizing your bot's architecture and handling high-volume traffic, you can read about the Telegram Bot API specifications at https://botservice.biz/telegram-bot-api.
BotCreator — studio that ships Telegram bots / Mini Apps.
Top comments (0)