An offline upload queue that never stalled, and never complained
A persistent offline upload queue for iOS photo and video uploads built on Realm. It solved the problem it was built for and quietly created a worse one.
From 2016 · 3 min read · Published September 6, 2026
In 2016 I was building the upload pipeline for an iOS app where users assembled short video stories from their camera roll: pick photos and clips, add music and a title, and share. The hard part wasn't the UI. It was getting a dozen large media files compressed and uploaded to S3 reliably, from a phone, on a flaky connection, while the user kept tapping around. That called for an offline upload queue.
An upload queue of tasks, not requests
The design that worked was a persistent task queue. Each piece of media became a task row in the local database (we used Realm), and a small executor worked through them one at a time. A task knew how to do exactly one thing, and when it finished it could turn itself into the next kind of task:
override func execute() -> Promise<ExecutableTask> {
return Promise { resolve, _ in
getAsset()
.then { asset in asset.getVideo() }
.then { video in self.compressVideo(video) }
.then { export in self.updateMedia(export) }
.then { json in self.becomeUploadTask(json) }
.always { resolve(self) } // check next task
}
}Compression became upload. Upload, once S3 confirmed, marked the media ready and deleted itself. Because the state lived in the database rather than in memory, the app could be killed mid-upload and pick up exactly where it left off on the next launch.
The upload side followed the same shape: read the compressed file from its temp path, upload it, write the resulting cloud URL back to the media record, then complete. A separate "wizard" model tracked progress across all tasks so the UI could show one honest progress bar for the whole story.
Promise chains made the steps readable
This was Swift 2 and PromiseKit. Before that, the same flow in the Objective-C version was a pyramid of nested completion blocks, with comments like IN FOREGROUND and IN BACKGROUND at every level so we could keep track of which thread we were on. Moving to a flat promise chain made each step a single line you could read top to bottom.
The mistake
Look at the error handling in the real code:
}.error { error in
// reject(error)
resolve(self) // check next task
}Every task resolved on failure. Errors were caught and swallowed so the queue would move on to the next task. That was deliberate: one corrupt video should not block every photo behind it.
It worked. The queue never stalled. But a failed upload also never told anyone it failed. The task sat in the database, the user saw a story that was "almost done," and the only trace was a log line on a phone we'd never see.
What I'd do now
The instinct was right and the implementation was wrong. "Don't let one failure block the queue" and "don't hide failures" are not in conflict:
- Record the failure on the task. A
failedstate with an attempt count and the error message, not a silent resolve. - Retry with backoff, then stop. Transient network errors deserve a few retries. A malformed video does not deserve infinite ones.
- Surface it. Show the user which item failed and let them retry or remove it.
Related: The React Native bridge promise that never resolved · Webhook best practices: acknowledge fast, process later