· 7 min read
Reducing UI Jank in Telegram Desktop with Lazy Message Rendering
Telegram’s open source codebase makes it practical to profile and improve real-world performance, not just chase abstract benchmarks. This tutorial walks through one high-impact optimization: deferring heavy message item work until it is actually needed on screen.
Telegram’s open source clients are valuable not just because the code is available, but because they let you study a production-scale messaging app and make targeted performance improvements with real user impact. In a chat UI, the most common source of perceived slowness is not network latency; it is main-thread work during scrolling, resizing, and chat switching.
This tutorial shows how to approach a practical optimization in a Telegram-style desktop client: lazy message rendering and measurement. The goal is to reduce frame drops by avoiding expensive per-message work until a message is near the viewport.
The examples below are written for Telegram Desktop-style codebases, but the approach applies to any large chat list implementation.
What we are optimizing
In a message list, each visible row may require:
- text layout and wrapping
- media thumbnail sizing
- link preview measurement
- reply header layout
- date and service message formatting
- reaction badge placement
- read-state indicators
If all of that work happens eagerly for every message in the current history chunk, the UI can stall when:
- opening a long conversation
- jumping to an older message
- fast scrolling through loaded history
- resizing the window and invalidating layout
The key idea is simple: only fully compute what the user can see soon.
First, confirm the bottleneck
Before changing code, verify that message layout is actually the hotspot.
1. Reproduce a realistic workload
Use a chat with:
- thousands of loaded messages
- mixed media and text
- some long messages
- replies and forwarded items
- a few inline previews or attachments
Then test:
- opening the chat from the dialog list
- scrolling one page at a time
- flicking the wheel rapidly
- resizing the window horizontally
2. Profile the hot path
On desktop, use your platform profiler or built-in tracing to inspect:
- time spent in list item layout
- repeated text shaping
- image metadata queries
- unnecessary recomputation after minor state changes
You are looking for patterns like:
layout()called for many messages that are not visible- repeated
measureText()for identical content - full recomposition when only scroll position changed
- synchronous thumbnail decoding or aspect-ratio lookup
If the flame graph shows that message list work dominates frame time, the optimization is worth doing.
Design the optimization
A good lazy rendering strategy splits message processing into three stages:
- Cheap metadata pass
- message type
- rough height estimate
- presence of media or preview
- flags that affect layout decisions
- Viewport-near preparation
- text measurement
- thumbnail size calculation
- reply header layout
- bubble geometry
- On-screen finalization
- expensive sublayout only for visible items
- final painting data
- bitmap or glyph cache access if needed
The important rule is that stage 1 must be fast enough to run for many messages, while stages 2 and 3 are limited to the visible window plus a small prefetch margin.
Use a layout cache with invalidation
Most chat messages do not change after they arrive. That makes them ideal cache candidates.
A message layout cache should key off the properties that affect rendering, such as:
- message id
- edit version
- width bucket or exact width
- theme or font metrics version
- media dimensions or caption presence
- reply/forward state
A simple approach is to store a LayoutState per message:
struct LayoutState {
int width = 0;
int height = 0;
bool measured = false;
bool fullyLaidOut = false;
int version = 0;
};
Then separate “known size” from “fully computed layout”. For example:
measured = truemeans you know approximate row heightfullyLaidOut = truemeans all sub-elements are ready for painting
When width changes, invalidate only the width-dependent portion instead of rebuilding everything.
Prefetch only a narrow band around the viewport
A chat list rarely needs perfect layout for hundreds of rows. It needs exact layout for what is visible and near-visible.
Maintain a render band:
- visible rows
- one to two screens above
- one screen below
That gives scrolling a buffer without overworking the main thread.
Pseudo-logic:
const int kPrefetchAbove = viewportHeight * 2;
const int kPrefetchBelow = viewportHeight;
bool shouldFullyPrepare(const Row& row, int scrollTop, int scrollBottom) {
return row.bottom >= scrollTop - kPrefetchAbove &&
row.top <= scrollBottom + kPrefetchBelow;
}
Rows outside this band can use cached heights or rough estimates.
Defer expensive text shaping
Text shaping is often one of the highest-cost operations in chat rendering, especially for:
- long multilingual messages
- emoji-heavy content
- mixed-direction text
- rich formatting
If your renderer shapes text eagerly for every loaded row, move it behind a visibility check.
Practical pattern
- compute message bounds from cached font metrics and a fast approximation
- shape only when a row enters the prefetch band
- reuse shaped runs if width has not changed
- store line break results separately from paint results
This matters because users usually scroll through a chat in bursts. If text shaping is amortized across frames instead of blocking the initial open, the UI feels much smoother.
Avoid recomputing unchanged rows
A common mistake in chat UIs is invalidating too much state when anything changes.
For example, after receiving a new message, do not trigger full relayout of all visible history unless the insertion actually affects their positions. In many Telegram-style layouts, only the following should change:
- the inserted row
- adjacent grouping boundaries
- the scroll anchor if the user is pinned to bottom
Everything else should keep its cached geometry.
Good invalidation strategy
Invalidate by cause:
- content change: relayout only that message
- width change: relayout visible and prefetch rows
- theme change: repaint plus any theme-sensitive metrics
- font scale change: invalidate all cached measurements
- reaction update: repaint and row height only if badges affect geometry
This keeps the invalidation blast radius small and predictable.
Keep asynchronous work off the hot path
If your rendering path hits disk, decodes images, or queries metadata synchronously, it will cause jank.
Move those operations out of the frame-critical path:
- load thumbnails asynchronously
- cache media dimensions in the message model
- precompute sticker and emoji metadata when messages are parsed
- never wait on network or filesystem work during paint
A useful rule: paint should only consume ready data.
If data is not ready, paint a placeholder or fallback representation and schedule the expensive work for later.
Measure the improvement correctly
Once you implement lazy rendering, verify that it helps in the scenarios users actually feel.
Track:
- average frame time during scroll
- worst-frame time when opening long chats
- number of message layouts per scroll gesture
- CPU time in shaping and measurement
- memory cost of your caches
A successful optimization usually shows:
- lower peak CPU during chat open
- fewer missed vsync deadlines
- smoother first scroll
- small or acceptable memory growth
If CPU goes down but memory explodes, tighten cache limits or compress stored layout state.
Watch out for trade-offs
Lazy rendering is not free. Common pitfalls include:
1. Pop-in during fast scroll
If the prefetch band is too narrow, rows may appear before they are fully measured. Increase the buffer or make the rough-height estimate better.
2. Cache churn on resize
If every pixel-width change invalidates all layouts, resizing becomes expensive. Bucket widths or debounce relayout during drag resize.
3. Hidden layout bugs
Deferred work can expose cases where paint assumes layout is complete. Audit paint code to ensure it handles partial state safely.
4. Over-caching
Do not store full layout objects for every message in every chat forever. Use LRU eviction or per-chat caches with size limits.
A practical implementation checklist
If you are applying this in a Telegram open source client, start with this checklist:
- profile first and identify the dominant layout path
- separate cheap row sizing from full row layout
- add a viewport-near prefetch band
- cache layout by message id and width
- defer text shaping until needed
- move media metadata and thumbnail work off the hot path
- narrow invalidations to the smallest affected set
- verify scroll smoothness and open-chat latency after each change
What success looks like
After the optimization, the user should notice:
- faster chat opening
- fewer scroll hitches in large histories
- smoother resize behavior
- no visible degradation in message correctness
For developers, the best sign is that the profiler stops pointing at message layout as the main bottleneck and starts showing smaller, more distributed costs.
Final takeaway
Telegram’s open source clients are a strong playground for performance work because the codebase is large enough to expose real scaling problems, yet structured enough that targeted fixes matter. Lazy message rendering is one of the highest-value changes you can make in a chat UI: it reduces unnecessary work, keeps the main thread responsive, and improves the user’s perception of speed without changing product behavior.
If you are working on Telegram Desktop or a similar client, treat layout as a budgeted resource. Measure what is visible, defer what is not, and keep invalidation as narrow as possible. That is how you turn a heavy chat history into a smooth one.