Skip to content
Go back

Android Image Loading: OOM, Downsampling, and the Bandwidth Problem

Android Image Loading Pipeline and Downsampling

Loading images in a RecyclerView sounds straightforward — fetch a URL, show the image. But without an image-loading library, that simple task involves a chain of steps where memory errors, slow performance, and frozen UIs are all waiting to happen. Understanding the pipeline is what separates writing code that works from writing code that scales.


The Naive Approach and Its Problems

A basic implementation without any library looks something like this:

// Inside RecyclerView.Adapter onBindViewHolder — pseudocode
val url = item.imageUrl

// Background thread: download file
val file = downloadToFile(url, destinationPath)

// Background thread: decode file into bitmap
val bitmap = BitmapFactory.decodeFile(file.path)

// Main thread: set on ImageView
imageView.setImageBitmap(bitmap)

This code is technically correct but has three serious problems in practice:

ProblemCause
Out of Memory (OOM)Full-resolution bitmap loaded into RAM
Slow loadingDownload + decode repeated every scroll
UI freezeHeavy work leaking onto the main thread

Libraries like Glide solve all three, but knowing how they solve them requires understanding the pipeline first.


The Image Loading Pipeline

Every image goes through exactly two steps before it can appear on screen:

Step 1 — Download (~5 seconds)

The image file (e.g. profile.png) lives on a remote server. Your device downloads it over the network and saves it to local storage — the internal file system or SD card. At this point you have a file, not something you can display.

Step 2 — Decode (~2 seconds)

A file cannot be rendered directly. Android needs a Bitmap — a two-dimensional array where each cell holds the colour value of one pixel. The process of reading a file and constructing that array is called decoding. BitmapFactory.decodeFile() does this work.

Only after decoding can the bitmap be handed to an ImageView.

Total cold-load time without caching: ~7 seconds. For a RecyclerView with dozens of images, that is unacceptable.


What Is a Bitmap?

A bitmap is literally a grid of pixels. An image that is 500×500 pixels is a 500-row, 500-column array where each cell stores a colour (typically 4 bytes: red, green, blue, alpha).

500 × 500 × 4 bytes = ~1 MB in RAM

A 5000×5000 image at the same colour depth:

5000 × 5000 × 4 bytes = ~100 MB in RAM

That single image could consume more than half the typical app memory budget.


The OOM Problem

A server stores high-resolution originals — often 5000×5000 pixels or larger. Your device has an ImageView that is 500×500 pixels. Loading the full 5000×5000 image and then displaying it in a 500×500 slot means you used 100× more memory than necessary.

Load 10–15 such images in a RecyclerView simultaneously and the app crashes with OutOfMemoryError.


Solution: Downsampling

Downsampling reduces the image to match the actual display size before loading it into RAM. The quality remains perfectly adequate because you are displaying at the target resolution — there is no information lost that the user would ever see.

How it works conceptually: when an image is sampled down, the decoder averages groups of neighbouring pixels into a single pixel. A 3×3 block of red pixels becomes one red pixel. The visual result is identical at display size.

Android provides BitmapFactory.Options for this:

fun decodeSampledBitmap(filePath: String, targetWidth: Int, targetHeight: Int): Bitmap {
    // First pass: measure dimensions without loading pixels into memory
    val options = BitmapFactory.Options().apply {
        inJustDecodeBounds = true
    }
    BitmapFactory.decodeFile(filePath, options)

    // Calculate the appropriate inSampleSize
    options.inSampleSize = calculateSampleSize(options, targetWidth, targetHeight)
    options.inJustDecodeBounds = false

    // Second pass: decode with downsampling applied
    return BitmapFactory.decodeFile(filePath, options)
}

fun calculateSampleSize(options: BitmapFactory.Options, targetW: Int, targetH: Int): Int {
    val (rawW, rawH) = options.outWidth to options.outHeight
    var sampleSize = 1
    while (rawW / sampleSize > targetW || rawH / sampleSize > targetH) {
        sampleSize *= 2
    }
    return sampleSize
}

inSampleSize = 10 means decode at 1/10th width and 1/10th height → 1/100th the memory. A 5 MB bitmap becomes 0.05 MB.

Glide handles this automatically — it reads the ImageView dimensions before decoding and applies the correct inSampleSize for you.


The Bandwidth Problem

Downsampling on the device fixes the OOM problem but does not fix bandwidth waste. The sequence is:

Server sends 5 MB  →  device downloads 5 MB  →  device scales to 0.5 MB

The user paid 5 MB of data transfer to display a 0.5 MB image. On mobile data, especially in regions with expensive or slow networks, this matters.

Client-Side (Most Apps)

Most companies accept this trade-off. Server stores one high-resolution original; clients download it and downsample locally. Simple to implement, costs users bandwidth.

Server-Side CDN (Large Companies)

A CDN service like Akamai can serve pre-scaled images:

  1. Client requests an image and sends its required dimensions (e.g. 500×500).
  2. Akamai checks its cache for a 500×500 version.
  3. If cached, it returns the small image immediately.
  4. If not, it fetches the original from the source, scales it to 500×500, caches that version, and returns it.

The user downloads only 0.5 MB instead of 5 MB. But Akamai and similar services are expensive — typically only large companies like Facebook, Instagram, or Netflix use them at scale.


What Glide Handles for You

The naive code at the top of this post requires you to implement all of this manually. Glide does it in one line:

Glide.with(context)
    .load(imageUrl)
    .into(imageView)

Internally, Glide:

The next two posts cover those last two points in detail: how unwanted task cancellation works, and how the two-level caching strategy is built.


Share this post on:

Previous Post
Why Images Load Slowly in RecyclerView and How Glide Fixes It with ViewTreeObserver
Next Post
Dagger 2 Fundamentals: Module, Component, Scope, and Qualifier Explained