Rendering a big image on iOS is one of those things that looks trivial until your app gets killed by the OS mid-export. CGContext, draw, makeImage(), done — except the moment the output gets large, that innocent-looking pipeline quietly asks for gigabytes of RAM and iOS terminates you.
I hit this wall building Mozary, an iOS app that packs 100+ photos into a single giant picture (a photo mosaic). In v1.1.0 I finally killed the "high-resolution export crashes with out-of-memory" bug for good.
The fix: stop putting the canvas in RAM at all. Put it in a memory-mapped file and let the OS page it to disk. RAM usage dropped from "4.8 GB, please die" to a few dozen MB, flat, regardless of output size.
This post is the walkthrough — with the actual Swift. If you've ever seen Core Graphics blow up on a big image (mosaics, collages, stitched panoramas, high-res rendering — same trap), this is for you.
TL;DR
- A non-compressed bitmap costs 4 bytes/pixel. A 1.2-gigapixel image = ~4.8 GB of RAM just for the canvas.
-
CGContext(data: nil, ...)allocates that in RAM.context.makeImage()then copies it again. Double death. - Back the canvas with a memory-mapped file (
mmap). Writes transparently page out to disk and don't count against your app's memory footprint. - Wrap that same mapping in a
CGImageviaCGDataProvider— zero copy — and stream it straight to a JPEG on disk. Never callmakeImage(). - Decode source tiles at their draw size, not full size.
- Because you now spend disk instead of RAM: add a free-space pre-flight check and clean up temp files after a crash.
Let's dig in.
The problem: "compressed file size" is a lie about memory
Mozary lays photos out on a grid. A typical high-res export is a 200 × 267 grid with each tile drawn at 150px:
width: 200 × 150 = 30,000 px
height: 267 × 150 = 40,050 px
That's ~1.2 gigapixels.
Here's the part people underestimate: the final JPEG is only a few hundred MB to ~2 GB because JPEG is compressed. But while you're drawing, the canvas is uncompressed — 4 bytes per pixel (RGBA):
30,000 × 40,050 × 4 bytes ≈ 4.8 GB
4.8 GB of RAM for the canvas alone.
iOS caps how much memory an app may use (varies by device, but you'll get jetsammed somewhere in the few-hundred-MB-to-~2-GB range). 4.8 GB is never happening. That's the crash.
The naive way (what I had before)
My original export looked like this — and honestly, it was brute force:
// ❌ OLD: allocate the giant bitmap in RAM
// "Pre-flight": compute how many bytes the bitmap needs
let bitmapBytes = UInt64(imgWidth) * UInt64(imgHeight) * 4
let availableMemory = os_proc_available_memory()
if UInt64(Double(bitmapBytes) * 1.3) > availableMemory {
// Not enough → just give up on high-res
throw NSError(domain: "MediaExporter", code: -10, ...)
}
// data: nil → CGContext allocates the bitmap in RAM
let context = CGContext(
data: nil,
width: imgWidth,
height: imgHeight,
bitsPerComponent: 8,
bytesPerRow: imgWidth * 4,
space: colorSpace,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
)!
// ... draw all the tiles ...
// makeImage() duplicates the whole bitmap (another +4.8 GB)
let cgImage = context.makeImage()!
Two problems:
-
CGContext(data: nil, ...)allocates the canvas in RAM → asks for 4.8 GB. -
context.makeImage()copies the canvas again → +4.8 GB at peak.
The os_proc_available_memory() check wasn't a fix — it was a polite "sorry, give up." High-res export only worked on the beefiest devices, and mid-tier sizes could sneak past the check and then crash on the makeImage() copy anyway.
I needed to render the same pixels without ever holding them all in RAM.
The fix: put the canvas on disk, not in RAM
The reframe that unlocked everything:
If 4.8 GB won't fit in RAM, don't put it in RAM.
Enter the memory-mapped file (mmap).
What mmap actually buys you
mmap maps a file into your address space. You get a pointer you read and write like normal memory — but the backing store is a file on disk.
The magic is that the OS manages that region in pages:
- Pages you write become "dirty" and the OS writes them back to disk as needed.
- Once written back (clean), those pages no longer count against your app's memory footprint (the thing that gets you jetsammed).
So even if the canvas is 4.8 GB, the only thing resident in RAM is the handful of pages you're touching right now. Everything else lives on disk. You never trip the memory limit.
In one sentence: I stopped trying to fit the work in the app's own memory, and the crash disappeared.
Step 1 — create the file and map it
let ppc = size.effectivePixelsPerCell(columns: columns, rows: rows)
let imgWidth = columns * ppc
let imgHeight = rows * ppc
let bytesPerRow = imgWidth * 4
let totalBytes = bytesPerRow * imgHeight // ← can be ~4.8 GB
// 1. Create a temp file
let canvasURL = FileManager.default.temporaryDirectory
.appendingPathComponent("mozary_canvas_\(UUID().uuidString).raw")
FileManager.default.createFile(atPath: canvasURL.path, contents: nil)
// 2. Open it and grow it to the needed size (ftruncate)
let fd = open(canvasURL.path, O_RDWR)
guard fd >= 0, ftruncate(fd, off_t(totalBytes)) == 0 else {
if fd >= 0 { close(fd) }
try? FileManager.default.removeItem(at: canvasURL)
throw NSError(domain: "MediaExporter", code: -11, ...)
}
// 3. Map the file into memory
let mapped = mmap(nil, totalBytes, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0)
close(fd) // The mapping keeps the file alive, so we can close the fd
guard let canvas = mapped, canvas != MAP_FAILED else {
try? FileManager.default.removeItem(at: canvasURL)
throw NSError(domain: "MediaExporter", code: -12, ...)
}
// 4. ALWAYS release the mapping, on every exit path
defer {
munmap(canvas, totalBytes)
try? FileManager.default.removeItem(at: canvasURL)
}
ftruncate grows the file to totalBytes, but it creates a sparse file — disk is only actually consumed for the bytes you write.
Step 2 — hand the mapping to CGContext
CGContext lets you pass your own buffer pointer as data:. Just give it the mmap pointer:
guard let context = CGContext(
data: canvas, // ← the mmap'd file, not RAM
width: imgWidth,
height: imgHeight,
bitsPerComponent: 8,
bytesPerRow: bytesPerRow,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else {
throw NSError(...)
}
// Then just... draw. The destination is transparently disk-backed;
// the Core Graphics code doesn't change at all.
for row in 0..<rows {
try Task.checkCancellation()
for col in 0..<columns {
// ... context.draw(tileCGImage, in: rect) ...
}
}
This is the beautiful part: from Core Graphics' point of view, nothing changed. It thinks it's drawing into a plain memory buffer. The writes just quietly spill to disk.
Step 3 — nudge finished rows out to disk
The OS will page dirty pages out on its own, but you can help it: tell it explicitly "I'm done with this region, feel free to flush it." That keeps the set of dirty (not-yet-written-back) pages small. The tool is msync:
if row % 10 == 0 {
progress(0.2 + cellProgress * 0.65)
// Every 40 rows, async-flush the scanlines we've already finished.
if row % 40 == 0, row > 0 {
let flushedBytes = (row * ppc) * bytesPerRow
msync(canvas, flushedBytes, MS_ASYNC) // MS_ASYNC = non-blocking
}
}
Why (row * ppc) * bytesPerRow? A bitmap is a single contiguous byte array in memory — rows laid out top to bottom. My draw loop goes top row → bottom row, so once I'm on row N, every row above it is finished and lives at the start of the buffer:
buffer: [==== finished ====][ drawing ][=== empty ===]
↑ canvas ↑
|←── flushedBytes ──→|
msync(MS_ASYNC) on this range =
"you can write these pages back now"
-
row * ppc→ number of finished pixel rows (ppc= pixels per cell) -
× bytesPerRow→ convert rows to bytes
One nuance worth stating precisely: MS_ASYNC doesn't free memory directly. It marks those pages clean (safely written to disk). Clean pages are the ones the OS can evict for free when it needs RAM back — because an identical copy already exists on disk. So msync doesn't reclaim memory; it makes memory reclaimable, which keeps your peak footprint low.
The other trap: makeImage() copies everything
Second landmine: encoding the JPEG.
Remember context.makeImage() duplicates the whole bitmap. Call it here and you've just asked for +4.8 GB — undoing everything.
So skip it. Wrap the same mmap region in a CGImage directly, handing the pointer to CGDataProvider with no copy:
// Make sure the whole canvas is synced to disk
msync(canvas, totalBytes, MS_SYNC)
// Wrap the mapping in a CGImage WITHOUT copying it.
// The empty releaseData is intentional: the deferred munmap above
// owns the mapping, and it outlives this scope-local image.
guard let provider = CGDataProvider(
dataInfo: nil,
data: canvas,
size: totalBytes,
releaseData: { _, _, _ in } // no-op
), let cgImage = CGImage(
width: imgWidth,
height: imgHeight,
bitsPerComponent: 8,
bitsPerPixel: 32,
bytesPerRow: bytesPerRow,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue),
provider: provider,
decode: nil,
shouldInterpolate: false,
intent: .defaultIntent
) else {
throw NSError(...)
}
// Write JPEG straight to a file (no UIImage in between)
let tempURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString).appendingPathExtension("jpg")
guard let dest = CGImageDestinationCreateWithURL(
tempURL as CFURL, "public.jpeg" as CFString, 1, nil
) else { throw NSError(...) }
CGImageDestinationAddImage(dest, cgImage, [
kCGImageDestinationLossyCompressionQuality: size.jpegQuality
] as CFDictionary)
guard CGImageDestinationFinalize(dest) else { throw NSError(...) }
return tempURL
CGImageDestinationFinalize reads from the CGImage (i.e. the mmap'd file) while it writes the JPEG. The encoder pulls data in tiles, so no giant buffer ever lands fully in RAM here either.
Result: both ends of the pipeline — the canvas input and the JPEG output — stay off the RAM budget.
Bonus win: decode tiles at draw size, not full size
One more thing that mattered. Mozary stores each tile's thumbnail as a 600px JPEG on disk. Decode all of them at full size for a 1,000-photo mosaic and:
600 × 600 × 4 bytes × 1000 photos ≈ 1.4 GB
1.4 GB just for the tile cache — but I only ever draw them at cell size (ppc, 60–150px). So decode them straight to that size with CGImageSourceCreateThumbnailAtIndex:
let thumbOptions = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: ppc // ← decode at cell size
] as CFDictionary
// CGImageSourceCreateThumbnailAtIndex(source, 0, thumbOptions)
At 100px a tile is ~40 KB decoded. 1,000 of them fit in a few dozen MB. Never decode at a higher resolution than you'll draw — downsampling on decode is a Core Graphics rule of thumb worth tattooing somewhere.
Spending disk means guarding disk
Trading RAM for disk creates a new constraint: free disk space. During export the uncompressed canvas (4.8 GB) and the JPEG being written (~2 GB) coexist — a peak of ~7 GB.
So I check the actual working space needed, not the final file size, before starting:
// The final "~2 GB" file size badly understates what export transiently needs.
let requiredBytes = size.requiredWorkingBytes(columns: columns, rows: rows)
let freeSpace = (try? FileManager.default.temporaryDirectory
.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey])
.volumeAvailableCapacityForImportantUsage) ?? 0
if freeSpace > 0, freeSpace < requiredBytes {
throw NSError(...) // "needs about N GB of temporary free space"
}
func requiredWorkingBytes(columns: Int, rows: Int) -> Int64 {
let ppc = effectivePixelsPerCell(columns: columns, rows: rows)
let pixels = Double(columns * ppc) * Double(rows * ppc)
// uncompressed canvas (4B/px) + JPEG being encoded + safety margin
return Int64(pixels * 4 + pixels * estimatedBytesPerPixel) + 500_000_000
}
I actually found this the hard way. On a test device with ~2 GB free, the export failed even though my original check (which only accounted for the final file) said there was room. Testing on a nearly-full device is what surfaced it — the real working set needs the transient space, obviously in hindsight. Glad I debugged on a cramped device.
And if the app gets killed mid-export, the defer cleanup never runs and a 2 GB+ temp file is orphaned — which would then eat into the next export's disk check. So I sweep old canvas files on start:
// Sweep temp canvases orphaned by a previous crash
if let leftovers = try? FileManager.default.contentsOfDirectory(
at: FileManager.default.temporaryDirectory, includingPropertiesForKeys: nil
) {
for url in leftovers where url.lastPathComponent.hasPrefix("mozary_canvas_") {
try? FileManager.default.removeItem(at: url)
}
}
(I also clamp cell size so no dimension exceeds JPEG's 65,535px limit, plus a few other small guards.)
Results
| Before | After | |
|---|---|---|
| High-res export | Rejected by pre-flight, or crashed on some devices | Works |
| Peak RAM | ~4.8 GB (canvas) + ~4.8 GB (makeImage copy) |
A few dozen MB, flat |
| Depends on output size? | Yes — bigger = death | No — constant footprint |
Ordinary iPhones now export 1.2-gigapixel mosaics without breaking a sweat.
Takeaways
- On iOS, drawing a big image blows up because the uncompressed canvas (4 B/px) exceeds the memory limit — the compressed file size tells you nothing about that.
-
Back the canvas with
mmapso writes page out to disk and stay off your app's memory footprint. - For the output
CGImage, avoidcontext.makeImage()(it copies) — wrap the mmap region withCGDataProviderfor a zero-copy hand-off, then stream to disk withCGImageDestination. - Decode inputs at draw resolution, never larger.
- Once you spend disk instead of RAM, pair it with a free-space pre-flight and post-crash cleanup.
The general principle — "if it won't fit in RAM, back it with a file and let the OS page it" — goes way beyond Core Graphics. Any time you're wrangling a buffer bigger than memory, mmap is worth reaching for.
If you've fought the same "Core Graphics eats all my memory" battle, I hope this saves you a few crash reports.
Try it out!
Mozary is on the App Store if you want to see the export in action (and I'd genuinely love feedback):
👉 https://apps.apple.com/us/app/mozary/id6759013945
What's your go-to trick for large-image or large-buffer work on mobile? I'd love to hear it in the comments.
Top comments (0)