Rebuilding camera uploads for Android from the ground up
Camera uploads has been a core Dropbox feature since 2012, quietly backing up photos and videos for hundreds of thousands of daily users. These users are among the most dedicated and engaged — they expect their backups to be fast and dependable, with the feature working seamlessly in the background even when the app hasn't been opened for weeks.
That reliability was becoming harder to deliver. The feature was built on a C++ library shared between Android and iOS. While it served billions of images over many years, the code had accumulated complex platform-specific hacks that made changes risky. With limited in-house C++ expertise and poor tooling support, the implementation also lagged behind modern Android constraints — it was unaware of background process restrictions, had bugs that could stall uploads for long intervals, and made recovery from outages slow and painful.
In 2019, the decision was made to rewrite the feature entirely. Android and iOS would now have separate, platform-native implementations — Kotlin and Swift respectively — using platform libraries like WorkManager and Room for Android. This allowed each version to be optimized for its environment and evolve independently. The new Android camera uploads shipped to all users in summer 2021 with no outages or major issues: error rates dropped and upload performance improved significantly.
Battling Android's background constraints
The core promise of camera uploads is that it just works in the background. The system notifies the Dropbox app when a photo is taken or modified, and a background worker called the scanner identifies new items and queues them. Another worker, the uploader, handles the batch upload process.
Uploads follow a two-step flow: each file is split into 4 MB blocks, each block is hashed and uploaded to the server, and then a final commit request with the block hashes creates the file in the user's Camera Uploads folder.
The biggest hurdle is Android's strict limits on background activity. App Standby, for example, can restrict network access to a single 10-minute window every 24 hours if the app isn't recently foregrounded. The old C++ implementation handled these constraints poorly — it would attempt uploads doomed to fail due to lack of network access, or fail to resume uploads when the system granted a network window.
The rewrite doesn't bypass these restrictions; users can still opt out in system settings if they choose. Instead, the new implementation maximizes the value of every network window. WorkManager handles the scheduling, guaranteeing upload attempts happen only when network access is actually available. To further squeeze out delays, the scanner does work offline — like duplicate checks on new photos — before requesting network time.
Measuring interactions with our status banners helps us identify emerging issues in our apps, and is a helpful signal in our efforts to eliminate errors. After the rewrite was released, we saw users interacting with more “all done” statuses than usual, while the number of “waiting” or error status interactions went down. (This data reflects only paid users, but non-paying users show similar results.)
Failed upload handling was also refined. The old code retried failures endlessly; the rewrite introduced backoff intervals and error-specific behavior. Transient errors get multiple retries, while permanent errors are not retried at all. The result is substantially fewer overall retry attempts — conserving network and battery — while users actually see fewer errors.
Delivering dramatic performance gains
Reliability wasn't the only goal. Users expect their photos to upload quickly without wasting system resources, and first-time uploads of large libraries now complete up to four times faster. Several design changes made this possible.
Parallel uploads
The C++ version uploaded files one at a time. Early in the rewrite, collaboration with the iOS and backend infrastructure teams produced a new commit endpoint that supports parallel uploads. Once that server constraint was gone, Kotlin coroutines made concurrency straightforward.
Kotlin Flows are sequentially processed by default, but their operators are flexible enough to build custom concurrent operators. These compose declaratively into code that's simpler — with less overhead than the manual thread management that C++ required.
val uploadResults = mediaUploadStore
.getPendingUploads()
.unorderedConcurrentMap(concurrentUploadCount) {
mediaUploader.upload(it)
}
.takeUntil {
it != UploadTaskResult.SUCCESS
}
.toList()
An example of a concurrent upload pipeline. unorderedConcurrentMap is a custom operator combining the built-in flatMapMerge and transform operators.
Stabilizing memory usage
Parallel uploads came with an immediate side effect: a significant spike in out-of-memory crashes from early testers. Several fixes were needed for production stability.
The uploader was modified to dynamically adjust its parallelism based on available system memory, giving high-memory devices full speed while protecting older hardware. Memory profiling then revealed two additional problems. First, memory usage wasn't returning to baseline when uploads finished — a known Java NIO API behavior creates an un-destroyable in-memory cache on every file-reading thread. Switching to direct byte buffers solved this. Second, memory spikes during uploads of large files traced back to garbage collection lag: allocating many byte arrays in quick succession left the garbage collector unable to free them fast enough. Reusing one buffer for all block reads eliminated the spike.
Scanning and uploading concurrently
The legacy implementation required full scans before any uploads, which had serious downsides. Edge cases with misleading timestamps could skip photos entirely, and recovery from bugs meant clearing timestamps to force full re-scans. New users enabling camera uploads had to wait through a complete check before their first photo began uploading.
The rewrite re-scans the complete library after every change, but runs scanners and uploaders in parallel — new photos start uploading while older ones are still being scanned. Full re-scans may take longer, but uploads themselves begin and finish much sooner, making a much stronger first impression.
Validating the Rewrite Before It Ships
Shipping a rewrite of this scope carries real risk. Failure modes like corrupting one in a million uploads might only surface at scale, and a rewrite inevitably introduces bugs because some edge cases in the old system were unknown or misunderstood. Early on, the team tried to remove what looked like dead code from the old camera uploads system and accidentally overloaded Dropbox's crash reporting service — a reminder of how much hidden complexity existed.
Hash Validation in Production
During early development, low-level components were validated by running them in production alongside their C++ counterparts and comparing outputs, confirming correctness before relying on the new results. One such component was a Kotlin implementation of the photo-identification hashing algorithms. Since these hashes drive de-duplication, even a tiny mismatch rate could cause old photos to be re-uploaded as if they were new. When the Kotlin and C++ implementations disagreed — about 0.005% of the time — it wasn't clear which one was wrong.
Additional logging resolved the question. In disagreement cases, the team checked whether the server rejected the upload due to a hash mismatch and what hash it expected. The server expected the Kotlin hashes, which confirmed the C++ hashes were the incorrect ones. This effectively fixed a rare bug that wasn't previously known to exist.
Validating State Transitions
Camera uploads tracks each photo's upload state in a database. Normally the scanner adds photos in NEW state, moves them to PENDING (or DONE if no upload is needed), and the uploader transfers PENDING photos to DONE or ERROR. With heavy parallelization, multiple workers read and write this state database at once. Individual operations are sequential, but subtle race conditions between workers can still cause redundant or contradictory changes. Unit tests alone won't catch such interactions, and integration tests may miss rare races.
The rewritten version guards against this by validating every state update against a set of allowed transitions — for instance, a photo can never move from ERROR to DONE without passing back through PENDING. Unexpected transitions signal a serious bug, so camera uploads halts and reports an exception.
These checks surfaced a nasty issue early in the rollout: a high volume of exceptions from attempted DONE to DONE transitions, which meant some photos were being uploaded multiple times. The root cause was surprising behavior in WorkManager where unique workers can restart before the previous instance is fully cancelled. Duplicate files weren't created since the server rejects them, but redundant uploads wasted bandwidth and time. Fixing it delivered a dramatic throughput improvement.
Rolling Out Slowly and Carefully
Validation of the parts wasn't enough — the fully integrated system was more complex than the sum of its components, and rare device types outside the internal testing pool could expose problems. To minimize risk, the team ensured rollback from the new version to the C++ version was possible, including keeping user preference changes compatible. In the end, no rollback was needed, but the option existed for a disaster scenario.
The rollout started with an opt-in beta pool of Play Store early access users receiving a new Dropbox Android app weekly. This population was large enough to surface rare errors and collect key performance metrics like upload success rate. These metrics were monitored for several months to build confidence before a wider release. The fast beta cadence enabled rapid iteration on the many problems discovered during this period.
Monitoring extended beyond obvious metrics. The team watched for ever-growing backlogs of photos waiting to upload to ensure the uploader wasn't falling behind, tracked retry success rates by error type to fine-tune the retry algorithm, and paid close attention to user feedback and support tickets, which caught bugs that metrics missed.
By the time the new version reached all users, the months in beta paid off. Metrics held steady with no major surprises, improved reliability and low error rates from the start, and the rollout finished ahead of schedule. Because quality issues were resolved during the weekly beta releases, there were no multi-week delays waiting for critical fixes in stable releases.
Was the Rewrite Worth It?
Rewriting a large legacy feature isn't always the right move. It's extremely time-consuming — the Android version alone took two engineers two full years — and can cause major regressions or outages. A rewrite must deliver tangible value, whether by improving the user experience or saving engineering time over the long term, to be worthwhile.
For teams considering a similar project, the key lessons are:
- Define goals and how to measure them. This matters at the start to justify the effort, and at the end to determine success. Some goals, like resilience against future OS changes, may not be quantifiable — that's acceptable, but it's worth spelling out which ones are measurable and which aren't.
- De-risk it. Identify the components or system interactions whose failure would cause the biggest problems, and protect against those from the start. Build critical components first and test them in production before the whole system is finished. Additional up-front work to enable rollback is prudent.
- Don't rush. Shipping a rewrite is riskier than shipping a new feature because users depend on existing behavior. Start with an audience just large enough to provide useful data, then watch, wait and fix until the metrics justify a wider release. Handling problems with a small user base is faster and less stressful.
- Limit your scope. It's tempting to bundle new features, UI cleanup and backlog work into a rewrite. Shipping the rewrite first and fast-following with improvements is often faster and easier to validate. During this rewrite, the team addressed only issues tied to core architecture, such as crashes inherent to the data model, and deferred everything else. Changing too much not only slows implementation but makes regressions harder to notice and rollbacks more difficult.
For Dropbox, the rewrite delivered immediate reliability gains — and more importantly, positioned camera uploads for future reliability. With iOS and Android moving in different directions, the C++ library would eventually break badly enough to force fundamental systemic changes. Now that the rewrite is complete, the team can build and iterate on camera uploads much faster, which translates into a better user experience.



