On paper, "back up my photos" is a one-line feature. In practice on iOS, it touches PhotoKit, background URLSessions, BGTaskScheduler, content hashing, deduplication, watermarks, and battery policy. Get any one of them wrong and the user either ends up with duplicate uploads, missed photos, or a battery that dies mid-afternoon.
Here's the pipeline we landed on, in the order events fire.
PhotoKit observer, not the picker
A picker (PHPickerViewController) is great for one-off uploads but useless for "back up everything new." For continuous backup, we register a PHPhotoLibraryChangeObserver against the user's full library. Every time the camera roll mutates — new photo, new video, edit, delete — we get a callback with the delta.
The observer fires on a background queue while the app is in the foreground. To handle photos taken while we're suspended, we also pull a delta on app launch using the watermark.
The watermark: a single date
Instead of tracking every uploaded asset individually, we store a single timestamp in UserDefaults: thecreationDate of the most recently uploaded photo. On each run we query PhotoKit for assets withcreationDate > watermark and enqueue those.
This is dramatically simpler than per-asset state and survives everything — app reinstalls (we re-discover the watermark from the server side), library scrubs, even iCloud Photos shuffling.
Content hashing for dedup
Watermarks alone aren't enough. A user might enable Camera Backup, disable it, manually upload a photo, then re-enable backup. Without dedup, that photo lands twice.
We compute a streaming SHA-256 of the asset's bytes as we read them from PhotoKit. If the hash already exists in the workspace, we skip the upload and just link the existing file. Two identical photos from two devices end up referencing the same blob.
Background URLSession: survive app kill
Foreground URLSessions die when the app is suspended. For a 4 GB video on a coffee-shop Wi-Fi connection, that's a deal-breaker. So every upload runs on a URLSession configured with URLSessionConfiguration.background(withIdentifier:).
- The system takes over the transfer — it continues even if the user kills the app.
- Progress is delivered via the URLSession delegate, which we plumb back into the Photos banner.
- We pre-sign upload URLs at enqueue time so the worker never needs an auth token mid-flight.
BGTaskScheduler for the long tail
Two task identifiers are registered in the project:us.virtualdrive.sync and us.virtualdrive.backup. iOS occasionally wakes the app in the background to drain whatever's left in the queue — typically plugged-in-and-on-Wi-Fi at night.
User controls: respect, then default
Camera Backup is opt-in. When enabled, defaults are:
- Wi-Fi only, unless the user explicitly enables cellular.
- Skipped when battery is below 20% and the device is unplugged.
- Pausable from Settings → Sync (the Photos banner reflects the paused state in real time).
We think the only acceptable answer to "should this drain my battery?" is "absolutely not." So we err on the side of pausing.
What we got out of this
A backup pipeline that finishes even when the user force-quits the app, that doesn't duplicate uploads, and that doesn't tank the battery. The Photos tab shows live progress with an ETA. New devices re-discover what's already backed up and resume cleanly.
It's not glamorous engineering, but it's the kind that ships.