← Back to list

Reduce your memory footprint with Coil (by quite much)

Introduction

JiaHao Liu Liu · 2026-04-03 20:01 · 4 claps · 8.1 min read
#coils #android #outofmemoryerror #oom
Open on Medium ↗

Reduce your memory footprint with Coil (by quite much)

Introduction

Coil is a wonderful library to load the images, both from backend as from the local storage. The main problem when an application with a lot of images is that those images goes to the memory cache and it could fill up it quite fast.

One common misunderstanding from many developers is that the size of the image downloaded from the backend is the same as the one stored in the memory.

Take this images for example:

Proxyman request

Proxyman request

As it is shown on the right-bottom corner, the image is 1400 x 800, and it uses 443 KB of memory.

Image size

Image size

Original photo (ARGB 8888)

Original photo (ARGB 8888)

But if we load this image into he app and we check the memory cache, we see something different:

Default (ARGB 8888) coding

Default (ARGB 8888) coding

So, if we do the math, 1400 x 800 x 4 Bytes = 4.480.000 Bytes, which is about 4.48 MB. This is very close to the space shown on the heap dump.

Optimization 1 — RGB 565

A simple optimization we can do is use different quality images. Instead of using the format ARGB_8888 that uses 4 bytes per pixel, we can use RGB 565 which uses 2 bytes instead.

This could be done through the general ImageLoader

class MyApplication: Application(), SingletonImageLoader.Factory {
    override fun newImageLoader(context: PlatformContext): ImageLoader {
        return ImageLoader(this).newBuilder()
            .bitmapConfig(Bitmap.Config.RGB_565)
            .build()
    }
}

Or per each one of the image requests

AsyncImage(
    model = ImageRequest
        .Builder(LocalContext.current)
        .data(imageUrl)
        .crossfade(true)
        .bitmapConfig(Bitmap.Config.RGB_565)
        .build(),
    contentDescription = null,
    placeholder = painterResource(R.drawable.coillogo),
    error = painterResource(R.drawable.coillogo),
)

The result is a 50% reduction on the memory footprint

Original photo (RGB 565)

Original photo (RGB 565)

Now, note that this is not the magic solution. RGB 565 allocates the bits in the format of RRRRR GGGGGG BBBBB , so 5 bits for Red, 6 bits for Green and 5 bits for Blue (hence RGB 565). The color palette is of 32×64×32 = 65,536 colors (high color). And it does not supports transparency. While the default fromat allocates the bits in format of AAAAAAAA RRRRRRRR GGGGGGGG BBBBBBBB , that’s 8 bits for Alpha, 8 bits for Red, 8 bits for Green and 8 bits for Blue (hens ARGB 8888). It supports 16.7 million colors (true color).

My advice here is use it for low memory devices and logos.

For more information about RGB 565, here is the wiki.

Optimization 2 — Fitting the view windows

One interesting effect I see is by default, for the same items in a row, the memory footprint might different. Let’s say I have a row of 5 items, with different size. Since we have the same view windows, the logic thing is each one of the images uses the same space in the memory cache.

Let’s see. Here is the basic code

@Composable
private fun ItemRow(
    modifier: Modifier = Modifier,
    imageUrl: String,
    contentScale: ContentScale,
) {
    Box(
        modifier = modifier
            .fillMaxWidth()
            .padding(top = 8.dp, bottom = 8.dp)
            .height(128.dp)
    ) {
        AsyncImage(
            model = ImageRequest
                .Builder(LocalContext.current)
                .data(imageUrl)
                .crossfade(true)
                .build(),
            contentDescription = null,
            placeholder = painterResource(R.drawable.coillogo),
            error = painterResource(R.drawable.coillogo),
            contentScale = contentScale,
        )
    }
}

Here are the sample images:

  1. Coffee (1328x1000) JPEG 537 KB

Coffee (1328x1000) JPEG 537 KB

Coffee (1328x1000) JPEG 537 KB

  1. Cookies (1594x1200) JPEG 754 KB

Cookies (1594x1200) JPEG 754 KB

Cookies (1594x1200) JPEG 754 KB

  1. Pastel de Belén (1328x1000) JPEG 279 KB

Pastel de Belén (1328x1000) JPEG 279 KB

Pastel de Belén (1328x1000) JPEG 279 KB

  1. Sagrada familia (1054x1400) JPEG 635 KB

Sagrada familia (1054x1400) JPEG 635 KB

Sagrada familia (1054x1400) JPEG 635 KB

  1. Snow (1328x1000) JPEG 643 KB

Snow (1328x1000) JPEG 643 KB

Snow (1328x1000) JPEG 643 KB

Crop

Images cropped

Images cropped

Note the fource image does not fit on the view windows because the width on the device is bigger than the width of the image.

This is the content of the memory:

The first item in the list has a memory footprint of 2.951.201 KB,

The size is 1054 x 1400 , which with RGB 565 enabled, makes it 2 bytes per pixel, which is 2.951.200 bytes.

On the preview it shows it is the fourth image:

For the second till fifth image in the list, the image footprints are consistenly 2.346.241 bytes. The real size of the image is 1248 x 940 pixels, with 2 bytes per pixel, it makes the 2.346.240 bytes.

The last image is the placeholder, which since all the images uses the same one, only one of them is kept in the memory. The size is 800 x 400, but 1.280.059 / (800x400) = 4,00018438, which is roughly 4 bytes. It is not too much change but it is good to keep in mind that Coil is using ARGB 8888 for this image instead.

The total size is 2.951.201 + 2.346.241 * 4 + 1.280.059 = 13.616.224 KB. About 13.61 MB.

Fit

It scales the content uniformly, making sure the whole image is visible. The final result could have the dimensions smaller than the original one, leaving some white spaces if the image does not fit.

Noted the images have been scaled down by coil to adopt the existing view ports. For instance, the biggest image before, the fourth image (Sagrada familia), now is the smallest one, with the size of 289 x 384, which with 2 bytes per pixel, its memory footprint is 221.952 bytes.

Now the biggest image is the place holder image, which remains 1.280.059 bytes.

Inside

If the source is larger than the destination, Inside scales the source to maintain the aspect ratio to be inside the destinatin bounds. Otherwise, it will behave similar to None .

In this case, the memory footprint of the images is the same as Fit

Fill width

It scales the source maintaining the aspect ratio, so the bounds will match the destination width.

In this case it looks the same as Crop , with the same memory footprints.

Fill height

Fill height is the counter part of Fill width , but instead of width, it will scale the image as much as to fill the height.

The interesting about the result is it looks like the same as Fit or Inside , but with the same memory footprint as Crop (Bigger memory footprint than Fit and Inside )

Fill bounds

This option will stretch the content non-uniformly to fill all the bounds.

The images looks bad and the memory footprints are big.

None

This option won’t do any scale to the source, scaling the content of 1.0.

It looks like Crop , but with much bigger memor footprints.

Solution 1

As it is shown above, both Crop and Fill width have the “right” look for the photos, with the same memory footprint. But Fill width depends on the image ratio: If an image does not have the right ratio, maybe Fill height is more suitable for this case.

Another important note here is even part of the image is shown, Coil does not actually cut the image according to the view port. The whole image is stored in the memory.

A easy soluton will be cut the images to the ratio we want to shown.

(Please ignore the change on the image glare. That’s a problem on resizing the images to jpg in mac)

The memory content for crop is much smaller:

Original size: 2.951.201 + 2.346.241 * 4 + 1.280.059 = 13.616.224 KB

Current size: 1.280.059 + 1.078.331 + 1.043.387 + 978.171 + 971.003 + 960.059 = 6.311.010 KB

which is 46.35% of the original size, more than half reduction.

Solution 2 (By AI)

AI provided some solutions

  • Added size hints to ImageRequest (400×128 pixels at display time)
  • Implemented LocalDensity to calculate accurate display dimensions
val density = LocalDensity.current
// Calculate display size in pixels (128.dp height, full width minus padding)
val displayHeightPx = with(density) { 128.dp.toPx().toInt() }
// Estimate width (full width, assuming standard screen sizes)
val displayWidthPx = with(density) { 400.dp.toPx().toInt() } // Conservative estimate
Implemented LocalDensity to calculate accurate display dimensions
  • Improved composable to load only the exact size needed
model = ImageRequest
    .Builder(LocalContext.current)
    .data(imageUrl)
    // Size hint: Load image at exact display dimensions to reduce memory
    .size(displayWidthPx, displayHeightPx)
    .crossfade(true)
    .build(),

Here is the result:

But the memory footprint didn’t get reduced (too much). The solution 1 still provides a better reduction. Potentially we can combine the solution 1 and 2.

Other general optimizations

  • Disabled strong references to enable immediate garbage collection strongReferencesEnabled(false)
  • Added network cache policy for persistent caching networkCachePolicy(CachePolicy.ENABLED)
class MyApplication : Application(), SingletonImageLoader.Factory {
    override fun newImageLoader(context: PlatformContext): ImageLoader {
        return ImageLoader(this).newBuilder()
            // Memory cache: Keep minimal in-memory cache
            .memoryCachePolicy(CachePolicy.ENABLED)
            .memoryCache {
                MemoryCache.Builder()
                    .maxSizePercent(this, 0.05) // Reduced from 0.1 (10%) to 0.05 (5%)
                    .strongReferencesEnabled(false) // Allow GC to collect bitmaps
                    .build()
            }
            // Disk cache: Use disk storage to reduce memory pressure
            .diskCachePolicy(CachePolicy.ENABLED)
            .diskCache {
                DiskCache.Builder()
                    .maxSizePercent(0.05)
                    .directory(cacheDir)
                    .build()
            }
            // Bitmap config: Use RGB_565 instead of ARGB_8888 to save 50% memory per pixel
            .bitmapConfig(Bitmap.Config.RGB_565)
            // Network cache policy: Read from cache first, write to cache always
            .networkCachePolicy(CachePolicy.ENABLED)
            .logger(DebugLogger())
            .build()
    }

Final Conclusion

As the readers can see, by reducing the image coding from ARGB 8888 to RGB 565 , the image size could be reduced to 50%. And by cutting the background image to scale the view port, it reduces the memory footprint of the images further.

Source code

https://github.com/jiahaoliuliu/CoilImageCaching/tree/memoryOptimization

Inspiration

ProAndroidDev — Measure and optimize bitmap size using Glide or Picasso (Link)


메타데이터
post_id
d93da5632e98
slug
reduce-your-memory-footprint-with-coil-by-quite-much-d93da5632e98
url
https://medium.com/@jiahaoliuliu/reduce-your-memory-footprint-with-coil-by-quite-much-d93da5632e98
canonical_url
https://medium.com/@jiahaoliuliu/reduce-your-memory-footprint-with-coil-by-quite-much-d93da5632e98
author_url
https://medium.com/@jiahaoliuliu
status
ok
fetched_at
2026-06-12 18:14:10