DEV Community

Maxwell
Maxwell

Posted on

Exporting 100 Photos on an iPhone Without Getting Killed: Memory Notes for Batch Image Processing in Swift

Processing one photo on an iPhone is easy. Processing a hundred in a row is where apps quietly die.

There's often no crash log in the usual sense. The app just vanishes partway through the batch, and if you're attached to Xcode you see "Terminated due to memory issue". iOS gives each app a memory budget, and when you go over it, the system ends the process. No exception, no second chance.

I work on an iPhone app that applies watermarks and filters to whole sets of photos, so batch export is the main path, not an edge case. These are the notes I wish I'd had at the start: why memory grows, and the handful of patterns that keep it flat.

The numbers that matter

A photo's file size tells you almost nothing about its memory cost. A HEIC from an iPhone might be 2–3 MB on disk, but once it's decoded into pixels, it costs width × height × 4 bytes (for 8-bit RGBA):

  • 12 MP (4032 × 3024): about 49 MB
  • 48 MP (8064 × 6048): about 195 MB

And that's one copy. A naive pipeline often holds several at once: the decoded source, an intermediate after the filter, the composited output, and the encoder's buffer. Do that for a few 48 MP photos at the same time and you've used a large slice of what the system will allow.

So the goal isn't to make each photo cheaper. It's to make sure only one photo's worth of memory is alive at any moment, and that it's released before the next one starts.

1. Process one photo at a time

It's tempting to fire off a TaskGroup and process the whole batch in parallel. On a desktop, maybe. On a phone, with full-resolution images, parallelism multiplies your peak memory by the number of photos in flight, and the GPU is already doing most of the heavy work anyway.

A plain sequential loop is the safest default. If you profile and find you're waiting on I/O, try two in flight, not ten.

2. Ask Photos for data, not a UIImage

PHImageManager.requestImage(for:targetSize:...) hands you a decoded UIImage. For thumbnails that's fine. For export, you want the encoded bytes so you control when and how decoding happens:

import Photos

func loadImageData(for asset: PHAsset) async throws -> Data {
    let options = PHImageRequestOptions()
    options.isNetworkAccessAllowed = true      // the original may be in iCloud
    options.deliveryMode = .highQualityFormat  // the handler is called once
    options.version = .current                 // include the user's edits

    return try await withCheckedThrowingContinuation { continuation in
        PHImageManager.default().requestImageDataAndOrientation(
            for: asset,
            options: options
        ) { data, _, _, info in
            if let data {
                continuation.resume(returning: data)
            } else {
                let error = info?[PHImageErrorKey] as? Error
                    ?? CocoaError(.fileReadUnknown)
                continuation.resume(throwing: error)
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

.highQualityFormat matters for more than quality. With .opportunistic, the handler can be called twice (a degraded image first, then the full one), and resuming a continuation twice is a crash.

3. Use one CIContext for the whole batch

A CIContext is expensive to create. It sets up GPU resources and caches. Creating one per photo is a common mistake that costs both time and memory. Make one, keep it for the batch, and turn off intermediate caching, since you'll never render the same image twice:

import CoreImage
import ImageIO

final class BatchRenderer {
    private let context = CIContext(options: [.cacheIntermediates: false])

    func render(
        _ data: Data,
        apply: (CIImage) -> CIImage,
        to url: URL
    ) throws {
        try autoreleasepool {
            guard let input = CIImage(
                data: data,
                options: [.applyOrientationProperty: true]
            ) else {
                throw CocoaError(.fileReadCorruptFile)
            }

            let output = apply(input)
            let colorSpace = input.colorSpace
                ?? CGColorSpace(name: CGColorSpace.sRGB)!

            try context.writeHEIFRepresentation(
                of: output,
                to: url,
                format: .RGBA8,
                colorSpace: colorSpace,
                options: [
                    kCGImageDestinationLossyCompressionQuality as CIImageRepresentationOption: 0.9
                ]
            )
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

A few details in there are doing real work:

  • .applyOrientationProperty: true rotates the image upright when it's loaded. Without it, portrait photos come out sideways, and any watermark you position "bottom right" lands in the wrong corner.
  • Keeping the source colour space means a Display P3 photo stays Display P3 instead of being flattened to sRGB on export.
  • Writing straight to a file with writeHEIFRepresentation avoids holding the encoded output as a Data in memory and then writing it again.

4. Wrap each photo in autoreleasepool

Core Image, ImageIO and Photos still create a lot of autoreleased Objective-C objects under the hood. In a tight loop, those objects aren't freed until the pool drains, which may not happen until the loop ends. That's how memory climbs steadily across a batch even though your Swift code holds nothing.

autoreleasepool { } around each photo's work drains them every iteration. One catch: you can't await inside an autoreleasepool block, because the closure is synchronous. That's why the render function above is synchronous, and the async loading happens outside it:

func exportBatch(
    _ assets: [PHAsset],
    renderer: BatchRenderer,
    apply: (CIImage) -> CIImage,
    progress: (Int) -> Void
) async throws -> [URL] {
    let folder = FileManager.default.temporaryDirectory
        .appendingPathComponent(UUID().uuidString, isDirectory: true)
    try FileManager.default.createDirectory(
        at: folder,
        withIntermediateDirectories: true
    )

    var outputs: [URL] = []
    for (index, asset) in assets.enumerated() {
        try Task.checkCancellation()                     // let the user stop
        let data = try await loadImageData(for: asset)   // async, outside the pool
        let url = folder.appendingPathComponent("\(index).heic")
        try renderer.render(data, apply: apply, to: url) // sync, inside the pool
        outputs.append(url)
        progress(index + 1)
    }
    return outputs
}
Enter fullscreen mode Exit fullscreen mode

The loop only keeps URLs, which are tiny. Each photo's Data goes out of scope at the end of its iteration.

5. Build the edit so it scales with the photo

The apply closure is where your actual feature lives. Here's a logo watermark that's sized relative to the photo's short side, so it looks the same on a 12 MP photo and a cropped 2 MP one:

func stampLogo(
    _ logo: CIImage,
    widthFraction: CGFloat = 0.18,
    marginFraction: CGFloat = 0.04
) -> (CIImage) -> CIImage {
    return { image in
        let extent = image.extent
        let shortSide = min(extent.width, extent.height)

        let scale = (shortSide * widthFraction) / logo.extent.width
        let scaled = logo.transformed(by: CGAffineTransform(scaleX: scale, y: scale))

        let inset = shortSide * marginFraction
        let x = extent.maxX - scaled.extent.width - inset
        let y = extent.minY + inset   // Core Image's origin is bottom-left

        let placed = scaled.transformed(by: CGAffineTransform(
            translationX: x - scaled.extent.minX,
            y: y - scaled.extent.minY
        ))
        return placed.composited(over: image).cropped(to: extent)
    }
}
Enter fullscreen mode Exit fullscreen mode

Create the logo CIImage once, before the loop. Core Image is lazy, so this closure only builds a recipe. Nothing is rendered until writeHEIFRepresentation runs, which is why there's no extra full-size buffer per filter step.

6. Hand the files to Photos in chunks

Saving with UIImageWriteToSavedPhotosAlbum means decoding the image again into a UIImage. Instead, give Photos the file you already wrote, and let it move the file rather than copy it:

func saveToPhotos(_ urls: [URL], chunkSize: Int = 20) async throws {
    let chunks = stride(from: 0, to: urls.count, by: chunkSize).map {
        Array(urls[$0..<min($0 + chunkSize, urls.count)])
    }
    for chunk in chunks {
        try await PHPhotoLibrary.shared().performChanges {
            for url in chunk {
                let request = PHAssetCreationRequest.forAsset()
                let options = PHAssetResourceCreationOptions()
                options.shouldMoveFile = true
                request.addResource(with: .photo, fileURL: url, options: options)
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Chunks keep each change request small, and if something fails, you know roughly where. You only need add-only access for this, so request PHPhotoLibrary.requestAuthorization(for: .addOnly) rather than full library access. Users are more willing to say yes to that.

7. Previews are a separate problem

The grid of thumbnails at the top of a batch screen can use more memory than the export if every cell holds a full-size image. For previews, either ask PHImageManager for a targetSize that matches the cell, or downsample with ImageIO:

func thumbnail(from data: Data, maxPixelSize: Int) -> CGImage? {
    let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
    guard let source = CGImageSourceCreateWithData(data as CFData, sourceOptions) else {
        return nil
    }
    let options = [
        kCGImageSourceCreateThumbnailFromImageAlways: true,
        kCGImageSourceCreateThumbnailWithTransform: true,
        kCGImageSourceShouldCacheImmediately: true,
        kCGImageSourceThumbnailMaxPixelSize: maxPixelSize
    ] as CFDictionary
    return CGImageSourceCreateThumbnailAtIndex(source, 0, options)
}
Enter fullscreen mode Exit fullscreen mode

This decodes straight to the small size. It never builds the full-resolution bitmap first.

How to check you've actually fixed it

Don't trust a feeling. Measure:

  • Xcode's memory gauge during a 100-photo export should look like a flat line with small bumps, not a staircase.
  • Instruments → Allocations, with "Mark Generation" between photos, shows exactly what survives each iteration.
  • os_proc_available_memory() (import os) tells you how much headroom you have left at runtime. Logging it every ten photos in a debug build is a cheap early warning.
  • Test on your oldest supported device, with 48 MP photos. A new Pro phone with a lot of RAM will hide problems your users will hit.

The short version

One photo at a time. Load data, not images. One CIContext. A sync render inside autoreleasepool. Write to disk, then let Photos move the file. Downsample previews.

None of these are clever, and that's the point. Batch processing on a phone is mostly about not doing extra work, and making sure what you do finishes before the next photo starts.

These notes come out of building BatchMark, where you set a text or logo watermark once and export a set of up to 100 photos on the device. If you want to see what that flow looks like from the user's side, here's the batch watermark page. And if you've found other memory traps in Core Image or PhotoKit, I'd like to hear about them in the comments.


Disclosure: I'm an independent developer, and BatchMark (灵感图像大师 in Chinese) is my own app. It's free to download on the App Store, with optional paid features.

Top comments (0)