Telegram is a cloud messenger: by default your messages live on Telegram's servers and every device you log in on sees the same history. That one product decision drives most of its architecture. It needs a client-server protocol fast enough for mobile networks, a way to place users and files across data centres, a sync model that lets a device that was offline for a week catch up exactly, and a delivery path for media watched by millions. End-to-end encryption is offered separately, in secret chats, with deliberately different properties.

A caveat up front: Telegram's server code is closed source. What is public is the MTProto protocol, the client API and open-source clients, so this article describes the system from that boundary. Anything about server internals is labelled as inference. The specifics below come from the protocol documentation at core.telegram.org; check it for changes before building on them.

Advertisement

The shape of the system

A client holds a list of data centres (DCs), obtained from help.getConfig, and talks to them over a transport such as TCP or WebSocket. Each account has a home DC that stores its data. Files are stored in a particular DC too, so a client often holds connections to several DCs at once. Some DCs are flagged media_only for file transfer, and a separate tier of CDN DCs caches popular public media.

Telegram as the public protocol describes it: clients, home DCs, media and CDN pathsPhone clientauth key per DCDesktop clientown auth keysBot / TDLibsame APITransportTCP / WebSocket / HTTPobfuscated framingHome DCaccount, chats, updatesOther DCfiles, other usersMedia-only DCfile transferRPC + updatesCDN DCuntrusted, encryptedAES-256-CTR*_MIGRATE_X errors redirect a client to the right DCauth.exportAuthorization / importAuthorization moves login, not keysSecret chats: E2E between two devices, relayed through the DCs as opaque payloads
Clients connect to their home DC for account data and updates, to other DCs and media-only DCs for files, and to untrusted CDN DCs for popular public media, which arrives encrypted.

MTProto in three layers

MTProto separates concerns into three layers, and keeping them apart is what lets the same API run over very different networks.

  • Transport. Framing over TCP, WebSocket or HTTP. A TCP client announces its framing with an initial marker: 0xef for the abridged format, 0xeeeeeeee for intermediate, 0xdddddddd for padded intermediate, and no marker for the full format. Obfuscation hides even that: the client sends a random 64-byte initialisation payload, with the protocol identifier at offset 56, from which both sides derive AES-256-CTR keys for the rest of the stream, so the connection carries no fixed bytes a filter could match.
  • Cryptographic layer. Encrypts each message with keys derived from a long-lived authorization key, described in the next section.
  • API layer. Remote procedure calls and updates, serialised in TL, Telegram's type language. The schema defines every method and constructor; clients are generated from it.
Advertisement

Authorization keys and message encryption

Before anything else a client runs a Diffie-Hellman exchange with a DC to create a 2048-bit authorization key. The key is identified on the wire by auth_key_id, the 64 lower-order bits of SHA-1 of the key. Keys are per DC: the documentation states that encryption keys are not copied between DCs, so a client creates a fresh one for each DC it uses.

Inside the encrypted envelope each message carries a 64-bit server salt, a 64-bit session id, a 64-bit message id, a 32-bit sequence number, a length, the payload, and 12 to 1024 bytes of random padding. The message id must approximately equal unix time multiplied by 2 to the power 32 and must increase monotonically, which makes it both an ordering key and a replay check. The salt, rotated by the server, guards against replays and against clients that set their clock far into the future. The sequence number counts content-related messages, as opposed to service messages such as acknowledgements.

The MTProto 2.0 key derivation, quoted from the specification, with x = 0 for client-to-server messages and x = 8 for server-to-client:

msg_key_large = SHA256(substr(auth_key, 88 + x, 32) + plaintext + random_padding)
msg_key       = substr(msg_key_large, 8, 16)            # middle 128 bits

sha256_a = SHA256(msg_key + substr(auth_key, x, 36))
sha256_b = SHA256(substr(auth_key, 40 + x, 36) + msg_key)
aes_key  = substr(sha256_a, 0, 8) + substr(sha256_b, 8, 16) + substr(sha256_a, 24, 8)
aes_iv   = substr(sha256_b, 0, 8) + substr(sha256_a, 8, 16) + substr(sha256_b, 24, 8)

wire = auth_key_id + msg_key + AES256_IGE_encrypt(plaintext + random_padding, aes_key, aes_iv)

The receiver recomputes msg_key after decrypting and drops the message if it differs, so the message key doubles as an integrity check. The x offset makes the two directions use different key material. Do not implement this yourself for production; use TDLib or a maintained client library, and treat this description as a way to read their logs.

Cloud chats and secret chats

Ordinary chats, groups and channels are encrypted between client and server; the server can read them. That is what makes multi-device history, server-side search and groups of very large size straightforward. Secret chats are end-to-end encrypted between exactly two devices. One side calls messages.requestEncryption with its Diffie-Hellman value, the other answers with messages.acceptEncryption, and both derive a key the server never sees. The messages use MTProto 2.0 with the roles setting x.

Secret chats are bound to devices: once accepted on one device, the chat exists only there, not on the user's other devices. Official clients re-key after a key has encrypted and decrypted more than 100 messages or has been in use for more than a week. Secret chat events are sequenced by qts. The trade-off is the classic one between convenience and confidentiality; compare it with the ratchet design in the Signal protocol, where end-to-end encryption is the default and multi-device support is engineered on top of it.

Data centres and account placement

A user is tied to a home DC chosen from their phone number at registration and from IP geolocation. The API tells a client to move with errors rather than redirects:

ErrorMeaningClient action
PHONE_MIGRATE_XThis phone number belongs to DC XReconnect to DC X and repeat the login call
NETWORK_MIGRATE_XA new user's IP suggests DC X is closerReconnect to DC X
USER_MIGRATE_XThe account's data has moved to DC XReconnect to DC X
FILE_MIGRATE_XThe requested file is stored in DC XDownload from DC X instead

To use another DC without asking the user to log in again, the client calls auth.exportAuthorization on its current DC and auth.importAuthorization on the target, after creating a new authorization key there. The login moves; the key does not. As a sharding scheme this is placement by owner: each account and its data have one home, and cross-DC work happens at the edges. It is the same pattern discussed in sharding, with the client, not a proxy, doing the routing.

Sync: pts, qts, seq and gap recovery

The heart of Telegram's multi-device experience is its update model. Each event that changes a message box increments a counter called pts. Private chats and basic groups share one pts sequence per account; every channel and supergroup has its own. Secret-chat and some bot events use qts, and seq orders the update containers themselves. Each update carries the new value and the number of events it covers, pts_count, and the client applies a simple rule.

def on_update(local, upd):
    expected = local.pts + upd.pts_count
    if expected == upd.pts:            # contiguous: apply and advance
        apply(upd)
        local.pts = upd.pts
    elif expected > upd.pts:           # already applied: duplicate, ignore
        return
    else:                              # gap: something was missed
        buffer(upd)
        schedule(0.5, lambda: fill_gap_if_still_open(local))

def fill_gap_if_still_open(local):
    if gap_still_open(local):
        diff = call("updates.getDifference", pts=local.pts, date=local.date, qts=local.qts)
        while True:
            apply_all(diff.new_messages, diff.other_updates)
            local.set_state(diff.state)
            if diff.kind != "differenceSlice":
                break
            diff = call("updates.getDifference", **local.state_args())

The documentation recommends waiting up to half a second before fetching, because missing updates are often just reordered. Channel gaps are filled per channel with updates.getChannelDifference, paginated until the server marks the result final. When too many events are pending, the server sends updatesTooLong or updateChannelTooLong instead of the events, and the client fetches the difference itself.

Worked example: a phone has pts 1000 and receives an update with pts 1003 and pts_count 1. Since 1000 plus 1 is less than 1003, two events are missing. The client buffers the update, waits half a second, still sees the gap, and calls getDifference from 1000. The server returns the two missing messages and the new state, the client applies them followed by the buffered one, and history is exact again. Per-channel counters mean a client returning after a long absence catches up only on the channels it opens, rather than downloading one enormous merged log first. How the server stores these sequences internally is not documented.

Files, media DCs and the encrypted CDN

Uploads are chunked. Each part size must be divisible by 1 KB, 512 KB must be divisible by the part size, and 512 KB is the maximum. Files up to 10 MB use upload.saveFilePart; larger ones use upload.saveBigFilePart. The maximum number of parts comes from client configuration, upload_max_fileparts_default and upload_max_fileparts_premium, so read the limits at runtime rather than hard-coding a size. A file is downloadable directly only from the DC where it was uploaded; other DCs answer FILE_MIGRATE_X. Downloads use upload.getFile with an offset and limit that, without the precise flag, must be multiples of 4 KB and stay inside one 1 MB chunk.

Popular media from public channels with more than 100,000 members can be served by CDN DCs in regions where Telegram does not run main servers. The CDN is treated as untrusted. The main DC answers with upload.fileCdnRedirect, files are stored on the CDN encrypted with AES-256-CTR under per-file keys that only the main DC and the authorised client hold, and the client checks each fragment against SHA-256 hashes from upload.getCdnFileHashes. If the CDN lacks a piece, the client asks the main DC to upload.reuploadCdnFile. General CDN design is covered in the CDN article; Telegram's twist is that the edge stores only ciphertext.

Worked example: one photo, a large channel

An admin posts a 3 MB photo to a channel with 500,000 subscribers. Following the documented paths:

  1. The admin's client splits the file into 512 KB parts and uploads them with upload.saveFilePart to its DC, then calls the send-media method referencing the uploaded file. The file now lives in that DC.
  2. The server assigns the post the next pts in the channel's own sequence.
  3. Online subscribers who have the channel loaded receive an update; subscribers who are offline, or whose clients receive updateChannelTooLong, catch up later with updates.getChannelDifference.
  4. Each client downloads the photo from the file's DC. As the post becomes popular in a region, downloads may be redirected to a CDN DC, served as ciphertext and verified against SHA-256 hashes.

The expensive part, pushing 500,000 copies, is mostly avoided: the message is stored once in the channel's sequence, notification is cheap metadata, and bytes flow from caches. For how push notifications reach sleeping devices in general, see notification system design.

Failure modes and operational guidance

  • Clock skew. Message ids are time-based, so a badly set clock makes the server reject messages. Clients must correct their time offset from server responses and handle bad_server_salt by retrying with the new salt.
  • Lost updates. Treat the gap rule as mandatory. Clients that apply updates without checking pts drift silently out of sync until reinstall.
  • Wrong DC. Every *_MIGRATE_X error must be handled generically: connect, create a key if needed, import the authorization, retry.
  • Rate limits. FLOOD_WAIT_X means wait X seconds. Bots that retry immediately extend their own ban; honour the wait and queue the work.
  • Reconnect storms. After a network blip, thousands of clients reconnecting and calling getDifference at once is a thundering herd. Jittered back-off is the client's responsibility.
  • Assuming E2E. Users and integrators often assume ordinary chats are end-to-end encrypted. They are not; design data handling accordingly.

Design lessons and trade-offs

DecisionGainCost
Cloud chats by defaultInstant multi-device sync, search, huge groupsServer can read content; a high-value target
Per-account home DCSimple ownership and localityCross-DC file and login hops; migration logic in every client
Per-channel ptsCatch-up proportional to what you readClients track many counters
Client-side gap detectionExact sync without server per-device queuesEvery client must implement it correctly
Encrypted, untrusted CDNCheap edge capacity anywhereHash checks and re-upload paths in the client

If you are designing a chat system, the transferable ideas are per-conversation sequence numbers with a contiguous-apply rule, a difference endpoint as the single recovery path, owner-based placement and an edge that never sees plaintext. Designing a real-time chat system shows how to assemble those pieces generically.

What to do next

  1. Read the MTProto description and updates pages on core.telegram.org end to end, noting the formulas and rules quoted here.
  2. Build a small client or bot on TDLib or a maintained library and log pts, qts and every *_MIGRATE_X error it handles.
  3. Simulate a gap by dropping updates in your client and verify getDifference restores exact state.
  4. Implement FLOOD_WAIT handling with a queue and jittered back-off before shipping any bot.
  5. Upload a file larger than 10 MB with saveBigFilePart, reading the part limit from config.
  6. Write down, for your own chat design, which data is server-readable and which is end-to-end, and why.
Key takeaway: Telegram's architecture follows from making chats cloud-first: MTProto gives a fast, obfuscatable client-server channel with per-DC authorization keys; accounts and files have home DCs reached through migrate errors; pts, qts and seq counters with a contiguous-apply rule and getDifference give exact multi-device sync; chunked uploads and an encrypted, untrusted CDN carry media at scale; and secret chats trade multi-device convenience for device-bound end-to-end encryption. The server internals are closed, but the public protocol is enough to learn the design and to build correct clients.