Skip to content
HeyDai blog
Go back

Telegram Doesn't Load Your Chat History. So Why Do Thousands of Messages Appear Instantly?

A conversation can live for years. Tens of thousands of messages, photos, videos, replies, edits, deletes.

Yet when you open Telegram, it never feels like the app is doing:

request server → wait for response → parse JSON → write database → build UI.

The most recent messages appear almost immediately. Scroll up, and history keeps materializing. Lose your connection, and you can still read most of what you’ve already seen.

The more interesting puzzle hides in a harder situation:

The app goes offline for an hour and misses 237 changes. When it comes back online, how does Telegram know exactly what it missed — without re-downloading the conversation?

That question is where Telegram’s message architecture actually earns its reputation, and it’s what this post digs into.

Tip

Telegram didn’t just build an API to fetch messages. It built an engine where the client always knows what it has, knows what it’s missing, and fetches only the missing part. Every claim below is checked against Telegram’s own documentation — sources at the end.

Table of contents

Open Table of contents

How Fast Is Telegram, Really?

I’m going to resist the temptation to open with a number like “Telegram opens a chat in 50 ms.”

Telegram publishes no official benchmark for open chat → first message rendered on any device, and I’m not going to invent one. But there’s stronger evidence than a benchmark: the shape of the API itself.

Telegram describes TDLib — the engine inside official and third-party clients — as:

For throughput, the TDLib README states that in the Telegram Bot API, each TDLib instance handles more than 40,000 active bots simultaneously. (This number keeps growing — older versions of the same page said 18,000, then 24,000, then 37,000 — so treat it as an order of magnitude, not a spec.) It isn’t a chat-open benchmark, but it tells you the engine underneath was built for serious throughput.

The most telling detail, though, is a single parameter. TDLib’s history API exposes:

getChatHistory(..., only_local = true)

The documentation is explicit: pass only_local = true “to get only messages that are available without sending network requests”, and the call is an offline method.

That’s the real evidence behind the “instant” feeling:

Telegram doesn’t have to ask the server before it has something to render.

The First Mistake: Treating “Load Messages” as an API Call

A straightforward implementation imagines this:

User opens thread

GET /messages?threadId=123

Wait network

Parse response

Render messages

This architecture has one fundamental problem:

network latency sits directly on the UI’s critical path.

If the API takes 300 ms, the user waits 300 ms. If it takes a second, the user waits a second. If you’re offline:

loading...

Telegram’s model decouples the two:

             ┌── Local Message Database ──→ UI

User opens chat

             └── Sync Engine ──→ Server

                  update local DB

The UI and the network are no longer a serial pipeline. That’s the first step to understanding everything else.

The Local Database Isn’t Just a Cache

The common framing is:

Server = source of truth
Local DB = cache

For a good message engine, a more useful mental model is:

Server

Synchronization engine

Local message store

UI

The UI reads what the client already knows. The network’s job is to move what the client knows closer to the server’s latest state.

TDLib owns both halves — network and local storage — and getChatHistory having an only_local mode means “render from what we have” is a first-class operation, not a hack.

That produces the UX property everything else in this post builds on:

time-to-message is no longer the same thing as network latency.

Telegram Doesn’t Load 10,000 Messages

Say a conversation holds:

1
2
3
...
99,998
99,999
100,000

The user opens it. The UI needs only a small window near the end of history:

99,951
...
99,999
100,000

TDLib’s getChatHistory requests history starting from a from_message_id, with an offset, and a limit that “can’t be greater than 100” per call. The docs even note that “the number of returned messages is chosen by TDLib and can be smaller than the specified limit” — the engine reserves the right to return less when that’s the faster answer.

So instead of:

load EVERYTHING

you get:

load WHAT THE USER NEEDS

That’s the first principle:

History size and initial rendering cost must be decoupled.

Whether a conversation has 100 or 100,000 messages says nothing about how many messages the UI must materialize.

Message IDs Are a Natural Cursor

Telegram’s history API isn’t built on:

page=1
page=2
page=3

It anchors pagination to a message:

from_message_id
offset
limit

Say the window currently holds:

10000
9999
9998
...
9951

When the user scrolls up, the next request anchors at from_message_id = 9951 and fetches the older window.

This matters because chat history is not a static dataset. While the user scrolls:

a new message arrives
a message gets deleted
a message gets edited

Page-number pagination shifts under your feet. ID-anchored history is far more stable, because the question becomes:

“Give me the messages around this ID.”

instead of:

“Give me page 7 of a dataset that just changed.”

The Real Problem Starts When You Go Offline

If Telegram were just a local database plus cursor pagination, the architecture wouldn’t be that special. The real problem shows up offline.

Suppose the client’s last known state is:

pts = 12500

Then it loses the connection. Meanwhile the server keeps absorbing:

+ message
+ message
+ edit
+ delete
+ message
...

On reconnect, a naïve implementation fetches the latest messages and then guesses:

Which messages am I missing?
Were there edits?
Were there deletes?
Did anything happen that I never saw?

Telegram has a much better mechanism.

pts, qts, seq: The Client Knows Where It Stands

Telegram’s update protocol maintains explicit synchronization state — sequence counters named pts, qts, and seq. Each event that touches a message box increments the counter, so the counter is the position in the event stream.

The apply rule in the documentation is precise. Every update carries the new pts and a pts_count (how many events it represents), and the client checks:

local_pts + pts_count == pts   →  apply the update
local_pts + pts_count  > pts   →  already applied — ignore it
local_pts + pts_count  < pts   →  there's a gap — some updates are missing

Notice what the middle branch buys you for free: deduplication. If the server delivers the same update twice, the arithmetic says “already applied” and the client drops it. The same three-line rule handles ordering, dedup, and gap detection.

So when the client sits at local_pts = 104 and receives an update requiring pts = 107, it doesn’t suspect something is missing. It knows updates 105 and 106 are missing. Telegram’s documentation calls this a gap in seq / pts / qts.

One more nuance worth knowing: pts is not one global number. Each channel and supergroup has its own pts sequence, separate from the common state for private chats and small groups. Sync state is a set of counters, not a single high-water mark.

Detecting the Gap Is Half the Battle: getDifference

When synchronization is lost, Telegram provides:

updates.getDifference

and, for channels:

updates.getChannelDifference

The idea is simple. The client says:

I know everything up to pts = 12500.

The server’s current state is 12737. The client doesn’t ask “give me the latest 1000 messages.” It asks for the difference since the state it already has.

The documentation is prescriptive about when: “On startup, only updates.getDifference should be called, to fetch updates received while the client was offline” — and again whenever a gap is detected in seq / pts / qts.

Channels get an extra trick. Clients don’t call updates.getChannelDifference for every channel at startup. Instead, the server sends updateChannelTooLong — effectively saying “too many updates happened in this channel; come fetch the difference yourself” — and the client syncs that one channel on demand. The server actively helps the client decide when syncing is worth it.

The mental model becomes:

Known state
12500

   │  missing changes

12737

instead of:

?????????????????
refetch everything
?????????????????

This might be the single most important idea in Telegram’s message synchronization.

Why Telegram Waits Half a Second Before Syncing

There’s a lovely detail in the documentation. When a gap is detected, it says “it may be useful to wait up to 0.5 seconds in this situation, as the missing updates may have been simply reordered by the server, and may arrive shortly after, filling the gap.”

Why? Because a “gap” is sometimes just reordering:

received 103
received 105

"Missing 104!"

…and a few milliseconds later:

received 104

Calling getDifference() immediately would burn a round-trip on a problem that was about to solve itself. Telegram gives late updates a small grace window; only if the gap survives 0.5 seconds does the client start gap recovery.

A tiny optimization carrying a big philosophy:

Don’t spend network solving a problem that might disappear on its own.

Known Ranges: The Client Tracks What It Has, Not Just What It Sees

This is the part I find most underrated.

Telegram’s documentation says clients with a message database should track the ranges of message IDs they know. Picture a local DB holding:

1 ─────── 100

              GAP

150 ─────────────── 300

The client never needs to ask the server “do I have everything?” It can answer locally:

known range: 1..100
missing:     101..149
known range: 150..300

When the user scrolls to a boundary — message 100, with the next known message at 150 — the engine knows the exact range to fill. The docs even describe the mechanics: fill old history gaps with messages.getMessages or channels.getMessages, up to 200 IDs per call, then merge the results back into the message database. They go as far as suggesting a segment tree as the data structure for tracking known ranges — implementation advice you rarely see in protocol documentation.

That’s the mental-model upgrade:

"Have I loaded page 4?"

is much weaker than:

"What exact ranges of history do I know?"

Why Opening a Chat Doesn’t Re-Probe the Server

Suppose the local engine knows:

messages:
950 ───────────────── 1000

known edge:
1000 = latest known state

When the user opens the thread:

Local DB → 950...1000 → render immediately

The sync engine handles new changes independently. If the database knows a range is complete, the client doesn’t have to keep asking:

"Hey server, anything after 1000?"

The update state and the synchronization protocol already answer that question. This is where Telegram saves the network round-trips a naïve message engine pays on every single thread open.

Deletes Must Not Break the Algorithm

Here’s a fun failure mode:

101
102
103
104
105

Message 103 gets deleted. A client looking only at its DB sees:

101
102
104
105

and might conclude: “103 is missing → fetch it.” But 103 isn’t missing. It’s deleted.

Telegram’s answer: the range-filling APIs return placeholder messageEmpty constructors “for deleted or otherwise non-representable messages” — so the entire requested range is still fully described, hole included. And the specialization runs deeper: the docs warn that messages.getHistory cannot be used to fill gaps in channels and supergroups — you must use the channel-specific methods. Even “fetch me this range” needs the right tool for the right message box.

The principle:

absence of data ≠ missing data.

A message engine needs to distinguish:

not loaded
deleted
unavailable
loaded

Otherwise it walks straight into the loop:

detect gap → fetch → doesn't exist → detect gap → fetch → ...

Putting It Together: A Message Window Engine

Assembled, the pieces look like this:

updates / getDifference

getMessages (≤200 IDs)

Telegram Server

Synchronization engine

pts / seq / qts

Local message database

Known message ranges

Missing ranges

Visible window

Gap recovery

UI

Opening a thread:

DB → visible window → UI

Scrolling backward:

DB → window reaches known boundary → fetch missing range → DB → extend window

Reconnecting:

local pts → getDifference → missing updates → DB → UI observes new state

Three problems —

open thread
scroll history
reconnect

— stop being three unrelated piles of API calls. They all revolve around one model:

Local state + visible window + known ranges + synchronization state.

An Architecture Story, Told Carefully

Telegram never published a timeline like “2015: open chat in 700 ms; 2020: 80 ms,” so I won’t invent one. But the architectural evolution is documented.

Stage 1 — MTProto and cloud history. Cloud-synchronized history was a core property from Telegram’s foundation, not a later add-on.

Stage 2 — update state becomes the contract. pts / seq / qts, difference fetching, and gap recovery became an explicit part of the client–server contract. Today’s documentation spells out exactly how startup, reconnect, reordered updates, and gaps must be handled.

Stage 3 — TDLib packages the complexity. Network, storage, ordering, synchronization, encryption — all folded into one reusable engine. App developers no longer hand-build this state machine; TDLib guarantees ordered updates, asynchronous execution, and local storage.

Stage 4 — local-first becomes first-class API. getChatHistory(only_local=true) in the public API contract means offline retrieval isn’t a hack bolted onto the architecture. It is the architecture.

This Isn’t an Ad for Telegram

One thing matters for this post’s credibility: there is no evidence that “Telegram always loads messages fastest.”

Users have reported periods of slow loading on Android and iOS, chats stuck on “Updating…”, and media-cache misbehavior. So the honest claim is:

Telegram’s message synchronization architecture has strong properties for fast, resilient UX

— not:

Telegram never lags.

Those are very different statements, and only the first one is defensible.

What’s Worth Copying Isn’t SQLite

If the takeaway were “Telegram is fast because it uses SQLite,” we’d have missed nearly the whole lesson. SQLite is one piece. What’s worth studying is how the pieces interlock — and at least six principles fall out:

1. Local-first rendering. Keep the network off the critical path whenever local state is enough to render.

2. Windowed history. Never materialize a whole conversation to show a few dozen messages.

3. ID-anchored pagination. Anchor history to stable data, not page numbers.

4. Explicit synchronization state. The client must know where it stands — as a counter it can do arithmetic on, not a guess.

5. Gap detection + gap recovery. Never refetch history just to find out what you missed.

6. Known ranges. Don’t just store messages. Store which parts of history the client knows for certain it has.

The Questions That Actually Matter

When designing message loading, the first question is usually:

“Which API do I call to get messages?”

Once the system is big enough, that question is too small. The real ones are:

Which messages does the client know right now?

Which ranges does it know are complete?

Which window does the UI need at this moment?

Where are the gaps?

Is each gap caused by network, by deletion, or by never having loaded it?

What state is the client syncing toward?

How do we update local state without blocking the UI?

When is a network request actually necessary?

And that’s why Telegram’s architecture rewards study:

They didn’t just build an API to download messages. They built an engine where the client always knows what it has, what it’s missing — and downloads only the missing part.

Sources

Every protocol claim in this post was checked against these documents (as of August 2026):


Share this post:

Part of the Message Systems series


Previous Post
WhatsApp, Signal, Messenger, Telegram: How Do They Really Encrypt Your Messages?
Next Post
What Should a Mobile App Use for Local Storage? How Telegram, Messenger and Signal Store Millions of Messages