Why do I write about this?

I had an issue in my CasaZurich app where the metadata images downloaded from third-party websites were too big for my iOS widgets. So I had to come up with a solution to fix this and reduce both the image size and its quality.

Here are some rules you must follow if you want to showcase an image in iOS widgets:

  • Use small image dimensions (e.g. ≤ 1024px width).
  • Keep file size low (ideally < 1MB).
  • Prefer PNG or JPEG formats.
  • Remove unnecessary metadata (EXIF, GPS).
  • Use 1x scale (no @2x/@3x).
  • Avoid transparency in non-supported widget areas.

Based on those rules, I wrote an image processing service in Swift where I can pass a Data object and process the image based on my configuration.

The struct responsible for passing config information to my processing service:

import CoreGraphics
import Foundation

public struct ImageProcessingConfiguration: Sendable, Hashable {
    public let maxWidth: CGFloat?
    public let maxHeight: CGFloat?
    public let compressionQuality: CGFloat
    public let scaleFactor: CGFloat
    public let preferredFormat: ImageFormat

    public enum ImageFormat: Sendable, Hashable {
        case original
        case jpeg
    }

    public init(
        maxWidth: CGFloat? = nil,
        maxHeight: CGFloat? = nil,
        compressionQuality: CGFloat = 1.0,
        scaleFactor: CGFloat = 1.0,
        preferredFormat: ImageFormat = .original
    ) {
        self.maxWidth = maxWidth
        self.maxHeight = maxHeight
        self.compressionQuality = max(0.0, min(1.0, compressionQuality))
        self.scaleFactor = max(1.0, scaleFactor)
        self.preferredFormat = preferredFormat
    }

    public static let `default` = ImageProcessingConfiguration()

    public static let highDefinition = ImageProcessingConfiguration(
        maxWidth: 1920,
        maxHeight: 1080,
        compressionQuality: 1.0,
        scaleFactor: 1.0,
        preferredFormat: .jpeg
    )

    public static let thumbnail = ImageProcessingConfiguration(
        maxWidth: 1280,
        maxHeight: 720,
        compressionQuality: 1.0,
        scaleFactor: 1.0,
        preferredFormat: .jpeg
    )

    public static let compressedThumbnail = ImageProcessingConfiguration(
        maxWidth: 1280,
        maxHeight: 720,
        compressionQuality: 0.8,
        scaleFactor: 1.0,
        preferredFormat: .jpeg
    )

    public static let favicon = ImageProcessingConfiguration(
        maxWidth: 64,
        maxHeight: 64,
        compressionQuality: 1.0,
        scaleFactor: 1.0,
        preferredFormat: .jpeg
    )

    public static let compressedFavicon = ImageProcessingConfiguration(
        maxWidth: 64,
        maxHeight: 64,
        compressionQuality: 0.8,
        scaleFactor: 1.0,
        preferredFormat: .jpeg
    )
}

The service responsible for processing and modifying the image data to the target configuration:

//
//  ImageProcessingService.swift
//  Services
//
//  Created by Luca Archidiacono on 20.07.2025.
//

import CoreGraphics
import Foundation
import ImageIO
import Logger
import UIKit
import UniformTypeIdentifiers

public final class ImageProcessingService: Sendable {
    private let logger = Logger(label: String(describing: ImageProcessingService.self))
    private let configuration: ImageProcessingConfiguration

    public init(
        configuration: ImageProcessingConfiguration = .default,
    ) {
        self.configuration = configuration
    }

    public nonisolated func processImageData(_ imageData: Data) async -> UIImage? {
        // autoreleasepool ensures temporary objects are released immediately after processing
        autoreleasepool {
            // kCGImageSourceShouldCache: false prevents caching full decoded image in memory
            let sourceOptions: [CFString: Any] = [
                kCGImageSourceShouldCache: false,
            ]

            guard let imageSource = CGImageSourceCreateWithData(imageData as CFData, sourceOptions as CFDictionary)
            else { return nil }

            let cgImage: CGImage

            // Use thumbnail API for downsampling - never allocates full bitmap in memory
            if let maxDimension = maxPixelSize(from: configuration) {
                let thumbnailOptions: [CFString: Any] = [
                    kCGImageSourceCreateThumbnailFromImageAlways: true,
                    kCGImageSourceShouldCacheImmediately: true,
                    kCGImageSourceCreateThumbnailWithTransform: true,
                    kCGImageSourceThumbnailMaxPixelSize: maxDimension,
                ]

                guard let thumbnail = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, thumbnailOptions as CFDictionary)
                else { return nil }

                cgImage = thumbnail
            } else {
                // No resize needed - load as-is but still avoid caching
                guard let original = CGImageSourceCreateImageAtIndex(imageSource, 0, sourceOptions as CFDictionary)
                else { return nil }

                cgImage = original
            }

            // Compress if JPEG format requested
            let finalCGImage = compressCGImage(cgImage, configuration: configuration) ?? cgImage

            return UIImage(cgImage: finalCGImage)
        }
    }

    private func maxPixelSize(from configuration: ImageProcessingConfiguration) -> CGFloat? {
        let maxWidth = configuration.maxWidth.map { $0 / configuration.scaleFactor }
        let maxHeight = configuration.maxHeight.map { $0 / configuration.scaleFactor }

        switch (maxWidth, maxHeight) {
        case let (.some(w), .some(h)):
            return max(w, h)
        case let (.some(w), .none):
            return w
        case let (.none, .some(h)):
            return h
        case (.none, .none):
            return nil
        }
    }

    private func compressCGImage(
        _ cgImage: CGImage,
        configuration: ImageProcessingConfiguration,
    ) -> CGImage? {
        // If no compression requested, just return input
        switch configuration.preferredFormat {
        case .original:
            return cgImage
        case .jpeg:
            break
        }

        // Encode to requested data format
        let data = NSMutableData()
        let destinationUTType = UTType.jpeg.identifier as CFString
        guard let destination = CGImageDestinationCreateWithData(data, destinationUTType, 1, nil) else {
            return nil
        }

        let properties: [CFString: Any] = [
            kCGImageDestinationLossyCompressionQuality: configuration.compressionQuality,
        ]

        CGImageDestinationAddImage(destination, cgImage, properties as CFDictionary)

        guard CGImageDestinationFinalize(destination) else {
            return nil
        }

        // Decode back to CGImage
        guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil),
              let compressedCGImage = CGImageSourceCreateImageAtIndex(imageSource, 0, nil)
        else {
            return nil
        }

        return compressedCGImage
    }
}

With this utility, you can adjust the image data to your liking, whether that’s resizing, recompressing, or changing its format on the fly. One small detail worth noting: the processing is wrapped in an autoreleasepool so that temporary objects are released immediately, which keeps memory consumption low during the operation. However, keep in mind that the core memory issue isn’t truly solved: you still need to download the whole image and keep it resident in memory during processing.