· 8 min read
Telegram Open Source Performance Deep Dive: Faster Chat Rendering by Cutting UI Work on the Hot Path
A practical look at how Telegram’s open-source clients can squeeze more performance out of chat rendering by reducing work on the main thread, minimizing allocations, and making updates more incremental. This deep dive focuses on changes Telegram developers can actually use when profiling and tuning real-world message lists.
Telegram clients live and die by perceived speed. The app can download messages quickly, but if the chat list stutters, the composer lags, or scrolling a busy channel drops frames, users still experience the client as slow. In a messaging app, performance is not a side concern; it is the product.
This deep dive focuses on a specific and high-impact optimization pattern that applies across Telegram’s open-source clients: reduce main-thread work during chat updates by making rendering more incremental and data-driven. The goal is not to rewrite the UI stack. It is to identify the expensive path that gets executed for every incoming message, read receipt, edit, pin, reaction update, and pagination step—and make that path cheaper.
Why chat rendering is the right place to optimize
Telegram’s workload is unusually bursty:
- Multiple updates arrive in quick succession.
- A single event can invalidate many visible rows.
- Message content is heterogeneous: text, media, replies, service messages, polls, stickers, reactions.
- Layout depends on width, theme, text direction, font scaling, and media metadata.
That means a naïve update strategy quickly becomes expensive. If a client recomputes full row layouts, rebuilds view models from scratch, or re-measures text for every small state change, the CPU cost scales badly with activity. The result is jank exactly when the app should feel most alive.
The most effective improvement is usually to separate “what changed” from “what needs repainting” and then make the smallest possible update.
The core performance problem: too much work per update
In many chat UIs, an incoming update triggers a chain like this:
- Network or store layer receives a message/state change.
- The conversation model updates.
- The visible list is notified.
- View models are rebuilt.
- Layout is recalculated.
- Widgets/renders are invalidated.
- Images, avatars, text, and reaction badges are repainted.
The problem is not any single step. It is that the app often cannot distinguish between:
- a message text edit that affects one row,
- a reaction count update that changes only a small badge,
- a pin/unpin event that affects header state,
- a pagination insert that changes indices but not the content of existing rows,
- a theme switch that requires full repaint but not full data reconstruction.
When every update gets treated as a full row rebuild, developers pay for needless object churn, repeated text shaping, repeated media state derivation, and extra diff work.
What to optimize first
If you are working on Telegram’s open-source clients, the highest ROI usually comes from the same three places:
1. Reduce allocations in message-to-view-model conversion
Message rendering often creates intermediate objects:
- formatted text wrappers
- attachment descriptors
- reaction model objects
- per-row layout metadata
- helper structs for link previews, reply headers, and time badges
If these are reconstructed on every update, the GC and allocator become part of the hot path.
What to do instead:
- Cache immutable derived data wherever possible.
- Reuse layout-friendly structures between updates.
- Split stable message data from volatile state.
- Keep expensive parsing out of the per-frame path.
A good target is to make a “message presentation model” that changes only when its render-relevant inputs change.
2. Make diffing cheap and local
A common anti-pattern is rebuilding the entire visible list when a subset of rows changes. Even if the UI toolkit only re-renders changed rows, the model diff can still be expensive.
Preferred strategy:
- Track row identity with stable IDs.
- Use structural sharing for collections where possible.
- Compute minimal inserts, deletes, and updates.
- For edits that only affect one message, avoid touching surrounding rows.
In Telegram-like UIs, this is especially important because message streams are long and pagination is continuous. A one-message change should be O(1) or close to it, not O(n) across the visible conversation.
3. Defer or cache text layout work
Text shaping and measurement can be surprisingly expensive, especially when messages contain rich formatting, emojis, mixed scripts, or truncated previews.
Best practice:
- Cache layout results keyed by text + width + font metrics + formatting mode.
- Use lazy measurement for offscreen rows.
- Avoid re-shaping text for updates that do not affect typography.
- Separate text content changes from non-text metadata changes.
If your client renders message bubbles with rich typographic rules, text layout can dominate CPU time on active channels.
A practical optimization pattern: split message state into stable and volatile parts
One of the most useful refactors in a Telegram client is to divide each message’s data into two layers:
Stable layer
This includes data that usually changes rarely:
- message ID
- sender
- timestamps
- raw content
- media metadata
- reply target
- link preview payload
- attachment type
Volatile layer
This includes state that changes often:
- read/unread status
- reaction counts
- delivery state
- selection state
- hover/focus state
- animation flags
- ephemeral UI indicators
If you keep both layers bundled together, every volatile change can trigger a full reconstruction of the expensive stable representation. If you separate them, you can update badges and indicators without touching the text/media layout.
That one decision often yields more practical benefit than micro-optimizing any single function.
Example: reaction updates should not rebuild a message row
Reactions are a good test case. They change often, they are visually prominent, and they can arrive in bursts.
A poor implementation might:
- fetch the updated message state,
- rebuild the whole row model,
- recompute the bubble layout,
- re-render all child components.
A better approach is:
- keep the base message view model untouched,
- update only the reaction submodel,
- invalidate only the reaction badge area,
- preserve the message text and media layout cache.
This is more than an implementation detail. It changes the performance profile of the entire chat feed, especially in large group chats and channels where reaction activity is heavy.
Example: message edits should preserve layout when possible
Many edits do not meaningfully change layout. A typo fix, a URL replacement of similar length, or a change in reaction state should not always require a full row re-measure.
A solid optimization is to compute a layout-affecting change flag:
- If the edit changes only non-typographic metadata, reuse layout.
- If the edit changes text but not width-dependent wrapping, reuse partial layout when possible.
- If the edit changes bubble dimensions, remeasure only the affected row.
This kind of conditional invalidation is especially important for mobile clients, where CPU budgets are tighter and battery cost matters just as much as frame time.
Avoiding hidden costs in list virtualization
Telegram clients often show long histories, which means virtualization matters. But virtualization only helps if the visible-path work is actually small.
Watch out for these hidden costs:
- scanning too many rows to find visible items
- recalculating anchors after every insert
- rebuilding index maps repeatedly
- over-invalidating when pagination loads older messages
- forcing relayout after scroll position correction
To improve this, maintain a data structure that lets you answer:
- Which rows are visible?
- Which rows changed?
- Which rows need measurement vs. paint only?
- Which rows can be reused across scroll positions?
If the client is already using a fast list abstraction, the next gain usually comes from reducing the amount of per-item logic that runs inside that abstraction.
Instrumentation: measure the hot path, not just frame rate
Frame rate is important, but it is often too coarse to tell you where the time went. You need profiling around the actual chat update pipeline.
Add instrumentation for:
- model update time
- diff computation time
- text layout time
- image decode or thumbnail prep time
- row construction time
- paint invalidation time
- dispatch-to-render latency
The most useful metric is often not average render time, but tail latency: how long the worst updates take during bursty activity.
If 95% of updates are cheap but 5% are expensive enough to block scrolling, users will still report the app as laggy.
A good optimization target: make common updates cheap and rare updates bounded
You do not need every update to be free. You need the common ones to be very cheap and the rare ones to be predictable.
For Telegram clients, common updates include:
- unread count increments
- reaction count changes
- typing indicators
- message delivery state transitions
- selection toggles
- avatar presence changes
Rare but expensive updates include:
- a theme change
- font size change
- device rotation
- bulk history sync
- very large message edits
- media-rich channel loads
Design your cache and invalidation model so that common updates avoid full row rebuilds, while rare updates can still safely invalidate broader state.
What this means for Telegram developers
If you are maintaining or extending Telegram open-source client code, the performance lesson is straightforward:
- Treat chat row construction as a hot path.
- Make derived presentation state cacheable.
- Separate volatile UI state from stable message state.
- Invalidate narrowly.
- Profile allocations, text layout, and diffing before optimizing paint code.
The biggest gains usually come not from one clever algorithm, but from a disciplined architecture that avoids repeated work.
A checklist for your next optimization pass
Use this when reviewing a chat rendering path:
- Does every update rebuild the whole row model?
- Are text layouts cached by width and style?
- Are reactions and read states isolated from base content?
- Do edits reuse existing layout when possible?
- Are offscreen rows skipped entirely?
- Are allocations visible in the profiler during bursts?
- Can the list diff identify a single-row update without scanning the full history?
- Does the client avoid re-measuring when only paint state changes?
If the answer to several of these is no, there is likely real performance on the table.
Final takeaway
For Telegram open-source clients, the most valuable performance improvements are usually architectural, not cosmetic. The biggest win is to stop treating every chat update like a full rebuild and instead make the rendering pipeline incremental, cache-aware, and tightly scoped.
That approach improves scroll smoothness, lowers CPU usage, reduces battery drain, and makes large chats feel responsive even under heavy update traffic. In a messaging app, that is the difference between a client that merely works and one that feels instant.