🧪 I Built My Own Android Image Loader to Understand Glide’s Magic — Here’s What I Learned
If you’ve ever worked on an Android app that loads images (I know, we all have at-least loaded a single image in an application), chances are you’ve pulled Glide, Picasso, or Coil to load images effortlessly. These libraries abstract away the pain of loading, caching, and decoding images and reduce memory usage and jank.
But guess what? I wanted to dig deeper.
I wondered, what actually happens behind the scenes?
How do these libraries manage to load high-resolution images, reuse memory, downsample bitmaps, and still avoid OutOfMemoryErrors?
So I decided to build my own image loader. [Simple, no high-tech]
No Glide. No Coil. No magic.
Just raw Android SDK, bitmaps, and a desire to learn.
🎯 Why I Did This
I wasn’t trying to reinvent the wheel guys. I was just trying to understand how the wheel works.
Here’s what I wanted to figure out:
- What happens if I don’t downsample images?
- Can I reuse bitmaps across views?
- How much memory do I save by canceling background image loads?
- What kind of GC events get triggered when I scroll through a ton of images?
To answer these, I built a small benchmarking app that loads images from the internet using a custom pipeline with no third-party libraries involved.
🛠️ How I Built the Benchmarking App
The core idea was simple:
- Load a bunch of images in a RecyclerView
- Toggle optimization flags like bitmap reuse, downsampling, and task cancellation
- Measure things like memory usage, network traffic, GC events, and scroll performance
Here’s a glimpse of the flags I used:
data class BenchmarkConfig(
val usePool: Boolean,
val useDownsampling: Boolean,
val useCancellation: Boolean
)Each benchmark run would try out a different combination, from “do absolutely nothing smart” to “do everything smart”.
🌍 Image Mix I Used
To stress test my loader, I used a bunch of images from Picsum Photos:
- Huge landscape: 2076x966
- Tall portrait: 1200x1800
- Perfect square: 1500x1500
- Weird, Unconventional ones: 200x2000 and 2000x200
Then I duplicated and shuffled them 30 times to simulate a feed with variety.
📊 What I Measured
I wanted to look at hard numbers not just “it feels smoother” than before.
So I tried to track:
- Time taken to scroll through all items
- Images successfully loaded
- Bitmaps reused from the pool
- Tasks cancelled during fast scrolls
- Network data used (via TrafficStats)
- Memory usage before/after
- GC events (blocking and background)
A typical benchmark log looked like:
🔁 Automated Benchmarking Flow
I even automated the scroll and load flow:
- Wait until the first few images were visible
- Scroll to the middle
- Scroll to the bottom
- Wait for the remaining images to load
- Dump and log the stats
This made sure every test ran in the same conditions.
🧵 My Image Loading Flow
Note that, I am not using any image-loading libraries here.
For each item in the RecyclerView, I submitted a task to a thread pool (didn’t want to start 300–400 standalone threads) to load and decode the image manually.
📉 Baseline Case: No Pooling, No Downsampling, No Cancellation
Before going to optimizations, lets see what is our current status, without any optimizations.
This is the worst-case scenario and serves as our baseline for comparison.
Images are fully decoded at original sizes, memory use spikes, and all background tasks run to completion regardless of scroll. Expect high native heap usage, huge network data consumption, and long load times.
Lets Try Optimizing Now
My pipeline looked like this:
- Check for cancellation if the view was recycled
- Fetch and decode the image using BitmapFactory
- Downsample based on the ImageView size (if enabled)
- Try reusing an old bitmap from a custom pool (if enabled)
- Update the UI only if the view is still bound to the same image
Let’s look at some parts of that in more detail.
♻️ Bitmap Reuse (DIY Style)
Android allows decoding bitmaps into existing memory buffers if you pass a mutable bitmap into options.inBitmap.
So I built a small LRU-based pool, keyed by size and config:
fun getReusableBitmap(width, height, config): Bitmap?If I had a reusable bitmap, I passed it to the decoder. Otherwise, it allocated a new one.
Lesson: Reusing bitmaps works great if the dimensions and config match exactly. Otherwise, decodeStream() throws.
🪄 Downsampling: A Hidden Hero
By default, if you decode a 2076x966 image, we see that Android allocates that full size in memory, even if your ImageView is 400x200. Crazy, right?
To fix that, I computed an inSampleSize:
val sampleSize = calculateInSampleSize(width, height, targetWidth, targetHeight)This drastically reduced memory usage and GC spikes.
Pooling + Downsampling: Speed Meets Efficiency
Bitmap pooling reduces allocation overhead by reusing existing memory, and downsampling ensures that each decode uses only as much memory as necessary.
Get Raja Bhandari’s stories in your inbox
Join Medium for free to get updates from this writer.
When combined, these two strike a solid middle ground between raw speed and memory efficiency. While not as lean as the full-optimized combo, it’s a great config for medium-speed scrolling or prefetch-heavy scenarios.
🔍 Insights from the Result:
With 8 bitmaps reused and ~9 MB of data saved compared to the unoptimized case, this config brings noticeable improvements. It’s a good reminder for us that optimization isn’t always about one thing, but often about combining multiple techniques.
Lesson: Always downsample. It’s a free performance win.
🚫 Cancellation Matters More Than You Think
One of the worst things you can do in an image loader: keep loading images even after they scroll off-screen.
I tracked the current task for each ViewHolder. If the view was recycled, I canceled the task using Future.cancel(true) and avoided updating the UI.
Downsampling + Cancellation: A Lean Combo
Downsampling alone helps reduce memory pressure by scaling images before decoding them. Cancellation, on the other hand, prevents wasted work by skipping images that scroll off-screen.
But what if we combine both?
This combo is incredibly effective. You’re loading smaller images and only when they’re still needed. In one test, I went from ~70 MB of data usage down to just 2.9 MB, with memory footprint dropping to nearly half compared to the baseline.
🔍 Insights from the Result:
This combination slashes both data and memory usage. It shows how cancellation paired with downsampling leads to lean, efficient image loading.
Lesson: Without cancellation, you’re wasting CPU, network, memory & just hammering the GC. Especially during fast flings.
🏆 The Best of All Worlds: Pooling + Downsampling + Cancellation
What happens when you enable all three optimizations?
You get the smoothest, leanest, most efficient image loading pipeline possible.
In this configuration, my loader reused 6 bitmaps, canceled 287 tasks, and consumed just 2.12 MB of network data. All these while keeping memory usage comfortably low at around 38 MB native heap. The scroll felt buttery smooth, and GC logs barely spiked.
🔍 Insights from the Result:
This is the gold standard. Smart reuse (bitmap pool), intelligent decoding (downsampling), and precise execution (task cancellation) working together. It’s exactly what libraries like Glide do under the hood & now I truly understand why.
🔍 What I Learned
✅ Bitmap Pooling Works, Sure; But Only If You’re Careful
- Make sure your bitmaps are mutable
- Don’t reuse recycled ones
- Match dimensions and config
- Use an LRU strategy to evict and recycle old bitmaps
✅ Downsampling Saves Your App
- Prevents huge memory allocations
- Makes decoding faster
- Reduces GC churn
✅ Cancelling Tasks = Less Garbage, More Speed
- Avoids unnecessary decoding
- Saves memory and CPU
- Makes your app feel smoother on scroll
✅ You Can Track GC and Memory in Real-Time
I used:
Debug.getRuntimeStat(“art.gc.blocking-gc-count”)…and other similar APIs to track what’s happening during scroll. It’s incredibly eye-opening to see GC counts and time per run.
Summarizing the tests
The following table summarizes the benchmarking results across all tested configurations, showcasing the effect of bitmap reuse (pooling), downsampling, and task cancellation on performance metrics:
😩 Challenges I Faced
- Some devices return ImageView.width = 0 before layout. I used a fallback of 500x300 (This problem can be better solved using ViewTreeObserver.OnGlobalLayoutListener, and also doOnLayout in kotlin)
- Early on, I forgot to verify that the imageView was still bound to the right URL before updating the UI.
So, what happened? Some images appeared in the wrong cells for a few milliseconds. - Didn’t recycle bitmaps early on. It caused GC storms
- I initially thought I could reuse any bitmap from my pool, plug it into BitmapFactory, and move on. But No!
Android’s decoder throws a nasty exception if the inBitmap passed doesn't exactly match the size and Bitmap.Config of the image being decoded. - Forgot inMutable = true once and drum-rolls!! boom, crash.
All were great reminders of how much Glide actually does for us.
At a Glance: What Libraries Like Glide Does by Default
After building everything from scratch, I now appreciate what Glide, Coil, and others quietly handle:
- ✅ Memory + disk caching [Planning to add this next to my current setup]
- ✅ Safe bitmap reuse with pooling
- ✅ Lifecycle-aware task cancellation
- ✅ Auto downsampling to match view size
- ✅ Request deduplication
These features solve real problems I ran into while doing it manually. Now I truly understand the value they bring to the table.
❤️ Why This Was Worth It
Sure, it took time.
Sure, I could’ve just used Glide and called it a day.
But now, I actually understand what happens when you scroll through a feed of high-res images, and why Glide’s magic is so… so magical!
🙌 Should You Build Your Own?
No, unless you want to learn.
For production: use Glide, Coil, or Picasso.
They handle things like lifecycles, caching, memory, threading and edge cases way better than a DIY loader ever could.
But if you’re curious, bored, or just enjoy a good challenge:
Build your own. Even a very low-tech one; Even a janky one.
You’ll walk away with a new appreciation for image loading, memory management, and the Android platform in general.
🧠 Final Thoughts
This little experiment taught me a lot about:
- Memory
- Bitmaps
- GC behavior
- Optimization tradeoffs
And most importantly: how to measure real performance instead of guessing.
You can checkout the experiment project repository here and provide some feedback: https://github.com/IamRaja007/BitmapPoolLearnings
I hope it gets your curious side started too.
Thanks for reading till the end & feel free to share your own experiments!









