<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Sutaek Oh</title>
    <description>The latest articles on DEV Community by Sutaek Oh (@brian_stark_09127).</description>
    <link>https://dev.to/brian_stark_09127</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3724008%2Fbaff443f-2040-4769-b3ea-fd588989312a.png</url>
      <title>DEV Community: Sutaek Oh</title>
      <link>https://dev.to/brian_stark_09127</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/brian_stark_09127"/>
    <language>en</language>
    <item>
      <title>Fixing Animated WebP Jitter on Android with Jetpack Compose</title>
      <dc:creator>Sutaek Oh</dc:creator>
      <pubDate>Thu, 29 Jan 2026 02:10:36 +0000</pubDate>
      <link>https://dev.to/brian_stark_09127/fixing-animated-webp-jitter-on-android-with-jetpack-compose-1ah</link>
      <guid>https://dev.to/brian_stark_09127/fixing-animated-webp-jitter-on-android-with-jetpack-compose-1ah</guid>
      <description>&lt;p&gt;Have you ever noticed some animated WebP images jittering by about 1 pixel on Android? It’s subtle, but it becomes obvious once you notice it. This post covers why it happens and how to fix it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fviazgfdyut9tvsc1rcaw.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fviazgfdyut9tvsc1rcaw.gif" alt="1px jitter visible on grid lines" width="400" height="399"&gt;&lt;/a&gt;1px jitter visible on grid lines&lt;/p&gt;

&lt;p&gt;In my testing, this issue reproduced on Android 15 and earlier, but not on Android 16 (tested on emulator and Samsung Galaxy S23+). However, many users are still on older OS versions, so this workaround remains relevant. A repro asset is included at the end of this post if you want to test it yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;I was displaying animated WebP images in an app when I noticed them shaking slightly — about 1 pixel. It only happened at certain downscaled sizes, and the same image displayed normally in Chrome on the same device.&lt;/p&gt;

&lt;p&gt;In our production setup, this issue is common. Design assets are typically exported at higher resolutions than their display size, and most animations are delivered as animated WebP. So downscaling is almost always involved.&lt;/p&gt;

&lt;h2&gt;
  
  
  Root Cause Analysis
&lt;/h2&gt;

&lt;p&gt;This happens when animated WebP is decoded via &lt;code&gt;ImageDecoder&lt;/code&gt; (Android 9+) into &lt;code&gt;AnimatedImageDrawable&lt;/code&gt; — the common setup for Coil and Glide.&lt;/p&gt;

&lt;p&gt;After digging in, I found the issue occurs when two conditions are met:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The animated WebP uses &lt;strong&gt;partial frames&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;The image is scaled at a &lt;strong&gt;non-integer ratio&lt;/strong&gt; (e.g., 0.7x, 0.8x)&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  What Are Partial Frames?
&lt;/h3&gt;

&lt;p&gt;Animated images can store frames in two ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Full frames&lt;/strong&gt;: Every frame contains the complete image.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Partial frames&lt;/strong&gt;: Only the changed region relative to the previous frame is stored.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Partial frames (or delta frames) reduce file size, so they’re often the default. Each one also includes coordinates (offset) like “draw this patch at position (x: 100, y: 100).”&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Non-Integer Scaling Causes Problems
&lt;/h3&gt;

&lt;p&gt;Since Android generally handles image placement coordinates in integer pixel units, the renderer must round scaled coordinates to integers. The problem is that partial frames can have slightly different offsets, and rounding errors vary depending on the scale ratio.&lt;/p&gt;

&lt;p&gt;For example, at 0.7x scaling with offsets that shift by 2px each frame (100 → 102 → 104 → 106):&lt;/p&gt;

&lt;p&gt;100 × 0.7 = 70.0 → 70&lt;br&gt;
102 × 0.7 = 71.4 → 71&lt;br&gt;
104 × 0.7 = 72.8 → 73 ← skips 72&lt;br&gt;
106 × 0.7 = 74.2 → 74&lt;br&gt;
Rendered positions: 70 → 71 → 73 → 74 (irregular spacing causes jitter)&lt;/p&gt;

&lt;p&gt;This manifests in two ways:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Jittering/Shaking&lt;/strong&gt;: The partial frame position shifts by 1 pixel between frames, making the image appear to vibrate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gray lines/Seams&lt;/strong&gt;: A thin gray line where the image was cut and reassembled.
Both symptoms have the same root cause, so the fix is identical.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;
  
  
  The Solution
&lt;/h2&gt;

&lt;p&gt;Assuming you’re using Coil with Jetpack Compose. If you don’t override size, Coil resolves the request size from the destination constraints and passes it to &lt;code&gt;ImageDecoder&lt;/code&gt;. &lt;strong&gt;The jitter only reproduced when decoded at a scaled size.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The fix is simple — &lt;strong&gt;decode at original size (&lt;code&gt;Size.ORIGINAL&lt;/code&gt;), and let Compose handle the scaling to fit your layout constraints&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Composable&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;WebpImageWithWorkaround&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;contentDescription&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;?,&lt;/span&gt;
    &lt;span class="n"&gt;modifier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Modifier&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Modifier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;AsyncImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ImageRequest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Builder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LocalContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Size&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ORIGINAL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;precision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Precision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;EXACT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;contentDescription&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;contentDescription&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;modifier&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;modifier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Fixing Scaling Quality Degradation
&lt;/h3&gt;

&lt;p&gt;The above workaround fixes the jitter, but there’s a catch: &lt;strong&gt;scaling quality can degrade on some devices&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;By decoding at &lt;code&gt;Size.ORIGINAL&lt;/code&gt;, scaling shifts to the draw path of &lt;code&gt;AnimatedImageDrawable&lt;/code&gt;, and the filtering quality can be noticeably worse. I tried a few common quality-tuning knobs, but none made a difference.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flstjjqgd1zipvtp4nijo.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flstjjqgd1zipvtp4nijo.webp" alt="Quality loss with first workaround" width="496" height="497"&gt;&lt;/a&gt;Quality loss with first workaround&lt;/p&gt;

&lt;p&gt;To fix the quality issue, render at original size into an offscreen layer first, then scale the layer output via GPU:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Composable&lt;/span&gt;
&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;WebpImageWithWorkaround2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;contentDescription&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;?,&lt;/span&gt;
    &lt;span class="n"&gt;modifier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Modifier&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Modifier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Box&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;modifier&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;modifier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scaledLayout&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;AsyncImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ImageRequest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Builder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;LocalContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;size&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Size&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ORIGINAL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;precision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Precision&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;EXACT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="n"&gt;contentDescription&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;contentDescription&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;modifier&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Modifier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;graphicsLayer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;compositingStrategy&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;CompositingStrategy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Offscreen&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="c1"&gt;// ContentScale and Alignment must now be handled manually in scaledLayout().&lt;/span&gt;
            &lt;span class="c1"&gt;// For simplicity, this example assumes ContentScale.Fit and Alignment.Center.&lt;/span&gt;
            &lt;span class="n"&gt;contentScale&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ContentScale&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;alignment&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Alignment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TopStart&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nc"&gt;Modifier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scaledLayout&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;layout&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;measurable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;constraints&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
    &lt;span class="c1"&gt;// Measure with unbounded constraints to ensure &lt;/span&gt;
    &lt;span class="c1"&gt;// the image is drawn at its original size.&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;placeable&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;measurable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;measure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Constraints&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;placeable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;placeable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="nd"&gt;@layout&lt;/span&gt; &lt;span class="nf"&gt;layout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;constraints&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;minWidth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;constraints&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;minHeight&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;scale&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;minOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;constraints&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;maxWidth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toFloat&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="n"&gt;placeable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;constraints&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;maxHeight&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toFloat&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="n"&gt;placeable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;scaledWidth&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;placeable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;width&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;roundToInt&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;scaledHeight&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;placeable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;height&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;roundToInt&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="nf"&gt;layout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scaledWidth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scaledHeight&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;placeable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;placeWithLayer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;scaleX&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;
            &lt;span class="n"&gt;scaleY&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;
            &lt;span class="n"&gt;transformOrigin&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TransformOrigin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;CompositingStrategy.Offscreen&lt;/code&gt;, the drawable renders into an offscreen buffer, and &lt;code&gt;scaledLayout()&lt;/code&gt; ensures it renders at original size by passing unbounded constraints. Then &lt;code&gt;placeWithLayer()&lt;/code&gt; applies GPU-accelerated scaling to the buffer output, bypassing &lt;code&gt;AnimatedImageDrawable&lt;/code&gt;'s low-quality draw-time scaling.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzuagq29ygw8c30xgzohd.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fzuagq29ygw8c30xgzohd.gif" alt="Left: Default AsyncImage (1px jitter). Right: Workaround applied (stable + clean)." width="800" height="400"&gt;&lt;/a&gt;Left: Default AsyncImage (1px jitter). Right: Workaround applied (stable + clean).&lt;/p&gt;

&lt;p&gt;In production, you may want to skip this workaround on Android 16+ where I couldn’t reproduce the issue.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trade-offs
&lt;/h2&gt;

&lt;p&gt;This solution uses somewhat more memory — you’re decoding at original size, plus maintaining a full-size offscreen canvas. It also adds GPU overhead from offscreen rendering and scaling. However, in my testing on lower-end devices, I didn’t observe any noticeable performance impact. It’s a reasonable trade-off for smooth animations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Repro Asset
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fdiskerr%2Fblog-resources%2Fmain%2Fjitter-sample.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fdiskerr%2Fblog-resources%2Fmain%2Fjitter-sample.webp" alt="Valid asset; jitter is scenario-dependent (non-integer scaling + partial frames)." width="800" height="800"&gt;&lt;/a&gt;Valid asset; jitter is scenario-dependent (non-integer scaling).&lt;/p&gt;

</description>
      <category>android</category>
      <category>kotlin</category>
      <category>mobile</category>
      <category>ui</category>
    </item>
    <item>
      <title>ImmutableList vs. List in Jetpack Compose: Rethinking “Best Practice” After Strong Skipping Mode</title>
      <dc:creator>Sutaek Oh</dc:creator>
      <pubDate>Wed, 21 Jan 2026 14:15:23 +0000</pubDate>
      <link>https://dev.to/brian_stark_09127/immutablelist-vs-list-in-jetpack-compose-rethinking-best-practice-after-strong-skipping-mode-55p0</link>
      <guid>https://dev.to/brian_stark_09127/immutablelist-vs-list-in-jetpack-compose-rethinking-best-practice-after-strong-skipping-mode-55p0</guid>
      <description>&lt;p&gt;Before &lt;strong&gt;Strong Skipping Mode&lt;/strong&gt; &lt;a href="https://developer.android.com/develop/ui/compose/performance/stability/strongskipping" rel="noopener noreferrer"&gt;[1]&lt;/a&gt; became the default, it was widely considered best practice in Compose to prefer &lt;strong&gt;immutable collections&lt;/strong&gt; (e.g., kotlinx &lt;code&gt;ImmutableList&amp;lt;T&amp;gt;&lt;/code&gt;) over &lt;strong&gt;unstable collections&lt;/strong&gt; like &lt;code&gt;List&amp;lt;T&amp;gt;&lt;/code&gt; for stability and better skipping behavior.&lt;/p&gt;

&lt;p&gt;With &lt;strong&gt;Strong Skipping Mode&lt;/strong&gt; now being the default, the situation has changed. In most real-world screens, immutable collections don't automatically outperform unstable ones - in many cases they provide little to no performance benefit, and can even become pure overhead due to conversion and equality costs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Case-by-Case Analysis
&lt;/h2&gt;

&lt;p&gt;To make this discussion concrete, I'll compare &lt;code&gt;ImmutableList&amp;lt;T&amp;gt;&lt;/code&gt; and &lt;code&gt;List&amp;lt;T&amp;gt;&lt;/code&gt; across three common cases.&lt;/p&gt;

&lt;p&gt;In most Android apps, data from Retrofit, Ktor, Room, or similar libraries usually comes as &lt;code&gt;List&amp;lt;T&amp;gt;&lt;/code&gt;. So using &lt;code&gt;ImmutableList&amp;lt;T&amp;gt;&lt;/code&gt; for Compose stability typically means calling &lt;code&gt;toImmutableList()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;With that in mind, this post assumes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;List&amp;lt;T&amp;gt;&lt;/code&gt; is not backed by a &lt;code&gt;MutableList&amp;lt;T&amp;gt;&lt;/code&gt; that is mutated from elsewhere.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ImmutableList&amp;lt;T&amp;gt;&lt;/code&gt; is created via &lt;code&gt;toImmutableList()&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Also, this post only discusses Compose skippability, not the architectural benefits of immutable collections.&lt;/p&gt;

&lt;h3&gt;
  
  
  Case 1. The list never changes (always the same instance)
&lt;/h3&gt;

&lt;p&gt;If your list never changes and you keep the exact same instance, &lt;code&gt;List&amp;lt;T&amp;gt;&lt;/code&gt; and &lt;code&gt;ImmutableList&amp;lt;T&amp;gt;&lt;/code&gt; behave almost the same in practice: both end up with the cost of an &lt;strong&gt;instance equality check&lt;/strong&gt; (&lt;code&gt;===&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Though &lt;code&gt;ImmutableList&amp;lt;T&amp;gt;&lt;/code&gt; still has an &lt;strong&gt;O(N) conversion cost&lt;/strong&gt; (e.g., &lt;code&gt;toImmutableList()&lt;/code&gt;), it's usually negligible since you do it only once in this case.&lt;/p&gt;

&lt;h3&gt;
  
  
  Case 2. The list instance changes only when the content actually changes
&lt;/h3&gt;

&lt;p&gt;In this scenario, using &lt;code&gt;ImmutableList&amp;lt;T&amp;gt;&lt;/code&gt; adds extra work: you pay a &lt;strong&gt;conversion cost&lt;/strong&gt; to build it, and you also pay an &lt;strong&gt;O(N) object equality cost&lt;/strong&gt; (structural equality, &lt;code&gt;==&lt;/code&gt; or &lt;code&gt;equals()&lt;/code&gt;) when Compose compares the new parameter to the previous one. Since you need to recompose anyway, that extra work is &lt;strong&gt;pure overhead&lt;/strong&gt; - and &lt;code&gt;List&amp;lt;T&amp;gt;&lt;/code&gt; avoids both costs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Case 3. The content doesn't change, but a new list instance is created frequently
&lt;/h3&gt;

&lt;p&gt;This is the one case where &lt;code&gt;ImmutableList&amp;lt;T&amp;gt;&lt;/code&gt; can actually help. If you keep recreating &lt;code&gt;List&amp;lt;T&amp;gt;&lt;/code&gt; instances with the same content, Compose will treat the parameter as changed because the &lt;strong&gt;instance equality&lt;/strong&gt; check fails. With &lt;code&gt;ImmutableList&amp;lt;T&amp;gt;&lt;/code&gt;, Compose can use &lt;strong&gt;object equality&lt;/strong&gt; to recognize "same content" and skip work higher up the composition tree.&lt;/p&gt;

&lt;p&gt;The catch is the cost: &lt;code&gt;equals()&lt;/code&gt; for lists is &lt;strong&gt;O(N)&lt;/strong&gt;, and you’re also paying the conversion cost upfront. If most of the UI work is already isolated in skippable leaf composables, these costs are sometimes harder to justify than just letting &lt;code&gt;List&amp;lt;T&amp;gt;&lt;/code&gt; flow down and relying on skipping at the leaf — especially with lazy composables like &lt;code&gt;LazyColumn&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And there's another catch: instance changes without content changes can sometimes be filtered upstream, turning Case 3 into Case 2 - for example, using &lt;code&gt;StateFlow&amp;lt;T&amp;gt;&lt;/code&gt; in your ViewModel. Details are in the &lt;strong&gt;Appendix&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So even in this case, &lt;code&gt;ImmutableList&amp;lt;T&amp;gt;&lt;/code&gt; isn't an automatic win.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;If your concern is skippability, there’s no need to convert &lt;code&gt;List&amp;lt;T&amp;gt;&lt;/code&gt; coming from your data sources — the old assumption that "unstable = always bad" no longer holds. Still, &lt;code&gt;ImmutableList&amp;lt;T&amp;gt;&lt;/code&gt; can sometimes be worth the overhead for better skippability — and it may offer architectural benefits beyond what this post covers. So if you’re already using it, there's no reason to switch back to &lt;code&gt;List&amp;lt;T&amp;gt;&lt;/code&gt; either. Don’t worry too much upfront. It's enough to optimize when you see a real bottleneck.&lt;/p&gt;

&lt;p&gt;Remember: not every composable should be skippable. &lt;a href="https://developer.android.com/develop/ui/compose/performance/stability/fix#not-composable" rel="noopener noreferrer"&gt;[2]&lt;/a&gt; And not every list needs to be &lt;code&gt;ImmutableList&amp;lt;T&amp;gt;&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Appendix. Case-by-Case Details
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Case 3–1. Lazy composables
&lt;/h3&gt;

&lt;p&gt;With lazy composables like &lt;code&gt;LazyColumn&lt;/code&gt;, it’s easy to overestimate &lt;strong&gt;what you save by skipping&lt;/strong&gt; higher up. Even if the parent recomposes, actual work is still bounded by the visible range, rather than scaling with the total list size. In that setup, paying O(N) for &lt;code&gt;equals()&lt;/code&gt; and conversion can outweigh the work saved by higher-level skipping.&lt;/p&gt;

&lt;h3&gt;
  
  
  Case 3–2. &lt;code&gt;StateFlow&amp;lt;T&amp;gt;&lt;/code&gt; and upstream filtering
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;StateFlow&amp;lt;T&amp;gt;&lt;/code&gt; has &lt;code&gt;distinctUntilChanged()&lt;/code&gt; built-in: if the new value equals the current one, it doesn't emit and keeps the existing instance. So if you call &lt;code&gt;update { it.copy(list = ...) }&lt;/code&gt; with the same content and nothing else changed, &lt;code&gt;StateFlow&amp;lt;T&amp;gt;&lt;/code&gt; won't emit - the original list instance stays intact. This effectively turns Case 3 into Case 2. Thus, in this scenario, &lt;code&gt;List&amp;lt;T&amp;gt;&lt;/code&gt; saves a conversion cost and an object equality check cost as described in Case 2.&lt;/p&gt;

&lt;p&gt;Note: &lt;code&gt;StateFlow&amp;lt;T&amp;gt;&lt;/code&gt; doesn't eliminate Case 3 entirely. For example, if you call &lt;code&gt;update { it.copy(list = newInstance, isLoading = false) }&lt;/code&gt; and only &lt;code&gt;isLoading&lt;/code&gt; actually changed, &lt;code&gt;StateFlow&amp;lt;T&amp;gt;&lt;/code&gt; will emit because &lt;code&gt;T.equals()&lt;/code&gt; returns false - and the new list instance gets delivered anyway.&lt;/p&gt;

</description>
      <category>android</category>
      <category>kotlin</category>
      <category>androiddev</category>
    </item>
  </channel>
</rss>
