Mobile Core Concepts
Image Loading Internals
Why loading an image is a memory and decode problem, not a network call - and the pipeline decisions that keep a thousand-cell grid from OOMing a mid-range device.
Every image on Android passes through the same decode boundary: a compressed stream from disk or network feeds into Skia's JPEG, PNG, WebP, or HEIC decoder, which fills a Bitmap's pixel buffer in some chosen Bitmap.Config. The cost of that decode - heap, GC pressure, GPU upload, time - depends on choices made at that boundary: which API parses the bytes (BitmapFactory vs ImageDecoder), where the resulting pixels live (Java heap vs native vs graphics memory), and which sub-sampling and color-space rules apply. This page covers the platform substrate that every Android image loader is built on: the two-pass decode contract, bitmap memory accounting across the API 26 inflection, ImageDecoder's replacement of BitmapFactory, EXIF and color-space handling, and the timing rules around OnPreDrawListener and RecyclerView recycling that show up in every loader's hot path. The library-design layer on top of this (cache ladder sizing, in-flight dedup, bitmap pooling, transformation pipelines) lives in the Image Loader Library system-design problem; the frame pipeline downstream of the decoded bitmap lives in How Rendering Works.
The cache ladder
Every image request runs the same four stages, short-circuiting at the first cache hit: memory cache, disk cache, network fetch, decode. The cache key is not the URL alone - it is (url, target-dimensions, transformations), so a 200x200 thumbnail and a 1080p preview of the same image are independent entries.
The library-design details (memory cache structure, disk cache schema, in-flight dedup, bitmap pooling) live in the Image Loader Library system-design page. For the platform-substrate purposes of this page, two facts about the ladder are essential:
- The memory cache holds decoded bitmaps; the disk cache holds compressed bytes. Disk fits ~10x more entries by keeping originals encoded; decode is cheap on disk hits.
- Glide ships a second weak-reference tier above the memory LRU (currently-displayed bitmaps share one
Bitmapinstance across views); Coil's memory cache is a single LRU. Both designs work; Glide's saves allocations when many cells share an image, Coil's is simpler and avoids weak-reference GC roundtrips.
Either way, the library owns the bitmap's lifetime through the LRU. Stashing the decoded Bitmap in a field on a long-lived object pins the heap allocation and defeats the LRU - hand the result to the view via setImageBitmap and let the loader manage it.
Sources: Coil caching docs · Glide caching docs
OOM prevention - two-pass decode
This is the core trick. BitmapFactory.decodeStream(stream) allocates width * height * bytesPerPixel bytes immediately - for a 4000x3000 JPEG at ARGB_8888 that is 48 MB. You cannot afford that for one image, never mind a grid of them. The fix is to decode twice.
// Pass 1: read dimensions, allocate zero pixels.
val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeStream(stream, null, opts)
// Largest power of 2 such that the decoded size is still >= target.
var sample = 1
while (opts.outWidth / (sample * 2) >= targetW &&
opts.outHeight / (sample * 2) >= targetH) sample *= 2
// Pass 2: decode at the reduced size.
opts.inJustDecodeBounds = false
opts.inSampleSize = sample
opts.inPreferredConfig = Bitmap.Config.RGB_565 // 2 bytes/pixel for opaque images
val bitmap = BitmapFactory.decodeStream(stream2, null, opts)The first pass with inJustDecodeBounds = true reads only the file header - the decoder fills in opts.outWidth and opts.outHeight and returns null without allocating a pixel buffer. The cost is essentially a few syscalls and a header parse.
Any non-power-of-2 value is rounded down to the nearest power of 2 by the decoder; values <= 1 are treated as 1. A factor of 4 means a 4000x3000 image is decoded as 1000x750 directly by the decoder - the full-resolution bitmap never exists in memory. The pixel reduction is quadratic: subsample 4 on each axis is 16x less RAM.
The power-of-2 restriction is not arbitrary. libjpeg (the underlying JPEG decoder Skia delegates to) can scale by 1/2, 1/4, or 1/8 at the IDCT stage - the inverse-DCT step that turns compressed 8x8 blocks back into pixels. Subsampling by a power of 2 costs almost nothing extra because the decoder simply emits fewer pixels per block; non-power-of-2 scaling would require a full decode followed by a resample, which is why the API doesn't expose it.
Bitmap.Config is the other lever. The full table:
| Config | Bytes/pixel | Use case |
|---|---|---|
ALPHA_8 | 1 | Masks, single-channel data |
RGB_565 | 2 | Opaque images on low-RAM devices |
ARGB_8888 | 4 | The default; standard 8-bit-per-channel with alpha |
RGBA_F16 | 8 | Wide-gamut + HDR; 16-bit float per channel |
HARDWARE | varies | Pixels live in graphics memory, not the Java heap (see below) |
Switching from ARGB_8888 to RGB_565 halves the heap cost for opaque images; for a JPEG with no alpha it is visually indistinguishable on most displays. RGBA_F16 is the inverse trade for Display P3 / HDR photo apps that need the precision and accept the cost.
Concrete numbers worth remembering:
| Source | Decoded at full res (ARGB_8888) | Decoded for a 200x200 dp ImageView |
|---|---|---|
| 12 MP photo (4000x3000) | 46 MB | ~0.15 MB |
| 4K wallpaper (3840x2160) | 32 MB | ~0.15 MB |
| 1080p screenshot (1920x1080) | 8 MB | ~0.15 MB |
The point of the second column is that the target view is what bounds memory, not the source resolution. A 200x200 dp ImageView on a 3x device wants 600x600 pixels - about 1.4 MB at ARGB_8888, half that at RGB_565. Everything else is waste.
Source: developer.android.com - Loading large bitmaps efficiently
Where bitmap pixels live
The "46 MB" number above hides a question that matters for every cache budget: 46 MB of what? The answer changed at API 26 and changed again with HARDWARE bitmaps. Image loaders have to know which era's accounting they are working with.
API < 26 (pre-Oreo). Pixel buffers live in the Java/Dalvik (managed) heap. Bitmaps count against Runtime.getRuntime().maxMemory() directly, so the accounting is honest and the GC reclaims them as regular managed objects. The catch is that on these versions a decoded bitmap eats straight into the same Java cap the rest of the app shares, so a grid of large bitmaps can throw OutOfMemoryError quickly. bitmap.recycle() exists here as a way to drop the pixel buffer immediately rather than waiting for the next GC.
API 26+ (Oreo). Pixel buffers moved into the native heap. The Java-side Bitmap is a small wrapper holding a pointer plus metadata, and the pixels no longer count against Runtime.getRuntime().maxMemory() directly - though they consume the same physical RAM. Android still tracks the allocation, and a NativeAllocationRegistry frees the native bytes when the wrapper is collected by GC, so bitmap.recycle() is rarely necessary on this path.
Bitmap.Config.HARDWARE (API 26+). A third option: the pixel buffer is allocated directly in graphics memory (via Gralloc), bypassing both the Java heap AND the native heap. The CPU cannot read the pixels back (getPixel() throws), but the GPU samples them during composition. Modern loaders prefer HARDWARE for memory-cache bitmaps because it sidesteps the Java cap entirely - a 32 MB hardware bitmap costs zero Java heap.
| Era | Where pixels live | Counts against Java heap | recycle() needed |
|---|---|---|---|
| API < 26 | Java/Dalvik heap | Yes | No - GC handles it, recycle() frees early |
| API 26+ default | Native heap | No | No - NativeAllocationRegistry frees via GC |
API 26+ HARDWARE | Graphics memory | No | No - released with the reference |
The catch with HARDWARE: it's upload-once and immutable. Any operation needing CPU pixel access (software canvas draw, color-filter readback, inBitmap reuse) silently falls back to an ARGB_8888 copy in the Java heap, doubling the cost. Loaders use HARDWARE for the cached result but decode and transform on a regular config first. See Memory and GC for the full heap topology and onTrimMemory interaction.
ImageDecoder - the modern decode API
BitmapFactory predates Android 1.0; ImageDecoder arrived in API 28 (Pie) as its replacement. New code should reach for ImageDecoder first - it is faster, supports more formats, handles orientation and color spaces correctly, and exposes a cleaner async surface.
val source = ImageDecoder.createSource(contentResolver, uri)
val bitmap = ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
// Header has been parsed; info.size is the source resolution.
// Choose target dimensions here - no second decode pass needed.
decoder.setTargetSize(targetW, targetH)
decoder.allocator = ImageDecoder.ALLOCATOR_HARDWARE // graphics memory
}The OnHeaderDecodedListener callback is ImageDecoder's answer to inJustDecodeBounds: the decoder parses the header, hands the listener the source dimensions, and the listener configures the decode (target size, allocator, mutability, color space, post-processor) before the pixel buffer is allocated. One call, one pass, dimensions read once.
Key differences from BitmapFactory:
- HEIC support.
BitmapFactorycannot decode HEIC at all on most sources;ImageDecoderdecodes HEIC natively on API 28+. Same for AVIF on API 31+. - EXIF orientation is automatic. A JPEG taken in portrait mode with the orientation tag set decodes upright;
BitmapFactorydecodes it sideways and the caller has to rotate the bitmap themselves. - Exact-size decode.
setTargetSize(w, h)decodes to the exact requested dimensions, not just powers of 2 from the source. The JPEG IDCT-stage scaling still applies as the first pass; an additional resample produces the final size. - Color-space-aware. Reads the embedded ICC profile (sRGB, Display P3, etc.) and either converts at decode time or preserves the tag for the GPU.
- Animated formats.
decodeDrawablereturns anAnimatedImageDrawablefor animated GIF and animated WebP only.
Modern loaders use ImageDecoder on API 28+ and fall back to BitmapFactory on older devices. The two-pass-decode discipline from the previous section still describes what happens conceptually; ImageDecoder just folds both passes into one call.
EXIF orientation
A photo taken on a phone in portrait mode is almost always stored as a landscape JPEG with an EXIF orientation tag (1-8) describing how the camera was held. The display software is expected to apply the rotation. BitmapFactory does not - it returns the raw pixel grid, and a caller that calls setImageBitmap on that result sees the image rotated 90 degrees.
val exif = ExifInterface(inputStream)
val rotation = when (exif.getAttributeInt(TAG_ORIENTATION, ORIENTATION_NORMAL)) {
ExifInterface.ORIENTATION_ROTATE_90 -> 90f
ExifInterface.ORIENTATION_ROTATE_180 -> 180f
ExifInterface.ORIENTATION_ROTATE_270 -> 270f
else -> 0f
}
val rotated = Bitmap.createBitmap(
bitmap, 0, 0, bitmap.width, bitmap.height,
Matrix().apply { postRotate(rotation) }, true,
)Loaders normalize orientation before caching - otherwise the cache key would have to encode orientation, and two views displaying the same photo at different rotations would each pay for a separate decoded copy. On API 28+, ImageDecoder reads the EXIF tag and applies the rotation automatically; on older devices, the loader handles it via ExifInterface and a Matrix.postRotate.
Color spaces
Pre-API-26 Android assumed every image was sRGB and threw the ICC profile away at decode. API 26 added wide-gamut color support: Bitmap carries a ColorSpace, ImageDecoder reads the embedded profile, and the renderer (Skia + RenderEngine) applies the color transform when compositing to the display's color space.
The three color spaces that matter on mobile:
- sRGB. The default; covers most JPEGs and PNGs from the web.
- Display P3. Apple's wide-gamut space; iPhone HEICs, photo-app exports, and modern displays. About 25% more saturated reds and greens than sRGB.
- Rec. 2020 / HDR. Used in HDR video and a small number of still-image formats; requires
RGBA_F16and a display panel that supports it.
The actual color transform happens at draw time, not decode time. A Display P3 bitmap composited onto an sRGB display gets its colors mapped down by the GPU as part of the composition pass - the decoded pixel values are not pre-converted. This is why a wide-gamut photo looks correct on a P3 display and slightly desaturated (but never wrong) on an sRGB display.
For thumbnail-grid use cases this is overkill; sRGB + ARGB_8888 (or RGB_565) is the right default. The wide-gamut path matters for full-screen photo viewers that decode via ImageDecoder and allocate at RGBA_F16 to preserve the precision.
Format support matrix
Not every format works on every API level. The full picture:
| Format | BitmapFactory | ImageDecoder | Notes |
|---|---|---|---|
| JPEG | All API levels | API 28+ | Universal |
| PNG | All API levels | API 28+ | Universal |
| WebP (static) | API 14+ | API 28+ | Universal in practice |
| WebP (animated) | Not supported | API 28+ | Use software decoder on older devices |
| GIF (static) | All API levels | API 28+ | Static frame only via BitmapFactory |
| GIF (animated) | Not supported | API 28+ | Software decoder pre-28 |
| HEIC / HEIF | Not supported | API 28+ | No back-port; ImageDecoder required |
| AVIF | Not supported | API 31+ | Newer codec; no back-port |
The view-not-measured problem
The decode math above needs targetW and targetH. Where do those come from? The obvious answer is "the ImageView" - but at the moment imageView.load(url) is called, the view has not been measured yet. Inflation set its LayoutParams but the measure / layout pass has not run, so view.width and view.height are both 0.
The fix is to defer the decode until layout is done. Both Glide and Coil register a ViewTreeObserver.OnPreDrawListener on the target view at request time. The listener fires after measure and layout but before the frame is drawn - the earliest point where view.width is meaningful - so the cache key, the decode dimensions, and everything downstream see real numbers.
Decoding at native resolution as a fallback is not an option: it defeats the entire two-pass scheme and reintroduces the OOM the library exists to prevent.
// Simplified version of what the library does internally.
view.viewTreeObserver.addOnPreDrawListener(object : OnPreDrawListener {
override fun onPreDraw(): Boolean {
view.viewTreeObserver.removeOnPreDrawListener(this)
val w = view.width // now non-zero
val h = view.height
scheduleDecode(targetW = w, targetH = h)
return true // continue the draw pass
}
})The listener removes itself on the first call so it does not fire on every frame. By the time the network fetch returns (tens to hundreds of milliseconds later), the target dimensions are known and the second decode pass uses them.
This is also why setting android:layout_width="wrap_content" on a remote image is dangerous: the view's measured size depends on the bitmap, the bitmap's target dimensions depend on the measured size, and you get the full-resolution decode by default.
Sources: ViewTreeObserver.OnPreDrawListener (AOSP)
Cancel on RecyclerView recycle
A RecyclerView reuses view holders: a cell scrolls off the top, gets rebound to a new URL, and reappears at the bottom. Without cancellation, the in-flight request for the old URL completes and calls setImageBitmap - the wrong image flashes for one frame before the new request's result arrives.
The substrate question this page cares about is why a simple cancel is not enough. Decode runs on a worker thread; the result is posted back to the main thread via the main Looper's MessageQueue. By the time the rebind handler calls dispose() on the previous request, the previous completion message may already be enqueued on the main thread - past the point of recall. So the completion callback runs after dispose() returns, on a view that has already been rebound.
This is a property of Android's threading model, not a loader bug. Every async-into-main-thread API has the same window: Handler.post, Choreographer.postFrameCallback, coroutine Dispatchers.Main - all enqueue work that survives a cancellation request issued on a different thread.
The standard defense is tag re-checking inside the callback: stamp the view with a request identity on dispatch, compare on completion, and discard if the identity has changed. The Image Loader Library page covers the exact implementation; the takeaway here is that "cancel on rebind" is two layers - the cancel handles the request, the tag-recheck handles the message-queue race window.
// On main thread, before applying the bitmap:
if (view.getTag(R.id.image_request_tag) !== thisRequest) return
view.setImageBitmap(bitmap)Glide vs Coil
The pipeline above (caches, two-pass decode, pre-draw defer, tag-based cancel) is identical between the two libraries. The differences live around it.
| Glide | Coil | |
|---|---|---|
| Language | Java-first (2014) | Kotlin-first, coroutine-native |
| Compose | GlideImage (added later) | AsyncImage (first-class) |
| Binary size | ~500 KB | ~120 KB |
| Multiplatform | Android only | Coil 3.x: KMP (iOS, desktop) |
For a new Kotlin/Compose project, Coil is the lean default. Glide is reasonable when inheriting a Java codebase or relying on its RESOURCE disk cache to skip decode entirely. Existing projects rarely justify the migration cost.
Animated formats
GIF and animated WebP are multi-frame containers. The static-image rules above (two-pass decode, inSampleSize, Bitmap.Config) apply per frame; what's new is the frame-memory budget and the platform's animated-drawable API.
Frame memory budget. 100 frames of a 400x300 ARGB_8888 animation is 48 MB if pre-rendered eagerly. Loaders bound the in-memory frame cache to a small ring buffer (typically 2-3 frames: one being drawn, one queued, one being decoded) and decode the rest on demand. Animation pauses when the view detaches from the window so a scrolled-away GIF stops burning CPU.
AnimatedImageDrawable (API 28+). ImageDecoder.decodeDrawable returns an AnimatedImageDrawable for animated GIF and animated WebP on API 28+. The drawable implements Animatable2 (start, stop, callback registration); the underlying decoder runs on a RenderThread-adjacent thread pool and feeds frames to the display, off the main thread.
val drawable = ImageDecoder.decodeDrawable(ImageDecoder.createSource(file))
if (drawable is AnimatedImageDrawable) drawable.start()
imageView.setImageDrawable(drawable)Pre-28 there is no built-in animated decoder; software decoders (Glide's bundled GIF decoder, Coil's coil-gif artifact) fill the gap.
See also
- How Rendering Works - the frame pipeline that takes the decoded bitmap and turns it into pixels; image decode is upstream of
Modifier.graphicsLayer, RenderThread, and SurfaceFlinger. - Caching Strategies - the generic LRU + memory + disk tiering this page applies to images specifically.
- Memory and GC - the heap topology this page references; where Java-heap vs native vs graphics-memory pixel allocation cashes out in
OutOfMemoryErrorand lmkd kills.
Used in
- Photo Gallery App - the canonical use case; the three-tier thumbnail design, cancel-on-recycle for the grid, and
WhileSubscribedcollectors all sit on top of this pipeline. - Newsfeed App - image-heavy feed scrolling;
OnPreDrawListenerdeferred decode and tag-based cancel are essential on fling. - Image Loader Library - the dedicated system-design problem; the deep dive walks the exact pipeline this page describes and asks the candidate to defend the choices.
- Doc Editor App - embedded images in documents reuse the same loader, with a stricter cache budget because the document itself competes for heap.
Done reading? Mark it so it sticks in your dashboard.