Production
Things you'll wish you'd known before the first FloodWait at 3 AM. Proxies, multi-account fan-out, where teleproto runs and where it doesn't, FloodWait strategy, debugging, and the ban risks nobody warns you about until your account is already limited.
Proxies#
Pass proxy in TelegramClientParams. teleproto ships two transports out of the box: SOCKS5 (via the socks dep) and MTProxy. No HTTP-proxy support — if you only have an HTTP forward proxy, run a local SOCKS bridge.
import { TelegramClient } from "teleproto";
import { StringSession } from "teleproto/sessions";
const client = new TelegramClient(
new StringSession(process.env.TG_SESSION ?? ""),
Number(process.env.TG_API_ID),
process.env.TG_API_HASH!,
{
connectionRetries: 5,
proxy: {
ip: "127.0.0.1",
port: 1080,
socksType: 5,
username: process.env.PROXY_USER,
password: process.env.PROXY_PASS,
timeout: 10,
},
},
);MTProxy uses Telegram's own obfuscated transport. Same proxy field, different shape:
const client = new TelegramClient(session, apiId, apiHash, {
connectionRetries: 5,
// useIPV6: true, // uncomment if your network is v6-only
proxy: {
ip: "proxy.example.com",
port: 443,
MTProxy: true,
secret: "dd000102030405060708090a0b0c0d0e0f",
},
});If your network is IPv6-only, also set useIPV6: trueon the client params — teleproto won't auto-detect it.
Multi-account#
teleproto has no "session pool" abstraction and no built-in worker. Each account is one TelegramClient with its own session. Keep them in a Map, log in on demand, fan out with Promise.all.
import { TelegramClient } from "teleproto";
import { StoreSession } from "teleproto/sessions";
const apiId = Number(process.env.TG_API_ID);
const apiHash = process.env.TG_API_HASH!;
const clients = new Map<string, TelegramClient>();
async function getClient(name: string) {
const existing = clients.get(name);
if (existing) return existing;
const client = new TelegramClient(
new StoreSession(name), // persists to ./<name>/
apiId,
apiHash,
{ connectionRetries: 5 },
);
await client.connect();
if (!(await client.isUserAuthorized())) {
throw new Error(`session "${name}" not logged in — run interactive login first`);
}
clients.set(name, client);
return client;
}
// fan out a request across every account
const me = await Promise.all(
["account-1", "account-2", "account-3"].map(async (name) => {
const c = await getClient(name);
return c.getMe();
}),
);Give every account a distinct StoreSession name — new StoreSession("account-1"), new StoreSession("account-2"). The name becomes a folder on disk; reusing one name across processes corrupts the auth key. (Also: "session" is reserved and will throw — pick anything else.)
Each connection opens its own TCP socket to the assigned DC, so ten accounts means ten persistent sockets. Budget memory and file descriptors accordingly.
Deployment#
Node 18+ and a single long-running process. That's the model. MTProto is stateful — sequence numbers, salt, auth key — so anything that kills the process mid-flight pays a re-handshake tax on the next start.
For StoreSession, mount a persistent disk. The session name maps to a directory (./account-1/) of small JSON shards written via node-localstorage. Lose the directory, lose the login. For serverless or 12-factor deploys where there's no disk, use StringSession, save the string after first login, and inject it as an env var on every boot.
# Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
# StoreSession writes here — mount a volume so it survives restarts
VOLUME ["/app/sessions"]
CMD ["node", "dist/index.js"]Under PM2 or systemd, cap restarts (--max-restarts, StartLimitBurst). A crash loop replays the MTProto handshake on every cycle and Telegram will start returning FloodWait on auth fast.
Avoid Lambda, Cloudflare Workers, Vercel edge functions, and anything that cycles the process per request. teleproto needs the socket to stay open; a cold start means a fresh handshake means a rate limit. If you must run on FaaS, put teleproto in a sidecar container or a dedicated VM and talk to it via your own RPC.
Reconnection#
teleproto reconnects on its own. The defaults are sensible; you mostly just choose how aggressive.
const client = new TelegramClient(session, apiId, apiHash, {
autoReconnect: true, // default
reconnectRetries: 5,
retryDelay: 1000, // ms between attempts
connectionRetries: 5,
});Transient drops, DC migrations, and socket resets are handled internally. What you do have to handle yourself: FrozenError / FrozenMethodError / FrozenParticipantError (Telegram limited the account), and the migration error family (UserMigrateError, PhoneMigrateError) the first time a session crosses DCs. See Errors & FloodWait for the full taxonomy.
For explicit kicks — another client logging in and revoking your session — subscribe to a catch-all Raw handler and watch for UpdateLoginToken or UpdatesTooLong:
Logging & debugging#
Log level is a single global setter: setLogLevel("none" | "error" | "warn" | "info" | "debug"). Default is warn. Bump to debug while reproducing, then back down — debug is loud.
For one-off poking, subscribe a Raw handler and dump every update type that comes through:
import { Raw } from "teleproto/events";
client.setLogLevel("debug");
client.addEventHandler((update) => {
console.log("[raw]", update.className, update);
}, new Raw({}));For failing requests, wrap the call in try/catch. Every error in teleproto/errors extends RPCError and carries .message and .code, plus request-specific fields (.seconds on FloodWaitError, .newDcon the migrate family). Don't parse error strings — match on class.
Rate limits & FloodWait#
Telegram answers "too fast" with FLOOD_WAIT_X, which teleproto surfaces as FloodWaitError with .seconds. Set floodSleepThreshold (in seconds) and the client will silently sleep on any wait at or below that bound — handy for the second-or-two ones that show up under bursty load.
import { FloodWaitError } from "teleproto/errors";
const client = new TelegramClient(session, apiId, apiHash, {
connectionRetries: 5,
floodSleepThreshold: 60, // auto-sleep waits <= 60s, throw on longer
});
try {
await client.sendMessage(chat, { message: "ping" });
} catch (err) {
if (err instanceof FloodWaitError) {
// hand off to your scheduler — don't tight-loop
await queue.deferUntil(Date.now() + err.seconds * 1000);
return;
}
throw err;
}For longer waits, catch the error and defer to your queue or scheduler. Do not write a while (true) retry loop. Telegram tracks repeated offenders and the next wait will be longer, not shorter.
Account safety#
Userbots violate the strict reading of Telegram's ToS. Telegram knows the patterns and limits them aggressively. The teleproto-specific FrozenError / FrozenMethodError / FrozenParticipantError family exists precisely because of this enforcement — you'll meet them eventually if you push.
Patterns that get accounts limited or frozen, fast:
- Spamming reactions across many chats.
- Mass-joining channels or groups.
- Scraping participant lists across many large chats.
- Sending identical messages to many DMs.
- Any "automation that looks like a bot but isn't one."
Use a phone number you don't depend on for 2FA elsewhere. If the account is your daily driver and it gets restricted, you lose more than the bot.
Catching up after restart#
By default, after connect() you only see updates that arrive while the socket is open. Anything that happened during downtime is gone. Call catchUp() right after connecting to ask Telegram for the replay.
await client.connect();
await client.catchUp(); // replay updates that arrived while we were offlineThe replay window depends on your session's last-seen pts/qts state, which teleproto persists in the session. If the session is fresh or too far behind, Telegram returns UpdatesTooLong and you have to refetch the relevant dialogs by hand with iterMessages.
Shutting down#
Always close the socket cleanly. disconnect() flushes pending writes and lets the server retire the connection; ripping the process down hard leaves orphaned state Telegram has to time out.
async function shutdown() {
try {
await client.disconnect();
} finally {
process.exit(0);
}
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);Use destroy() instead of disconnect() only when you also want to tear down the auth key — i.e. log the session out for good. That's rare in production; you want disconnect() on shutdown so the next boot reuses the same auth key and skips the handshake.