How to Design an Image Loading Library in Android - LRU CACHE

Every Android app needs fast and efficient image loading—whether it’s for a social media feed, an e-commerce catalog, or a news app. We often use libraries like Glide, Picasso, or Coil, but have you ever thought about how they work under the hood?
Imagine you’re asked in a system design interview: (Most Commonly Asked)
💡 “Design an image loading library from scratch.”
Sounds exciting, right? Let’s break it down! 👇
The Problem – Why Do We Need a Custom Image Loader?
Downloading and displaying images might seem simple, but it comes with challenges:
Slow Loading – Downloading large images blocks the UI.
Network Overhead – Fetching the same image multiple times wastes bandwidth.
Memory Issues (OOM Errors) – Unoptimized images kill your app.
Scrolling Lag – If images are not efficiently loaded, RecyclerView stutters.
A well-designed image loading library should solve these problems efficiently.
High-Level Architecture of an Image Loader
A modern image loader consists of four major components:
1. Request Manager – Handles API calls and queues requests.
2. Cache Layer – Stores images in memory & disk to avoid re-downloading.
3. Decoding & Transformation – Resizes, crops, or applies filters to images.
4. UI Renderer – Efficiently loads images into an ImageView without blocking the UI.
Before moving ahead, kindly join android whatsapp community
https://chat.whatsapp.com/JGIQRz0RRyhIrEe8oNfHYR
Get follow our page TheCodingShef
https://www.linkedin.com/company/thecodingshef/
Let’s build this step by step! 🏗️
📡 Step 1: Image Downloading (Fetcher Layer)
Before caching, we need to download images efficiently.
✅ Use OkHttp for fast & optimized network calls.
✅ Implement request deduplication (Avoid multiple calls for the same URL).
✅ Support timeouts & retries to handle slow networks.
fun fetchImage(url: String): Bitmap? {
val request = Request.Builder().url(url).build()
val response = OkHttpClient().newCall(request).execute()
return BitmapFactory.decodeStream(response.body?.byteStream())
}Optimization Tip: Support different formats (WebP, PNG, JPEG) to reduce file size.
Step 2: Implementing a Dual Cache System (Memory + Disk)
Caching is the key to speed!
🔹 Memory Cache (RAM) – Fastest access, but limited space.
🔹 Disk Cache (Storage) – Slower but persistent across app restarts.
Memory Cache using LruCache (Least Recently Used Eviction)
val memoryCache = LruCache<String, Bitmap>(cacheSize)
fun getFromMemoryCache(url: String): Bitmap? = memoryCache.get(url)
fun putInMemoryCache(url: String, bitmap: Bitmap) = memoryCache.put(url, bitmap)Disk Cache using File Storage
fun getFromDiskCache(url: String): Bitmap? {
val file = File(cacheDir, url.hashCode().toString())
return if (file.exists()) BitmapFactory.decodeFile(file.path) else null
}
fun putInDiskCache(url: String, bitmap: Bitmap) {
val file = File(cacheDir, url.hashCode().toString())
FileOutputStream(file).use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
}Optimization Tip: Use LRU eviction to remove the least-used items when memory is full.
Step 3: Image Decoding & Transformation
Images should be optimized for UI.
✅ Resizing – Load only the necessary resolution (avoid full-size images).
✅ Caching Transformed Images – Don’t reprocess the same image repeatedly.
✅ Lazy Loading – Load only when required (use RecyclerView ViewHolders).
fun decodeAndResize(image: Bitmap, width: Int, height: Int): Bitmap {
return Bitmap.createScaledBitmap(image, width, height, true)
}Optimization Tip: Store multiple resolutions in cache for different screen sizes.
Step 4: Rendering Efficiently into ImageView
Once we have an image (from cache or network), we bind it to an ImageView without blocking the UI thread.
✅ Use Coroutines or Executors to load images asynchronously.
✅ Prevent UI Flickering by showing placeholders while loading.
✅ Cancel Unused Requests when the user scrolls away (important for RecyclerView).
fun loadImage(url: String, imageView: ImageView) {
val cachedBitmap = getFromMemoryCache(url) ?: getFromDiskCache(url)
if (cachedBitmap != null) {
imageView.setImageBitmap(cachedBitmap)
} else {
CoroutineScope(Dispatchers.IO).launch {
val bitmap = fetchImage(url)
bitmap?.let {
putInMemoryCache(url, it)
putInDiskCache(url, it)
withContext(Dispatchers.Main) { imageView.setImageBitmap(it) }
}
}
}
}Optimization Tip: Avoid blocking the UI thread by running operations in the background.
Bonus: Advanced Optimizations
🔹 Preloading & Prefetching – Load images before they are needed.
🔹 Request Deduplication – If the same image is requested multiple times, return the same Future instead of re-downloading.
🔹 Adaptive Caching – Store high-res images for large screens, low-res for small screens.
🔹 Glide-Like API Support – Provide a simple API:
ImageLoader.load(url).placeholder(R.drawable.loading).into(imageView)🎯 Final Workflow – How It All Comes Together
Step 1: Check if the image exists in memory cache (LruCache).
Step 2: If not found, check disk cache (DiskLruCache).
Step 3: If not cached, fetch from the network (OkHttp).
Step 4: Resize & cache the image in memory & disk for future use.
Step 5: Render the image efficiently in ImageView.
🚀 Final Thoughts – Why Build Your Own Library?
While Glide, Picasso, and Coil do an amazing job, building an image loading library helps you:
✅ Understand how caching & memory management works.
✅ Optimize performance for specific use cases.
✅ Handle custom transformations & rendering.
Wait for next Article where i will explain whole code. How you like this article. Let me know in the comments! 👇
Comments (0)
No comments yet. Be the first to share your thoughts!
Leave a Comment
Get the latest updates
Join our newsletter and never miss an article.