How Coil works under the hood: LRU caching, performance trade-off, bitmap sampling

skydovesJaewoong Eum (skydoves)||23 min read

How Coil works under the hood: LRU caching, performance trade-off, bitmap sampling

Image loading is one of the most critical yet complex aspects of Android development. While libraries like Glide and Picasso have served developers for years, Coil emerged as a modern, Kotlin-first solution built from the ground up with coroutines. But the power of Coil goes far beyond its clean API, it's in the solid internal machinery that makes it both performant and memory-efficient.

In this article, you'll dive deep into the internal mechanisms of Coil, exploring how image requests flow through an interceptor chain, how the two-tier memory cache achieves high hit rates while preventing memory leaks, how bitmap sampling uses bit manipulation for optimal memory usage, and the subtle optimizations that make it production-ready.

Understanding the core abstraction

At its heart, Coil is an image loading library that transforms data sources (URLs, files, resources) into decoded images displayed in views. What distinguishes Coil from other image loaders is its adherence to two fundamental principles: coroutine-native design and composable interceptor architecture.

The coroutine-native design means everything in Coil is built around suspend functions. Image loading naturally fits the structured concurrency model, requests have lifecycles, can be cancelled, and should respect scopes. Traditional image loaders use callback chains, but Coil embraces coroutines:

// Traditional callback approach
imageLoader.load(url) { bitmap ->
    imageView.setImageBitmap(bitmap)
}

// Coil's coroutine approach
val result = imageLoader.execute(
    ImageRequest.Builder(context)
        .data(url)
        .target(imageView)
        .build()
)

The composable interceptor architecture means the entire request pipeline is a chain of interceptors, similar to OkHttp. Each interceptor can observe, transform, or short-circuit the request. This makes the library extensible without modifying core code.

These properties aren't just conveniences, they're architectural decisions that enable better resource management, cleaner cancellation semantics, and powerful customization. Let's explore how these principles manifest in the implementation.

The ImageLoader interface and RealImageLoader implementation

If you examine the ImageLoader interface, it defines two primary entry points:

interface ImageLoader {
    fun enqueue(request: ImageRequest): Disposable
    suspend fun execute(request: ImageRequest): ImageResult
}

Two methods for the same operation? This reflects Android's dual nature, some callers need fire-and-forget loading (enqueue for views), while others need structured concurrency (execute for repositories or composables).

The RealImageLoader implementation handles both cases with a unified internal pipeline:

internal class RealImageLoader(
    val options: Options,
) : ImageLoader {
    private val scope = CoroutineScope(options.logger)
    private val systemCallbacks = SystemCallbacks(this)
    private val requestService = RequestService(this, systemCallbacks, options.logger)

    override fun enqueue(request: ImageRequest): Disposable {
        // Start executing the request on the main thread.
        val job = scope.async(options.mainCoroutineContextLazy.value) {
            execute(request, REQUEST_TYPE_ENQUEUE)
        }

        // Update the current request attached to the view and return a new disposable.
        return getDisposable(request, job)
    }

    override suspend fun execute(request: ImageRequest): ImageResult {
        if (!needsExecuteOnMainDispatcher(request)) {
            // Fast path: skip dispatching.
            return execute(request, REQUEST_TYPE_EXECUTE)
        } else {
            // Slow path: dispatch to the main thread.
            return coroutineScope {
                val job = async(options.mainCoroutineContextLazy.value) {
                    execute(request, REQUEST_TYPE_EXECUTE)
                }
                getDisposable(request, job).job.await()
            }
        }
    }
}

Notice the fast path optimization in execute(): if the request doesn't need main thread dispatch (no target view), it executes immediately without the overhead of launching a coroutine. This is important for background image loading in repositories where you're just fetching the bitmap.

The scope is a SupervisorJob scope, meaning one failed request doesn't cancel other in-flight requests:

private fun CoroutineScope(logger: Logger?): CoroutineScope {
    val context = SupervisorJob() +
        CoroutineExceptionHandler { _, throwable -> logger?.log(TAG, throwable) }
    return CoroutineScope(context)
}

This isolation ensures that a network error loading one image doesn't affect other images currently loading. The CoroutineExceptionHandler logs uncaught exceptions rather than crashing, making the library resilient to unexpected errors.

The request execution pipeline: Interceptors all the way down

The core of Coil's architecture is the interceptor chain. When you execute a request, it flows through a series of interceptors before reaching the EngineInterceptor, which performs the actual fetch and decode:

private suspend fun execute(initialRequest: ImageRequest, type: Int): ImageResult {
    val requestDelegate = requestService.requestDelegate(
        request = initialRequest,
        job = coroutineContext.job,
        findLifecycle = type == REQUEST_TYPE_ENQUEUE,
    ).apply { assertActive() }

    val request = requestService.updateRequest(initialRequest)
    val eventListener = options.eventListenerFactory.create(request)

    try {
        if (request.data == NullRequestData) {
            throw NullRequestDataException()
        }

        requestDelegate.start()

        if (type == REQUEST_TYPE_ENQUEUE) {
            requestDelegate.awaitStarted()
        }

        // Set the placeholder on the target.
        val cachedPlaceholder = request.placeholderMemoryCacheKey?.let {
            memoryCache?.get(it)?.image
        }
        request.target?.onStart(placeholder = cachedPlaceholder ?: request.placeholder())
        eventListener.onStart(request)

        // Resolve the size.
        val sizeResolver = request.sizeResolver
        eventListener.resolveSizeStart(request, sizeResolver)
        val size = sizeResolver.size()
        eventListener.resolveSizeEnd(request, size)

        // Execute the interceptor chain.
        val result = withContext(request.interceptorCoroutineContext) {
            RealInterceptorChain(
                initialRequest = request,
                interceptors = components.interceptors,
                index = 0,
                request = request,
                size = size,
                eventListener = eventListener,
                isPlaceholderCached = cachedPlaceholder != null,
            ).proceed()
        }

        when (result) {
            is SuccessResult -> onSuccess(result, request.target, eventListener)
            is ErrorResult -> onError(result, request.target, eventListener)
        }
        return result
    } catch (throwable: Throwable) {
        if (throwable is CancellationException) {
            onCancel(request, eventListener)
            throw throwable
        } else {
            val result = ErrorResult(request, throwable)
            onError(result, request.target, eventListener)
            return result
        }
    } finally {
        requestDelegate.complete()
    }
}

This execution flow has several aspects:

Lifecycle integration: The requestDelegate connects the request to Android's lifecycle. For enqueue requests, it finds the view's lifecycle and suspends if the lifecycle isn't started. This prevents loading images for invisible views.

Size resolution: Before fetching, Coil resolves the target size. For views, this waits until the view is measured. The resolved size is critical for sampling, it determines how much to downsample the image.

Event listeners: The eventListener provides hooks for observing the request at every stage. This is how Coil's debugging tools work—they install a logging event listener.

Exception handling: Cancellation exceptions are propagated (respecting structured concurrency), while other exceptions are caught and converted to ErrorResult. This ensures that network errors or decoding failures don't crash the app.

The RealInterceptorChain implementation

The interceptor chain is elegantly simple:

internal class RealInterceptorChain(
    val initialRequest: ImageRequest,
    val interceptors: List<Interceptor>,
    val index: Int,
    override val request: ImageRequest,
    override val size: Size,
    val eventListener: EventListener,
    val isPlaceholderCached: Boolean,
) : Interceptor.Chain {

    override suspend fun proceed(): ImageResult {
        val interceptor = interceptors[index]
        val next = copy(index = index + 1)
        val result = interceptor.intercept(next)
        checkRequest(result.request, interceptor)
        return result
    }

    private fun checkRequest(request: ImageRequest, interceptor: Interceptor) {
        check(request.context === initialRequest.context) {
            "Interceptor '$interceptor' cannot modify the request's context."
        }
        check(request.data !== NullRequestData) {
            "Interceptor '$interceptor' cannot set the request's data to null."
        }
        check(request.target === initialRequest.target) {
            "Interceptor '$interceptor' cannot modify the request's target."
        }
        check(request.sizeResolver === initialRequest.sizeResolver) {
            "Interceptor '$interceptor' cannot modify the request's size resolver. " +
                "Use `Interceptor.Chain.withSize` instead."
        }
    }
}

This article continues for subscribers

Subscribe to Dove Letter for full access to 40+ deep-dive articles about Android and Kotlin development.

Become a Sponsor