Loading remote images efficiently
A farmer scanning a list of prices finds onion faster by its picture than by its name, so each price card gets one, and the detail screen a bigger one. Pictures are the heaviest thing most apps download and the easiest to get wrong without noticing: the app looks right, and a farmer on a prepaid data pack pays for it. This lesson adds them with Coil, then measures what the wrong picture, or the right picture at the wrong size, costs in bytes over the network and in memory on the phone.
The pictures, and where they come from
The course's rule is that images ship with the repository and are never hotlinked from someone else's site: a linked picture moves, gets rate-limited, or starts serving something else, and nothing in the code says why the app now looks broken. So the API in the Mandi Bhav repository has its own, drawn for it by a short Pillow script, tools/draw_commodity_images.py: a heap of onions, tomatoes or grain on a hessian sack. Nothing is downloaded from anywhere, so there is no licence to follow, and the script draws the same pictures every time it runs.
Each commodity comes in two sizes, because the screens need two:
| File | Size | Bytes (onion) | Used for |
|---|---|---|---|
images/thumb/onion.jpg |
160 × 160 | 7,971 | The 56 dp square on a card: 147 px on the emulator's 2.625× screen |
images/photo/onion.jpg |
960 × 640 | 109,003 | The detail screen's photo, up to 600 dp wide |
The JSON carries both paths, relative to the API, and lesson 1's mapping resolves them against the base URL into thumbnailUrl and photoUrl on CommodityPrice. Storing them took two nullable columns in prices, schema version 4, and module 8's kind of automatic migration, AutoMigration(from = 3, to = 4), with one more migration test: a version 3 database keeps its rows and gets empty picture URLs.
Why the server offers two sizes. The image library on the phone can decode a big picture small, as you are about to see; it cannot download it small. Only the server can decide how many bytes go over the air.
Coil
On 8 November 2026 Maven Central had Coil 3.6.3, which built with AGP 9.5.1 and Kotlin 2.4.21. Coil 3 splits networking into its own artifact, so it needs two:
[versions]
coil = "3.6.3"
[libraries]
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
Without coil-network-okhttp, Coil 3 loads local files and resources and fails every https:// URL. Coil uses one ImageLoader for the whole app, with a memory cache and a disk cache, and the Application can build it, over the client Retrofit uses, in MandiBhavApplication:
@HiltAndroidApp
class MandiBhavApplication : Application(), SingletonImageLoader.Factory {
@Inject
lateinit var okHttpClient: Lazy<OkHttpClient>
override fun newImageLoader(context: PlatformContext): ImageLoader =
ImageLoader.Builder(context)
.components {
add(OkHttpNetworkFetcherFactory(callFactory = { okHttpClient.get() }))
}
.build()
}
One client means one connection pool, lesson 4's timeouts on pictures too, and pictures in the debug OkHttp log beside the prices. Lazy is lesson 3's: nothing builds the client until a picture needs it.
The card's picture, in PriceCard.kt:
@Composable
private fun CommodityThumbnail(url: String?) {
AsyncImage(
model = url,
contentDescription = null,
contentScale = ContentScale.Crop,
placeholder = ColorPainter(MaterialTheme.colorScheme.surfaceVariant),
error = ColorPainter(MaterialTheme.colorScheme.surfaceVariant),
fallback = ColorPainter(MaterialTheme.colorScheme.surfaceVariant),
modifier = Modifier
.size(56.dp)
.clip(RoundedCornerShape(8.dp)),
)
}
contentDescription = nullbecause the picture is decorative: module 4 made the whole card one sentence for TalkBack, and "a picture of onions" adds nothing to "Onion at Lasalgaon APMC, 2,580 rupees per quintal".- Placeholder, error and fallback are the same plain surface. While loading, on failure, or with no URL at all, the square is quiet. A broken-image icon on every card on a slow train tells the farmer something is wrong when the prices, the part that matters, are fine.
- A fixed size. Coil reads the size the image will be drawn at and decodes to that, which is the whole of the next section.
The detail screen's photo is the same call at fillMaxWidth().aspectRatio(3f / 2f), capped at 600 dp like the rest of that screen. The card's content became a Row: the picture, then the old text column with Modifier.weight(1f), so the text takes whatever width the picture leaves. At module 4's 1.5× font scale on the emulator, the card still fitted on one 1080-pixel-wide screen: market and date on their own lines, "₹2,580 per quintal" on one line, and "Min ₹1,250" and "Max ₹2,950" side by side with a little room between them.
What a picture costs: measured
A scratch build logged Coil's result for each card, through ImageRequest.Builder(context).listener(onSuccess = …): the decoded width and height, and the bytes Coil reports for the bitmap. Each run cleared the app first, so everything came from the server, and the debug log gave the bytes downloaded for the first screen of cards, three onions:
| What the card asked for | Downloaded | Decoded to | Bitmap in memory |
|---|---|---|---|
| Thumbnail, as shipped | 7,971 bytes each | 147 × 147 | 86,436 bytes |
| The 960 × 640 photo in the 56 dp square | 109,003 bytes each | 221 × 147 | 129,948 bytes |
The photo, with size(Size.ORIGINAL) |
109,003 bytes each | 960 × 640 | 2,457,600 bytes |
| A 12-megapixel phone photo, 4000 × 3000 | 1,328,124 bytes each | 196 × 147 | 115,248 bytes |
The phone photo, with size(Size.ORIGINAL) |
1,328,124 bytes each | 4000 × 3000 | 48,000,000 bytes |
Read the columns separately, because they are separate problems.
Memory: Coil saved you, until you told it not to. Asked for a 56 dp square, Coil decoded every picture, even the 12-megapixel one, to about 147 px on its short side: 221 × 147 for a 3:2 photo, so that Crop can fill the square. A bitmap costs width × height × 4 bytes, so 147 × 147 is 86 KB. size(Size.ORIGINAL) tells Coil to decode every pixel, and the phone photo became a 48,000,000-byte bitmap for a square the size of a thumbnail. Three of them on screen took the app's native heap, where Android keeps bitmap pixels, from 11 MB to 152 MB, by dumpsys meminfo. On a phone with 3 GB of memory, that is the app Android kills first when the farmer switches to WhatsApp. Let Coil size the image from the layout. Size.ORIGINAL is for zooming into a photo, not for showing one.
Network: nothing on the phone saved you. Coil decoded the phone photo small, but downloaded every one of its 1.3 MB first, because decoding happens after the bytes arrive. Three onion cards cost 3,984,372 bytes for pictures that end up 147 px wide; the thumbnails cost 23,913. That is about 167 times the data for the same screen, on a prepaid pack the farmer pays for by the gigabyte. The size must be chosen where the file is made, which is why the API has a thumb and a photo and the card asks for the thumb.
One more number, from the first column: three onion cards meant three requests. On the first launch of the real app, all three went out in the same millisecond:
11-08 22:46:31.613 I/OkHttp (21864): --> GET http://10.0.2.2:8765/api/v1/images/thumb/onion.jpg
11-08 22:46:31.613 I/OkHttp (21864): --> GET http://10.0.2.2:8765/api/v1/images/thumb/onion.jpg
11-08 22:46:31.622 I/OkHttp (21864): --> GET http://10.0.2.2:8765/api/v1/images/thumb/onion.jpg
Nothing was in the cache yet, and the three loads did not wait for each other. After that first screen, the listener reported from MEMORY_CACHE and from DISK for the same picture and the log showed no requests. Small thumbnails make the duplicate cheap, 8 KB; with a photo in the card it would have been 330 KB.
Two things Coil reports that can mislead you
The default run reported 16,384 bytes for a 147 × 147 picture, not 86,436. The bitmap's config was HARDWARE: on Android 8.0 and later, Coil asks for hardware bitmaps by default, whose pixels live in graphics memory, and the number on the Java side is not the size of the image. The table's memory column comes from runs with allowHardware(false), which gives ordinary ARGB_8888 bitmaps that report their real size. Hardware bitmaps are faster to draw and do not crowd the app's heap; leave them on, and measure with them off.
The decoded size depends on the screen's density. On the emulator's 2.625× screen, 56 dp is 147 px; a cheaper phone at 2× decodes 112 px and uses about 42% less memory per picture. Measure on the phone your users have.
Pictures with no connection
Coil keeps what it downloaded in its disk cache, in the app's cacheDir (module 8: Android may delete it when storage is low, and the app downloads it again). In lesson 6's airplane-mode cold start, every card had its picture and the debug log had no requests at all. What it does not do is promise: a phone short of space may clear the cache, and then the cards show the plain surface until the connection returns. That is the reason for the quiet placeholder.
Check your work
Why the API has two sizes of each picture. Coil can decode a large file small, but only the server can make the download small.
What Coil decoded the 12-megapixel photo to in a 56 dp square. 196 × 147, about 115 KB, because it reads the size from the layout.
What Size.ORIGINAL cost. 48,000,000 bytes per bitmap; native heap from 11 MB to 152 MB with three on screen.
What the phone photo cost on the network. 1,328,124 bytes per card, decoded small or not.
Why the default run reported 16,384 bytes. It was a hardware bitmap, whose pixels are not counted on the Java side.
Why the picture's contentDescription is null. It is decorative; the card's own description already says what it is.
Practice 4. On the emulator, Back 1.5 seconds after opening Lasalgaon onion gave <-- HTTP FAILED: java.io.IOException: Canceled. … (1959ms): AsyncImage loads in the composition, and leaving the screen cancelled the coroutine and with it the OkHttp call. The server, which had been told to wait five seconds, still logged its 200 three seconds later, into a connection nobody was reading. Nothing reached the disk cache, so opening the screen again downloaded all 109,003 bytes again. The cancellation saved the phone from decoding a picture nobody would see; it did not save the data already in flight.
Practice
- Add Coil and its network artifact, the Application's
ImageLoader, the thumbnail on the card and the photo on the detail screen. Bump the database to version 4 with the automatic migration and its test. Then look at the card at 1.5× font scale, as module 4 did, and check that nothing important is cut off. - Add the logging listener to the card in a scratch copy, clear the app's storage, and fill in your own copy of the table's first row. Then repeat with
allowHardware(false). - Point the card at the photo instead of the thumbnail and read the bytes in the
OkHttplog for one screen of cards. Work out what a farmer would pay for a day of it. - With the server told to wait five seconds, open a detail screen and press Back before the photo appears. Watch the log and the server's own output, and say what happened to the request.
- Break it on purpose: add
size(Size.ORIGINAL)and serve a photo from your own phone's camera. Open the list and runadb shell dumpsys meminfo com.riztechacademy.mandibhav.verify. Find the native heap. - Harder: the first screen sent three requests for the same onion thumbnail at once. Find out from Coil's documentation whether it can share one download between requests for the same URL, decide whether three 8 KB thumbnails justify the effort, and say what would change your mind.
Official documentation
- Coil — Coil 3's overview, artifacts and the image loader.
- Coil: Jetpack Compose —
AsyncImage, placeholders, and how the size is resolved from constraints. - Coil: network images — Why networking is a separate artifact, and how to share an OkHttp client.
- Loading large bitmaps efficiently — Decoding at the size you show, the problem Coil solves for you.
Next: offline-first, and syncing when the connection comes back.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Selected students who finish a course can join our internship, with real-world projects and code review from a working engineer.
About the internship