|

Nuke: Coalescing Image Work Across Swift Concurrency

Daily (almost) Swift Open Source Overview hero for Nuke

Image loading gets expensive when several parts of an app ask for the same image at nearly the same time.

A feed can start prefetching an image just before its cell appears. Two views can request the same URL. One screen may need a thumbnail while another needs the original. If every request owns a completely separate pipeline, the app can repeat network, decoding, and processing work that has already started.

Nuke treats this as a shared-work problem.

Its ImagePipeline builds a graph of internal tasks and coalesces equivalent work. Public ImageTask values can come and go independently, while shared downloads and processing steps continue as long as at least one subscriber still needs them.

That separation is more interesting than a simple “deduplicate URLs” cache.

Two callers, one download

At the API level, nothing special is required to opt into coalescing. It is enabled by default.

import Nuke

let url = URL(string: "https://example.com/avatar.jpg")!

async let first = ImagePipeline.shared.image(for: url)
async let second = ImagePipeline.shared.image(for: url)

let (image1, image2) = try await (first, second)

If these equivalent requests overlap, the pipeline can attach both callers to the same underlying work instead of starting two downloads.

Nuke’s coalescing tests verify this directly: two equivalent requests create one data-loader task.

The important word is equivalent. Nuke does not decide that two requests are the same only because their URLs match.

Coalescing happens at several stages

ImagePipeline does not represent an image load as one monolithic operation. The current source describes a task graph.

For an image request, the path is conceptually:

load image
    ↓
fetch original image
    ↓
fetch original data

Processing can add more work above that original image.

The pipeline keeps separate TaskPool instances for loading data, loading images, fetching the original image, and fetching the original data. Each pool maps a key to an outstanding AsyncTask.

When a pool receives a key, it first checks whether equivalent work is already running. If so, it returns a publisher for that task. If not, it creates a task, stores it, and removes it from the pool when the task is disposed.

This gives Nuke more precision than deduplicating the final request as one unit.

Different final images can still share earlier work

Consider two requests that use the same source image but apply different processing:

import Nuke

let url = URL(string: "https://example.com/photo.jpg")!

let small = ImageRequest(
    url: url,
    processors: [.resize(width: 160)]
)

let large = ImageRequest(
    url: url,
    processors: [.resize(width: 640)]
)

async let smallImage = ImagePipeline.shared.image(for: small)
async let largeImage = ImagePipeline.shared.image(for: large)

let _ = try await (smallImage, largeImage)

These requests do not produce the same final processed image, so Nuke cannot simply reuse the final result.

But their original data is the same.

The tests cover this shape of problem. Requests with the same URL and different processors still create one data-loader task, while both processors are applied. There is also a test where two processing chains share the same first processor: Nuke applies that shared processing step once and then continues with the extra step needed by the second request.

The graph can therefore share the common prefix of the work without pretending that the final results are identical.

The keys define what can safely be shared

This behavior comes from a set of internal request keys in ImageRequestKeys.swift.

TaskFetchOriginalDataKey identifies original-data work. Its current fields include the original image identity, URL request cache policy, cellular-access setting, and whether the request skips the data-loading queue.

TaskFetchOriginalImageKey builds on the data key and also includes image scale and thumbnail options.

TaskLoadImageKey builds on that and includes request options and processor identities.

That hierarchy mirrors the pipeline stages.

Two requests can disagree about their processors and still have equal original-data keys. They can therefore share the download while taking separate paths later.

Conversely, request differences that change the semantics of a stage are represented in the corresponding key. The coalescing tests, for example, verify that non-equivalent URL requests with different cache policies do not share a data-loader task.

This is the central design decision: share work at the narrowest stage where the inputs are still equivalent.

AsyncTask is a shared job, not a public request

The internal AsyncTask<Value, Error> supports multiple subscriptions.

A subscription has its own callback and priority. The task starts when its first subscriber is attached. Additional subscribers join the same task and receive its events.

This makes cancellation different from cancelling a plain one-owner Task.

If one subscriber leaves while another still exists, the underlying operation continues. Nuke’s tests explicitly verify that removing one of two subscriptions does not cancel the operation.

When the final subscription is removed, the task terminates as cancelled. It cancels its operation, unsubscribes from its dependency, and runs its cancellation callback.

The Performance Guide describes the user-facing consequence: cancelling one ImageTask does not cancel shared work needed by another request. The underlying work is cancelled only after all requests sharing it are gone.

That is exactly the behavior an image pipeline needs when a prefetched image becomes visible at the same moment the prefetching owner disappears.

Priority also belongs to the group

Sharing work creates another question: whose priority should the shared operation use?

Nuke answers it by deriving the task priority from its active subscriptions.

The internal AsyncTask keeps a priority for each subscription and uses the maximum active priority as the task’s priority. When that value changes, it propagates to the current operation and to the task’s dependency.

The tests cover both directions. Adding a high-priority subscriber raises the operation’s priority. Removing that subscriber allows the priority to fall back to the remaining lower-priority subscriber.

So a low-priority prefetch can become useful to an on-screen request without throwing away the work that has already started.

The shared job changes priority instead.

Public Swift concurrency sits above the shared graph

Nuke exposes async/await APIs such as:

let image = try await ImagePipeline.shared.image(for: url)

But the implementation does not map each public call to an isolated network operation.

ImagePipeline creates an ImageTask, then connects it to the internal worker graph through a subscription. The public task receives values, progress, errors, and cancellation behavior through that subscription.

In the current main source, the pipeline itself is isolated to ImagePipelineActor. The task pools and AsyncTask subscription machinery also run under that actor.

This gives the pipeline one serialized place to manage the graph of outstanding shared work while keeping the public loading methods callable from normal concurrent Swift code.

Coalescing is not caching

It is useful to keep these two ideas separate.

A cache reuses a result that already exists.

Coalescing reuses work that is still happening.

If an image is already in memory, the memory cache may answer the request without starting the graph below it. If a download is currently in progress, coalescing can let another request join that work before there is a completed result to cache.

Nuke has both mechanisms, but they solve different timing problems.

This also explains why coalescing needs subscribers, cancellation, and priority propagation. A cached value is already done. Shared in-flight work still has a lifecycle.

You can turn it off

The behavior is configurable:

let pipeline = ImagePipeline {
    $0.isTaskCoalescingEnabled = false
}

The tests verify that two equivalent requests then create two data-loader tasks.

That option is useful when requests that otherwise look equivalent must behave as independent operations. But for normal image loading, coalescing is enabled by default.

Where this design helps

The model fits image-heavy interfaces especially well.

A scrolling feed can have prefetch and visible cells asking for the same resource. Several views can display one avatar. Different presentation sizes can share original data. Multiple consumers can appear and disappear while a network request is still running.

In these cases, “one caller owns one download” is a poor match for the actual UI.

Nuke instead gives each caller its own public task while letting the pipeline own reusable work underneath.

There is still a cost. The pipeline has to calculate stage-specific keys, maintain task pools, track subscriptions, propagate priorities, and manage cancellation across dependencies. For a small app with little overlapping image work, that machinery may not be the reason to choose an image library.

But once overlapping requests are common, the model avoids making every UI owner coordinate with every other UI owner.

Source worth reading

Start with ImagePipeline.swift.

Its task-factory section explains the graph explicitly and shows the four task pools used to coalesce stages.

Then read ImageRequestKeys.swift. It makes the definition of “same work” concrete by showing which request properties participate in each stage’s identity.

AsyncTask.swift is the lifecycle layer. It contains subscriptions, cancellation, priority recomputation, dependencies, and TaskPool.

Finally, ImagePipelineCoalescingTests.swift and TaskTests.swift are unusually useful here because they state the intended sharing behavior directly: one download for equivalent requests, shared work across different processors, cancellation only after the final subscriber leaves, and priority based on active subscribers.

Project health

Nuke is distributed under the MIT License.

The current package declares iOS 16, tvOS 16, macOS 13, watchOS 9, and visionOS 1 as minimum platforms and uses Swift tools 6.0.

The latest version commit I could verify is 13.2.0, dated August 15, 2026. The repository’s current main branch is already documenting Nuke 14 as work in progress, with additional concurrency, diagnostics, performance, and API changes. This article therefore describes the current source-level coalescing design rather than presenting the unreleased Nuke 14 work as a shipped release.

Engineering takeaway

The useful idea in Nuke’s coalescing is not simply “avoid duplicate requests.”

It is to model expensive work separately from the callers waiting for it.

A public image request can be cancelled without automatically killing a download another caller needs. A low-priority prefetch can gain a high-priority subscriber. Two different processed images can share the same original data, and processing chains can share the steps they have in common.

The pipeline can do that because identity exists at several stages, and each shared task has its own subscriber lifecycle.

That is a broader concurrency pattern worth keeping: when many consumers can need the same expensive operation, make the work independently owned, then attach consumers to it. Cancellation and priority can belong to subscriptions while the shared job exists only as long as someone still needs it.

Sources