記録 · Entry

How Telegram renders animated stickers

A Telegram animated sticker is a few tens of kilobytes of vector maths, not a video. Here is the whole pipeline from a gzipped .tgs file to pixels on screen, pulled from the shipped Android binary.

7 min read

A fully animated Telegram sticker — a cat that blinks, waves and bounces for a couple of seconds — is about twenty kilobytes. A single frame of 512×512 video is already larger than that. The trick is that a .tgs file is not frames at all.

It is a Lottie animation: vector keyframes exported from After Effects through the Bodymovin plugin, serialised as JSON, then gzip-compressed. The client ships the recipe for the drawing, not the drawing. Everything the eye sees is rasterised on the device, at the device's resolution, from a few hundred bytes of Bézier paths and transforms per layer. That is the entire reason the format is tiny, and it is where the interesting engineering lives.

Everything below came from pulling the APK off a phone, reading the decompiled Java with jadx and the 22 MB native library, and cross-checking every claim against Telegram's public source (the client is GPL). The shipped binary matched DrKLO/Telegram at 13.0.2 — with one exception I will get to, which the public teardowns online have not caught up with yet.

A Lottie animation rendered live — vector keyframes, the same shape Telegram ships as a gzipped .tgs.

#The shape of the pipeline

The journey from a .tgs on disk to a pixel on screen is short and almost entirely native. The Java side does lifecycle and caching; the C++ side does all the work.

Telegram animated sticker rendering pipeline .tgs file gzip + JSON gunzip Lottie JSON vector keyframes tlottie engine native rasteriser off UI thread Android Bitmap RGBA_8888 / A_8 LRU cache 10 MB, bounded screen
The whole path. Only the two boxes on the lower row and the LRU live in Java; decompression, parsing and rasterisation are native.

#Decompression happens in C, not Java

There is a decompressGzip() helper on the Java side — a plain GZIPInputStream wrapper — and you would expect it to be the one that runs. It mostly does not.

The hot path decompresses natively inside lottie.cpp, using gzopen/gzread from zlib directly. The JSON is streamed straight out of the gzip stream and into the parser, so a large animation never materialises as a multi-megabyte Java String on the heap. For a format whose whole premise is "stay small", paying a full inflate-to-String tax on the managed heap on every sticker load would be an odd place to give it all back. They don't.

#One native pointer, out-parameters back through an int array

The JNI surface is a class called RLottieNative. Loading a sticker calls nCreate(...) (from a file) or nCreateWithJson(...) (from an in-memory JSON buffer), and both return a long — a raw pointer to the native animation instance.

Metadata that the Java side needs — frame count, frames per second, the natural dimensions — does not come back as a return value. It is written into an int[] the caller passes in. This is the standard JNI shape for returning a small struct without defining a Java class to mirror it: one allocation, filled in place.

long ptr = RLottieNative.nCreateWithJson(json, w, h, metadata);
int frameCount = metadata[0];
int fps        = metadata[1];

From there, rendering is deliberately boring.

#Rendering writes directly into the bitmap's pixels

This is the part worth copying.

RLottieNative.nGetFrame(ptr, frame, bitmap, clear);

nGetFrame takes the native pointer, a frame index, and an already-allocated Android Bitmap. It locks that bitmap's pixel buffer and rasterises the requested frame straight into it. No intermediate buffer on the Java side, no per-frame allocation, no garbage for the collector to chase while an animation plays at 60 fps. The same bitmap is handed back frame after frame and overwritten in place; clear says whether to wipe it first.

When the animation is finished with, nDestroy(ptr) frees the native instance. Because a freed pointer used again is a segfault rather than an exception, there is a recycled-instance guard — an AtomicBoolean on the Java object — that throws cleanly if anyone asks for a frame after recycle. That is the one piece of defensive code in an otherwise lean path, and it is defending against exactly the failure mode that native lifecycle invites.

#Two bitmap formats, and the stride problem

Colour stickers render into RGBA_8888 — four bytes a pixel, the usual thing. But a sticker that is a single flat colour on transparency (a lot of them are) is rendered into A_8 instead: an alpha-only format, one byte per pixel, roughly four times less memory. The colour is applied at draw time; the rasteriser only needs to store coverage.

There is a subtlety the native code has to handle. An Android Bitmap's rows are not guaranteed to be packed — the stride (bytes per row) can be larger than width × bytesPerPixel, for alignment. When stride equals the packed width the engine rasterises straight into the locked buffer. When it does not, it renders into a temporary tight buffer and copies row by row into the bitmap, respecting the stride. Skip that check and you get the classic diagonal-shear corruption where every row drifts by a few pixels.

#The novel bit: it isn't rlottie any more

Here is the finding the existing teardowns miss, and I want to be clear it is my own observation from the shipped 13.0.2 binary rather than something documented.

Every public write-up of Telegram's sticker rendering says it uses rlottie, the open-source Lottie engine Samsung released and Telegram adopted years ago. The Java class is still named RLottieNative, which keeps the story looking unchanged. But the native symbols underneath have moved. This build calls into a new in-house engine — tlottie — through a plain C ABI:

void *instance = tlottie_new_with_options(/* ... */);

tlottie_render_with_options(
    instance, frame, width, height,
    pixels, count, 1, curveTolerance, clear);

The curveTolerance parameter is the tell: it is a flatness threshold for how finely Béziers are subdivided into line segments before scan-conversion. Trade a little smoothness on a tight curve for fewer segments to rasterise, and you can tune the quality/throughput balance per call — the kind of knob you add when you own the engine and are optimising it for your own content, not consuming someone else's library as-is. The Java name survived the swap; the renderer behind it did not. If you are reading an older analysis that confidently says "rlottie", check your own binary.

#What actually transfers to your own code

Strip away the reverse-engineering and the reusable lesson is small.

If you want Lottie on Android yourself, you do not need any of this. airbnb/lottie-android plays Lottie JSON directly. For a .tgs specifically, gunzip it first, then hand the resulting JSON to the player — the compression is the only thing standing between you and a standard Lottie file. The export pipeline is the mirror image: After Effects plus Bodymovin produces the JSON, gzip turns it into .tgs.

The one architectural idea worth stealing is the frame loop. Render your vectors into a reusable bitmap buffer, off the main thread, and never allocate per frame. Then cache the decoded results in a size-bounded LRU. Telegram's ImageLoader keeps its decoded drawables in a 10 MB LruCache<BitmapDrawable> — a field literally named lottieMemCache — keyed by image key, moved to front on a hit and dropped entirely (evictAll()) when the system reports memory pressure. A bound and an eviction policy, nothing exotic.

That is the whole shape: ship the maths, rasterise native, write into memory you already own, and keep a hard ceiling on what you hold. The sticker is small because it was never a picture; the client is fast because it never pretends otherwise.

#References

  • DrKLO/Telegram — the official Android client source (GPL), cross-checked at 13.0.2
  • Lottie and lottie-android — the player you would use yourself
  • Bodymovin — the After Effects export plugin that produces Lottie JSON

Related entries