agentleFS
keankean/Nuke/skills/nuke-optimize/SKILL.md

nuke-optimize

Skill8.7k starsChanged yesterdayMITView on GitHub

Find out why images load slowly, scrolling stutters, memory grows, or the cache misses in an app that uses Nuke, and fix it. Use when asked to make images faster, to use less memory or data, to review how an app sets up Nuke, or to tune ImagePipeline, ImageCache, DataCache, ImagePrefetcher, or LazyImage. Covers Nuke 14, and what differs on 13.

We split this skill into the rules it gives an agent

8 rules. About 0 are common, 1 is distinctive, and 7 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈1

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 7

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Use a second pipeline only for images with different rules, such as private ones that stay off the disk.

    line 132
  2. Very specific:

    A URL with a token that changes: every launch is a miss. Set imageID:

    line 138
  3. Very specific:

    If the URL asks the CDN for a size, put the size in imageID too.

    line 146
  4. Very specific:

    If images fall out of the cache while the app is in use, the limit is too small.

    line 178
  5. Very specific:

    If the queue share is high for the images on screen, prefetching is holding slots.

    line 248
  6. Very specific:

    Create URLs when you parse the data, not in a view: URL(string:) isn't free in a long list.

    line 274
  7. Distinctive:

    If a URL can be a GIF or a large still, skip the animations in a processor of your own:

    line 279
  8. Very specific:

    If you control the assets, a short video with NukeVideo is usually smaller than a GIF, and it's decoded in hardware.

    line 298

≈ “Common” is an estimate: at least 1 in 5 of the 308,232 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Optimizing Nuke

Nuke's defaults come from benchmarks, so the gains are almost always in how the app asks for images, and rarely in its configuration. A 12 MP photo decoded for a 120-point cell costs 46 MB, and no setting makes up for that.

Measure first, change one thing, and measure again. Each change here has a cost, and some make things worse when the cause is something else. If you can't run the app, say that what you changed comes from reading the code, and that it's unmeasured. For the API in general, see llms-full.txt at the root of the package.

Measure

Launch with NUKE_DIAGNOSTICS_LOG=1, in the scheme or like this, and read one line per task:

SIMCTL_CHILD_NUKE_DIAGNOSTICS_LOG=1 xcrun simctl launch booted <bundle-id>
xcrun simctl spawn booted log stream --predicate 'subsystem == "com.github.kean.Nuke.ImagePipeline"'
ImageTask #1 · success · 78.5 ms · from network · https://example.com/a.png · 1200×800 · png · 3.7 MB in memory · 70 KB

Go through the slow screen twice: once on a fresh install, and again after relaunching.

In the logIt meansGo to
from network after a relaunchThe disk cache missedCache keys, Disk cache
from httpCache, and tens of msURLCache asked the server whether the image changedDisk cache
MB in memory over width × height × 4 of the cell, in pixelsThe image is bigger than what's displayedDecode less
The same URL twice, with no coalescedTwo requests that differ, so nothing is sharedCache keys, Prefetching
More lines than cells on screen, on openEverything loads at onceLoad only what's on screen
A long list of cancelledNormal while scrolling fast: the cells cancel the loads they scrolled pastNothing

For where the time of a slow task went:

let pipeline = ImagePipeline { $0.isDiagnosticsEnabled = true }
let task = pipeline.imageTask(with: url)
_ = try await task.image
print(task.metrics!.formatted([.header, .breakdown]))
time:  network 93.6 ms (66%) · process 42.5 ms (30%) · cache 0.5 ms · decode 0.3 ms · queue 0.1 ms · other 3.8 ms (3%)
Largest shareCause
networkThe file is too big, it wasn't cached, or the app opened a new connection
process, decode, decompressThe image is bigger than it needs to be
queueThe task waited for a slot. Prefetching, or too many images at once

isDiagnosticsEnabled costs a few microseconds a task, and it sends signposts, so the same stages show up in Instruments under os_signpost. For a task that hangs, po ImagePipeline.shared.diagnostics in the debugger prints the queues and the memory cache.

Read the code

# More than one pipeline, or one created in a view
grep -rnE 'ImagePipeline(\(| \{)' --include='*.swift' .
# Every image load: does each one know the size it's displayed at?
grep -rnE 'LazyImage\(|ImageLoader\.load|FetchImage\(|\.image\(for:|imageTask\(with:' --include='*.swift' .
# Disk I/O that blocks the caller, which is a problem on the main thread
grep -rnE 'cachedData\(|cachedImage\(|containsCachedImage|containsData\(|storeCachedImage' --include='*.swift' .
# Prefetchers, and whether they build the same request as the views
grep -rn 'ImagePrefetcher' --include='*.swift' .
# Defaults someone changed: find out why before you keep or remove them
grep -rnE 'isTaskCoalescingEnabled|isDecompressionEnabled|skipDecompression|isProgressiveDecodingEnabled|isResumableDataEnabled|TaskQueue\(|maxConcurrentTaskCount|costLimit|sizeLimit' --include='*.swift' .
# Processors of your own: is the identifier stable?
grep -rnE ': ImageProcessing\b' --include='*.swift' .

What to fix, in the order it pays

Download less

The cheapest image is the one the server resizes. If the CDN takes a size, as in ?w=360, ask for the size you display. That saves the data, the decode, and the memory, and nothing below does all three.

A decoded image has a scale of 1, so a 360-pixel file is 360 points wide. If the view shows it at its natural size, or a processor of yours draws in points, set request.scale to the scale of the screen.

Decode less

Tell the pipeline the size you display at, in the request or with .processors. A SwiftUI .frame doesn't: it scales a full-size bitmap on every draw.

LazyImage(url: url) { state in
    if let image = state.image {
        image.resizable().scaledToFill()
    } else {
        Color.secondary.opacity(0.2)
    }
}
.processors([.resize(size: CGSize(width: 120, height: 120))]) // Points, aspect fill by default
.frame(width: 120, height: 120)
.clipped()

A 12 MP image shown at 120 points on a 3x screen, with 16 loading at once. Peak is the memory used on top of the downloaded files. Every row leaves 0.4 MB in memory, or 46.5 MB with no size:

No size.resize(width: 120)thumbnail, maxPixelSize: 360
JPEG, CPU per image74 ms35 ms35 ms
JPEG, peak267 MB4 MB5 MB
HEIC, CPU per image174 ms185 ms161 ms
HEIC, peak272 MB191 MB7 MB
PNG, CPU per image95 ms107 ms116 ms
PNG, peak142 MB97 MB14 MB

These are from generated images on an iOS 27 simulator, so use them for the ranking, and not as a budget. Downsampling is a memory win first: a 1200×800 JPEG takes the same 6 ms either way, and leaves 0.3 MB rather than 3.7 MB.

Use .resize for JPEG, and thumbnail for anything else that's large, such as photos people upload, which are HEIC on an iPhone. .resize decodes those at full size first, so a burst of them can end the app even though each one ends up small.

var request = ImageRequest(url: url)
request.thumbnail = ImageRequest.ThumbnailOptions(maxPixelSize: 360) // Pixels, the longest side
  • maxPixelSize is in pixels, and .resize takes points. maxPixelSize: 120 on a 3x screen is blurry.
  • maxPixelSize fits the longest side, so a cell that fills needs ThumbnailOptions(size:unit:contentMode:), which has to read the image size first.
  • A thumbnail shares the download with other requests for the same URL, but not the decode. Two .resize requests share both.

Never add either one to a request that can be animated. A processor drops the animation, and a thumbnail skips it, so a GIF becomes a still. See Animated images.

A processor of your own has to keep the size too. UIGraphicsImageRenderer draws at the scale of the screen by default, so an image with a scale of 1 comes out with nine times the pixels on a 3x device. Pass a format with format.scale = image.scale.

One pipeline

There should be one ImagePipeline, set at launch. A pipeline per view has a DataLoader of its own, so it has its own URLSession: no shared connections, so a TLS handshake per image, and no coalescing between views. Pass ImagePipeline.shared, or use the default.

Use a second pipeline only for images with different rules, such as private ones that stay off the disk. All pipelines share ImageCache.shared unless you give one its own.

Cache keys

The default key is the URL, with its query, plus the processors, and two requests only share work if those match.

  • A URL with a token that changes: every launch is a miss. Set imageID:
var request = ImageRequest(url: signedURL, processors: [.resize(width: 320)])
request.imageID = "photo-\(photo.id)" // Replaces the URL in the cache keys
  • imageID keys Nuke's caches: ImageCache and DataCache. URLCache belongs to Foundation and uses the URL, so on the default pipeline a signed URL still misses on disk. It needs .withDataCache.
  • If the URL asks the CDN for a size, put the size in imageID too. Otherwise two sizes share one entry.
  • A processor with a changing identifier: nothing is ever a hit. The identifier has to be a constant that describes what the processor does, and not a UUID() or a date.
  • Sizes from GeometryReader: each different width is a new image in every cache. Round the widths to a few sizes that the app actually uses.

Disk cache

The default pipeline has URLCache, which follows cache-control, and is limited to 150 MB. Check which of these the images are, because the wrong one is a bug and not a slow screen:

ImagesUseWhy
Never change at a URL.withDataCacheFaster, works offline, no revalidation
Can change at a stable URL, like an avatarThe default URLCache.withDataCache ignores cache-control, and shows the old image until it's evicted
BothA pipeline for eachThe rules are per pipeline, and these two can't share one
var configuration = ImagePipeline.Configuration.withDataCache(name: "com.example.images", sizeLimit: 500 * 1024 * 1024)
configuration.dataCachePolicy = .automatic
ImagePipeline.shared = ImagePipeline(configuration: configuration)

ImageCache doesn't read cache-control either: an image that changes at a stable URL stays in memory until it's evicted, however short its max-age. Give those a memory cache of their own, with a ttl, on a second pipeline. Pass it to the views with .pipeline(Pipelines.avatars), and to a prefetcher with ImagePrefetcher(pipeline: Pipelines.avatars):

enum Pipelines {
    static let avatars = ImagePipeline {
        let cache = ImageCache()
        cache.ttl = 120
        $0.imageCache = cache
    }
}

If images fall out of the cache while the app is in use, the limit is too small. That shows up as from network for images seen earlier in the session.

dataCachePolicy decides what's stored:

PolicyStoresUse when
.storeOriginalData, the defaultThe downloadThe app shows an image at several sizes. Each size is processed again after a relaunch
.automaticThe processed image, for a request with processorsEach URL is shown at one size. A relaunch skips the resize, and the disk holds less
.storeAllBothSeveral sizes, and the CPU matters more than the disk

Under .automatic, a second size of the same URL goes back to the network, and a .diskCache prefetch of a request with processors stores nothing.

Prefetching

private let prefetcher = ImagePrefetcher()

func collectionView(_ collectionView: UICollectionView, prefetchItemsAt indexPaths: [IndexPath]) {
    prefetcher.startPrefetching(with: indexPaths.map { makeRequest(for: items[$0.item]) })
}

func collectionView(_ collectionView: UICollectionView, cancelPrefetchingForItemsAt indexPaths: [IndexPath]) {
    prefetcher.stopPrefetching(with: indexPaths.map { makeRequest(for: items[$0.item]) })
}

private func makeRequest(for item: Item) -> ImageRequest {
    ImageRequest(url: item.url, processors: [.resize(width: 120)])
}

The prefetcher and the view must build the same request. With different processors, the image gets decoded and cached twice, and the prefetching does nothing for the view. Keep one function that makes the request, imageID included, and call it from both.

SwiftUI has no prefetch API, so start from onAppear. startPrefetching skips what's cached or already running, so repeated calls are cheap:

struct Feed: View {
    let items: [Item]
    @State private var prefetcher = ImagePrefetcher()

    var body: some View {
        List(items.indices, id: \.self) { index in
            LazyImage(request: makeRequest(for: items[index]))
                .onAppear {
                    let next = items.dropFirst(index + 1).prefix(8)
                    prefetcher.startPrefetching(with: next.map(makeRequest))
                }
        }
    }

    private func makeRequest(for item: Item) -> ImageRequest {
        ImageRequest(url: item.url, processors: [.resize(width: 120)])
    }
}

The prefetcher runs at .low, 2 at a time, and cancels what's left when it's deallocated, so keep one per screen. In a SwiftUI view it has to be in @State: a let is made again with the view, which cancels the work of the old one. Set isPaused = true when another screen covers it, or when NWPath.isConstrained says the user is saving data. ImagePrefetcher(destination: .diskCache) skips the decode, which suits large images, but it needs .storeOriginalData or .storeAll.

Load only what's on screen

LazyImage starts when it appears, so a VStack of 500 rows in a ScrollView starts 500 requests. Use List, LazyVStack, or LazyVGrid.

LazyImage cancels when it disappears, which is right for fast scrolling, and an interrupted download resumes if the server supports it. For large images that a user is likely to scroll back to, keep them going:

LazyImage(url: url)
    .onDisappear(.lowerPriority)

For what matters most on the screen, use .priority(.high), or ImageRequest(url:, priority: .high). A running ImageTask takes a new priority with task.priority = .low, which suits the grid behind a full-screen image.

If the queue share is high for the images on screen, prefetching is holding slots. Low-priority work runs in at most 3 of the 6 slots. Reserve 4 for large images, or 0 to 2 if prefetching has to keep up with thumbnails:

ImagePipeline.shared = ImagePipeline {
    $0.dataLoadingQueue = TaskQueue(maxConcurrentTaskCount: 6, reservedTaskCount: 4)
}

Memory

ImageCache takes up to 15% of the device's memory, capped at 768 MB, and clears itself on a memory warning. High memory is nearly always oversized images, so fix those first. Then:

ImageCache.shared.costLimit = 100 * 1024 * 1024

The default comes from the device, not the process. In an app extension or a widget, set the limit yourself, because the default can be larger than all the memory the extension is allowed. It also has to load small images: one full-size 12 MP decode is 46 MB, over the limit of a widget, whatever the cache does. ImageCache refuses an image that takes more than 10% of its limit (entryCostLimit), so a small limit stops caching large images.

The main thread

pipeline.cache[request] reads memory, and is safe anywhere. cachedImage(for:), containsCachedImage(for:), and cachedData(for:) read the disk by default, and cachedImage also decodes. Off the main thread, or limit it:

let cached = ImagePipeline.shared.cache.cachedImage(for: request, caches: [.memory])

Create URLs when you parse the data, not in a view: URL(string:) isn't free in a long list.

Animated images

  • The frames are decoded no larger than the view displays them, so don't resize. This saves memory and not CPU: a smaller frame takes the same time to decode.
  • If a URL can be a GIF or a large still, skip the animations in a processor of your own:
struct ResizeStills: ImageProcessing {
    let resize = ImageProcessors.Resize(width: 320)

    var identifier: String { "com.example.stills" + resize.identifier }

    func process(_ image: PlatformImage) -> PlatformImage? {
        resize.process(image)
    }

    func process(_ container: ImageContainer, context: ImageProcessingContext) throws -> ImageContainer {
        guard container.animation == nil else { return container }
        return try resize.process(container, context: context)
    }
}
  • If you control the assets, a short video with NukeVideo is usually smaller than a GIF, and it's decoded in hardware.
  • For a screen full of animations, show stills until someone asks: use state.image in the content closure of LazyImage, or set isPlaybackEnabled = false on AnimatedImageView.
  • The frames share a pool, AnimatedImageFramePool, sized at 5% of the device's memory, up to 128 MB. An animation that doesn't fit plays from a window of three frames, and pays to decode them on every loop.

Leave these alone

Each of these has been measured, or exists for a reason. If the code has changed one, find out why, and restore the default unless it's explained.

SettingDon't
isTaskCoalescingEnabledTurn it off. It's why two views of one image make one download
isResumableDataEnabledTurn it off. It costs nothing unless a download is interrupted
isDecompressionEnabled, .skipDecompressionDisable them on iOS. The decode moves to the main thread, at the first draw
isUsingPrepareForDisplayDisable it. It made decoding 41–45% faster on an iOS simulator
dataLoadingQueueRaise it past 6. In a simulation with shared bandwidth, 7 slots saved 7% for large images and nothing on HTTP/1.1. Use reservedTaskCount instead
imageProcessingQueue, imageDecompressingQueueRaise them. They compete with the main thread for CPU
isProgressiveDecodingEnabledEnable it for thumbnails. A progressive JPEG or a GIF gets decoded again every 0.5 s of its download
isAnimatedImageParsingEnabledDisable it for speed. It only runs for animated data
LazyImageReplace it with something hand-written for speed. It costs about 80 µs a cell over a plain Image, and the time is elsewhere

Report

Give the before and after for what you measured, and say which changes you couldn't measure. List the defaults you restored, and any trade-offs you took, such as stale images under .withDataCache.

On Nuke 13

The log, task.metrics, and diagnostics don't exist, so measure with Instruments or Pulse, and read the code. reservedTaskCount doesn't exist. isUsingPrepareForDisplay is off, and turning it on is a free win. TaskQueue uses maxConcurrentOperationCount. Upgrading helps more than any of this: see the nuke-migrate skill.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

Reviews can't be read right now.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.