Writing

WidgetKit under a memory budget

A widget extension gets roughly 30 MB of memory. Cross the line and the system doesn’t warn, doesn’t degrade, doesn’t negotiate — it kills the process, your widget goes stale, and the user learns your app is unreliable in the one place they see it most. Building the WidgetKit and Live Activity surfaces for a weather app shipping across four Apple platforms made that lesson concrete: surviving the budget is an architecture problem, not an optimization problem.

Where the memory actually goes

Profile a struggling widget and the ranking is almost always the same: decoded images first, everything else far behind. A 1024×1024 illustration might compress to a 90 KB PNG on disk, but it decodes to a 4 MB bitmap in memory — width times height times four bytes, full stop. The file size on disk is irrelevant. Two or three full-size images in a timeline’s entries and half the budget is gone before a single view renders.

Your Swift code is rarely the problem. The models, the formatting, the SwiftUI view tree combine to typically single-digit megabytes. Images are the budget. Treat them that way.

Downsample at the source

The fix is to never let a bitmap exist at a size no widget will draw. ImageIO can decode straight to a thumbnail without ever materializing the full image:

func downsampled(_ url: URL, maxDimension: CGFloat,
                 scale: CGFloat) -> UIImage? {
    let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
    guard let source = CGImageSourceCreateWithURL(url as CFURL,
                                                  sourceOptions) else {
        return nil
    }
    let options = [
        kCGImageSourceCreateThumbnailFromImageAlways: true,
        kCGImageSourceThumbnailMaxPixelSize: maxDimension * scale,
        kCGImageSourceCreateThumbnailWithTransform: true,
        kCGImageSourceShouldCacheImmediately: true
    ] as CFDictionary
    guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0,
                                                            options) else {
        return nil
    }
    return UIImage(cgImage: cgImage)
}

kCGImageSourceShouldCache: false on the source keeps ImageIO from retaining the full-size decode; the thumbnail is the only bitmap that ever exists. A small widget rendering at its native display size needs nowhere near the source resolution, so decoding straight to that size turns a several-megabyte bitmap into a small fraction of it. Do this for every image, at the exact pixel size each family renders, and the budget suddenly has room to breathe.

Timeline entries are values, so share everything

A timeline with 40 entries doesn’t get 40 budgets; the whole array is resident while WidgetKit archives your views. Two rules keep entries cheap. First, entries carry data, not resources: an image name plus rendering parameters, never a pre-decoded UIImage per entry. Second, anything identical across entries (formatters, gradients, symbol configurations) lives outside the entry as a shared static. The entry should be closer to a database row than an object graph.

Measure the way the system measures

Xcode’s memory gauge shows the app, not the extension, and the simulator does not enforce the widget limit at all. The first widget OOM many teams see is in the field. The number the jetsam machinery actually watches is phys_footprint, and you can read it from inside the extension:

func footprintMB() -> Double {
    var info = task_vm_info_data_t()
    var count = TASK_VM_INFO_COUNT
    let result = withUnsafeMutablePointer(to: &info) {
        $0.withMemoryRebound(to: integer_t.self,
                             capacity: Int(count)) {
            task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO),
                      $0, &count)
        }
    }
    guard result == KERN_SUCCESS else { return 0 }
    return Double(info.phys_footprint) / 1_048_576
}

Logging phys_footprint at the end of every timeline reload in debug builds is a reasonable start. The stronger move is a unit test: render the heaviest timeline of every widget family inside an autoreleasepool and assert that footprint stays under a fixed ceiling, set with real headroom below the documented limit, since the effective limit varies by device and OS release and discovering that variance in production is the expensive way to learn it. A pull request that blows the budget fails CI like any other broken test, which is exactly where a memory regression belongs: caught before review, not after a user’s widget goes blank.

The memory limit isn’t an obstacle to widget development. It’s the system telling you, precisely, how much it thinks a glance is worth.

The short version

  • Images are the budget. Downsample at decode time to the exact rendered size, per family.
  • Entries carry data, not resources. Share every invariant object across entries.
  • Measure phys_footprint on a device. The simulator will happily lie to you.
  • Gate the heaviest timeline in CI with a cushion. The limit is real; treat it like a failing test before the system treats it like a kill.