11 KiB
name, description
| name | description |
|---|---|
| tdlib-telegram | Reference guide for building Telegram integrations with TDLib (tdl/node). Covers rate limits, FLOOD_WAIT handling, file size constraints, bot vs user account differences, and safe code patterns. Use this skill whenever writing or modifying code that calls Telegram APIs via TDLib, the Bot API, or any Telegram client library — including sending messages, downloading/uploading files, scanning channels, forwarding messages, managing subscriptions, or handling notifications. Also use when debugging 429 errors, FLOOD_WAIT, or silent message drops. |
TDLib / Telegram Development Guide
This skill provides the rate limits, constraints, and patterns you need to write correct Telegram integrations. The limits below come from official Telegram documentation and well-established community findings (Telegram does not publish exact numbers for all limits).
Telegram Rate Limits
These are approximate safe boundaries. Telegram's actual limits are dynamic and depend on account age, history, and request type. The correct strategy is to respect these as guidelines and always handle FLOOD_WAIT errors gracefully.
Bot Accounts
| Operation | Limit | Notes |
|---|---|---|
| Messages to same chat | ~1 msg/sec | Bursts OK, sustained exceeds limit |
| Messages in a group | 20 msgs/min | Hard limit per group chat |
| Bulk notifications (different users) | ~30 msgs/sec | Global across all chats |
| Message edits in a group | ~20 edits/min | Community-observed |
| API requests (global) | ~30 req/sec | All request types combined |
| Paid broadcasts | up to 1000 msgs/sec | Requires Telegram Stars balance |
User Accounts (TDLib)
| Operation | Limit | Notes |
|---|---|---|
| API requests (global) | ~30 req/sec | All request types combined |
| Messages in a group | ~20 msgs/min | Same as bot |
| Channel history reads | No published limit | But pagination + delay is essential |
| Joining groups | Very strict | FLOOD_WAIT often 30-300+ seconds |
File Size Limits
| Context | Upload | Download |
|---|---|---|
| Bot API (standard) | 50 MB | 20 MB |
| Bot API (local server) | 2,000 MB | 2,000 MB |
| User account (TDLib) | 2 GB | 2 GB |
| Premium user (TDLib) | 4 GB | 4 GB |
Message & Content Limits
| Item | Limit |
|---|---|
| Message text length | 4,096 chars |
| Media caption | 1,024 chars (4,096 premium) |
| Album / media group | 10 items max |
| Forwarded messages per request | forwarded_message_count_max (TDLib option) |
| Inline keyboard buttons | 100 entities |
| Formatting entities per message | 100 |
| Scheduled messages per chat | 100 |
| Bot commands | 100 max |
Forum & Group Limits
| Item | Limit |
|---|---|
| Topics per group | 1,000,000 |
| Topic title | 128 chars |
| Group members | 200,000 |
| Admins per group | 50 |
| Bots per group | 20 |
| Pinned topics | 5 |
FLOOD_WAIT — How It Works
When you exceed rate limits, Telegram returns a FLOOD_WAIT_X error (or HTTP 429 with
retry_after). This is a mandatory pause — the value X is the number of seconds you
must wait before ANY request will succeed. It blocks the entire client, not just the
operation that triggered it.
The Right Way to Handle It
// Extract the wait duration from the error
function extractFloodWaitSeconds(err: unknown): number | null {
const message = err instanceof Error ? err.message : String(err);
// Pattern 1: FLOOD_WAIT_30
const flood = message.match(/FLOOD_WAIT_(\d+)/i);
if (flood) return parseInt(flood[1], 10);
// Pattern 2: "retry after 30"
const retry = message.match(/retry after (\d+)/i);
if (retry) return parseInt(retry[1], 10);
// Pattern 3: HTTP 429 without explicit seconds
if (String((err as any)?.code) === "429") return 30;
return null; // Not a rate limit error
}
// Wrap any TDLib call with automatic retry
async function withFloodWait<T>(fn: () => Promise<T>, maxRetries = 5): Promise<T> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (err) {
const wait = extractFloodWaitSeconds(err);
if (wait === null || attempt >= maxRetries) throw err;
// Add 1-5s jitter to prevent thundering herd
const jitter = 1000 + Math.random() * 4000;
await sleep(wait * 1000 + jitter);
}
}
throw new Error("Unreachable");
}
Key Rules
- Always respect the wait duration. Never retry before
retry_afterexpires. - Add jitter. Without it, multiple clients retry simultaneously and trigger another FLOOD_WAIT.
- Non-rate-limit errors should fail fast. Only retry on FLOOD_WAIT, not on other errors.
- Don't artificially throttle below ~1 req/sec. Telegram's own guidance (via grammY docs) is to send requests as fast as you need and handle 429 errors. Fixed low-frequency throttling wastes throughput without preventing floods.
Code Patterns
Pattern: Sequential Send Queue
When sending notifications to multiple users, use a sequential queue with a per-message delay. Never fire concurrent sends — you will hit the 30 msg/sec global limit instantly.
let sendQueue: Promise<void> = Promise.resolve();
function queueSend(chatId: bigint, text: string): void {
sendQueue = sendQueue
.then(() => withFloodWait(() => sendTextMessage(chatId, text)))
.then(() => sleep(50)) // ~20 msgs/sec, well under 30 limit
.catch((err) => log.error({ err, chatId }, "Send failed"));
}
Pattern: Paginated Scanning with Delay
When reading channel history or enumerating topics, always add a delay between pages:
while (hasMorePages) {
const result = await invokeWithTimeout(client, { _: "getChatHistory", ... });
processMessages(result.messages);
if (result.messages.length < limit) break;
await sleep(1000); // 1 second between pages — prevents FLOOD_WAIT on large channels
}
Pattern: Event Listener Before Action
When waiting for TDLib async events (upload confirmation, download completion), always attach the event listener BEFORE starting the operation. If you attach after, fast operations can complete before the listener exists, causing the promise to hang forever.
// CORRECT: listener first, then action
client.on("update", handleUpdate);
const tempMsg = await client.invoke({ _: "sendMessage", ... });
tempMsgId = tempMsg.id; // handler now knows which message to match
// WRONG: action first, then listener — race condition!
const tempMsg = await client.invoke({ _: "sendMessage", ... });
client.on("update", handleUpdate); // may miss updateMessageSendSucceeded
Pattern: Download/Upload Timeouts
Scale timeouts with file size. TDLib downloads/uploads are asynchronous — without a timeout, a stalled transfer hangs the entire pipeline.
const timeoutMs = Math.max(
10 * 60_000, // minimum 10 minutes
(fileSizeMB / 1024) * 10 * 60_000 // 10 minutes per GB
);
Pattern: TDLib Client Lifecycle
Always close TDLib clients in a finally block. Unclosed clients leak memory and file
descriptors, and can leave TDLib's internal database locked.
const client = await createTdlibClient(account);
try {
// ... use client ...
} finally {
await closeTdlibClient(client);
}
Anti-Patterns
Never: Concurrent TDLib Sends Without Queue
// BAD: fires all sends concurrently — will trigger FLOOD_WAIT immediately
await Promise.all(users.map((u) => sendTextMessage(u.chatId, msg)));
// GOOD: sequential with delay
for (const user of users) {
await withFloodWait(() => sendTextMessage(user.chatId, msg));
await sleep(50);
}
Never: Bare client.invoke() Without Retry
Every client.invoke() call can return FLOOD_WAIT at any time. Bare calls will crash
on rate limits instead of retrying.
// BAD: crashes on FLOOD_WAIT
await client.invoke({ _: "sendMessage", ... });
// GOOD: retries automatically
await withFloodWait(() => client.invoke({ _: "sendMessage", ... }));
Never: Retry Without Respecting retry_after
// BAD: fixed 1-second retry ignores Telegram's wait requirement
catch (err) { await sleep(1000); retry(); }
// GOOD: extract and respect the actual wait time
catch (err) {
const wait = extractFloodWaitSeconds(err);
if (wait !== null) await sleep(wait * 1000 + jitter);
else throw err;
}
Never: Ignore FLOOD_WAIT in Bots
Bot accounts get the same FLOOD_WAIT as user accounts. The bot API's 429 response blocks ALL operations for the specified duration — not just the chat that triggered it. A single unhandled flood in a notification loop can make the entire bot unresponsive.
Bot vs User Account Differences
| Capability | Bot | User (TDLib) |
|---|---|---|
| Read channel history | No (unless admin) | Yes |
| Send to users who haven't started bot | No | N/A |
| Join groups via invite link | No (must be added) | Yes |
| Forward messages (send_copy) | Yes | Yes |
| File upload limit | 50 MB (standard API) | 2 GB |
| File download limit | 20 MB (standard API) | 2 GB |
| Auth method | Bot token | Phone + SMS code |
| Rate limit profile | Same FLOOD_WAIT | Same FLOOD_WAIT |
TDLib-Specific Notes
BigInt Chat IDs
TDLib uses numeric chat IDs. Supergroups and channels use negative IDs (e.g., -1001234567890).
When passing to client.invoke(), convert with Number(chatId) — TDLib's JSON interface
doesn't handle BigInt. Be aware that very large IDs may lose precision with Number(),
though current Telegram IDs are within safe integer range.
TDLib Options (Runtime Queryable)
These are read-only values you can query at runtime via getOption:
message_text_length_max— max message text lengthmessage_caption_length_max— max caption lengthforwarded_message_count_max— max forwards per request
Session State
TDLib persists session state to disk. Each account needs its own state directory. Running two clients on the same state directory simultaneously will corrupt the database. Use separate directories per account, and separate volumes in Docker for worker vs bot.
Docker Considerations
- prebuilt-tdlib: The
prebuilt-tdlibnpm package provides platform-specific TDLib binaries. Container base image must match (e.g.,node:20-bookworm-slimfor Debian x64). - Volumes: Mount persistent volumes for TDLib state directories — losing state forces full re-authentication.
- Graceful shutdown: Wait for active operations to finish before closing DB connections. TDLib operations in flight will fail if the database pool is closed underneath them.
- Health checks: TDLib services don't expose HTTP — use database connectivity as the health signal instead.