Mobile Design Rounds
Case studyAndroid + iOS16 min read

Design photo backup and file sync (Android and iOS): resumable uploads and conflicts

Last verified . Drafted with AI assistance and checked against official documentation (how this is verified). Part of: The framework · Offline-first guide · Staff-level guide.

TL;DR. An upload is a durable, resumable job, not a request. Split each file into content-hashed blocks, ask the server which blocks it lacks, and upload only those. Store the session and per-block state in a local database so a killed process or a dead network costs one block, not the file. Let the OS run the transfer (WorkManager and user-initiated jobs on Android, a background URLSession on iOS), and gate it on Wi-Fi and charging by default. Never promise that a backup is complete until the server has confirmed every block.

1. Clarify the requirements (about 5 min)

Questions worth asking:

  • Photos only, or arbitrary files too? (Assume camera roll backup first, then a Drive-style folder tree as a follow-up.)
  • One direction (device to cloud) or two-way sync? (Assume backup is one-way; file sync is two-way.)
  • How big are files? (Assume photos of 3 to 10 MB and videos up to several GB.)
  • Which network and power conditions are allowed? (Assume Wi-Fi and charging by default, with a user setting for cellular.)
  • Single device or several devices on one account? (Assume several; this is where conflicts come from.)
  • Encryption? (Assume TLS in transit and encryption at rest on the server; mention end-to-end as a follow-up.)

Functional: pick which folders to back up, upload new and changed items automatically, show progress and "backed up" state per item, browse and download from the cloud, restore, delete, and handle the same file edited on two devices.

Non-functional (the part that makes it a mobile question):

ConcernTarget
ReliabilityNo lost items, no duplicates, no corrupt files
ResumabilityA dropped connection or killed process loses at most one block
Battery and dataNo upload on cellular or low battery unless the user opts in
MemoryConstant memory use for a multi-GB video
Perceived stateThe user always sees what is backed up, queued, or failed
SecurityTLS in transit, scoped credentials, no plaintext secrets on disk

Out of scope for now: sharing and permissions, search, face grouping, and editing. Say this out loud so the interviewer can redirect you.

2. High-level architecture (about 10 min)

observesnew itemsschedulesstateblockscommitUIuploading 14 of 212Sync repositoryplans the workLocal databaseitems, blocks, sessionsChange detectorMediaStore / PhotosTransfer schedulerWorkManager / URLSessionUploaderresumable, per blockBlock APIcontent-addressedMetadata APIfiles, versions
Photo backup architecture: a change detector feeds a local database; the OS-run scheduler drives a resumable uploader that sends blocks, then commits metadata.

Official architecture mapping: Compose plus ViewModel is the UI layer (the ViewModel exposes one uiState built from repository Flows). SyncRepository is the data-layer repository. Local DB, change detector and Block/Metadata APIs are its data sources. The scheduler and uploader are business-oriented work, so WorkManager. See data layer and offline-first.

Three decisions shape everything:

  1. The local database is the source of truth for sync state. The UI shows "uploading 14 of 212" by reading it through the repository as a Flow. The scheduler and uploader write to it. This is offline-first with lazy writes (save locally, queue the network update); backup is push-style, cloud changes are pulled with a cursor (hybrid sync).
  2. Blocks and metadata are separate. Blocks are immutable bytes named by their hash. Metadata says which blocks make up which version of which file. This is what makes dedupe, resume, and versioning cheap.
  3. The OS owns the long-running transfer. The app can be killed at any time, so the app plans the work and the OS carries it out.

3. Data model and API

Local schema (Room)

@Entity(tableName = "items", indices = [Index(value = ["localUri"], unique = true)])
data class ItemEntity(
    @PrimaryKey val itemId: String,       // client-generated UUID
    val localUri: String,                 // MediaStore content URI or file path
    val size: Long,
    val contentHash: String?,             // whole-file hash, filled after hashing
    val remoteFileId: String? = null,     // null until the server has created the file
    val baseVersion: Long? = null,        // version this edit is based on, for conflict checks
    val state: State,
) { enum class State { DETECTED, HASHING, UPLOADING, DONE, FAILED } }

@Entity(tableName = "blocks", primaryKeys = ["itemId", "index"])
data class BlockEntity(
    val itemId: String, val index: Int,
    val hash: String,                     // SHA-256 of the block bytes
    val length: Int,
    val state: BlockState,                // PENDING, PRESENT_ON_SERVER, UPLOADED
)

@Entity(tableName = "upload_sessions")
data class UploadSessionEntity(
    @PrimaryKey val itemId: String,
    val sessionUri: String,               // server-issued resumable session
    val expiresAt: Long,
    val confirmedOffset: Long,            // last offset the server acknowledged
)

confirmedOffset is always the server's answer, never the client's guess. Treat anything the client sent but the server did not acknowledge as unsent.

REST (sketch)

POST /v1/files/{id}/uploads         body: { size, blockHashes: [...] }
     → { sessionUri, missing: [3, 4, 9] }          (blocks the server does not have)
PUT  <sessionUri>   Content-Range: bytes 0-4194303/52428800   → 308 + Range | 200 done
PUT  <sessionUri>   Content-Range: bytes */52428800            (status query after a failure)
POST /v1/files/{id}/commit          body: { baseVersion, blockHashes }
     → 200 { version } | 409 { currentVersion }     (conflict)
GET  /v1/changes?cursor=<opaque>                   (what changed in the cloud)

The session shape follows Google's resumable upload model: start a session, send chunks with Content-Range, and send an empty PUT with bytes */total to ask where the server stands. The tus protocol does the same with HEAD and PATCH, and returns 409 when the client's offset does not match the server's. Pick one and say why; the point is that the server, not the client, owns the offset.

4. The hard part, part 1: resumable, block-level upload

The core flow, per item:

  1. Detect the new or changed item and insert it as DETECTED (see section 5).
  2. Split the file into blocks and hash each block while reading it as a stream. Do not read the file into memory.
  3. Send the block hashes. The server replies with the blocks it is missing. Blocks it already has, from this user or from a previous version of this file, are never uploaded.
  4. Upload the missing blocks in order. After each acknowledged block, update confirmedOffset in the database.
  5. Send a commit with the full list of block hashes and the baseVersion. Only the commit makes the new version visible.
  6. Mark the item DONE.
// Sketch: stream a file in fixed blocks, constant memory.
// Runs in a data-layer component called by the worker (never by a Composable or ViewModel); dispatcher injection and entity mapping omitted for brevity
suspend fun uploadBlocks(item: ItemEntity, session: UploadSession) {
    contentResolver.openInputStream(item.localUri.toUri())!!.use { input ->
        input.skip(session.confirmedOffset)                // resume where the server says; skip() may skip fewer bytes, so loop in production
        val buf = ByteArray(BLOCK_SIZE)                    // for example 4 MiB; one buffer, reused
        var offset = session.confirmedOffset
        while (true) {
            val n = input.readNBytes(buf, 0, buf.size)   // API 33+; on older versions loop on read() until the block is full
            if (n == 0) break
            val ack = api.putChunk(session.uri, buf, n, offset, item.size)  // Content-Range
            offset = ack.nextOffset                        // trust the server, not offset + n
            dao.setConfirmedOffset(item.itemId, offset)
        }
    }
}

Points to say out loud:

  • Block size is a trade-off. Small blocks dedupe better and lose less on failure, but add per-request overhead and metadata. A few MiB is a common starting point. Google's docs require chunks in multiples of 256 KB except the last one and advise using the largest practical size. Treat 4 MiB as an example value.
  • Fixed-size blocks break on insertion. Add bytes near the start of a file and every later block hash changes. Content-defined chunking (boundaries chosen by a rolling hash) keeps most blocks stable. For photos and videos, which are rarely edited in place, fixed blocks are usually enough. For documents, mention content-defined chunking.
  • Verify what you sent. Send a per-chunk checksum (tus defines an Upload-Checksum extension). A corrupt block that dedupes is corrupt for every file that uses it.
  • Sessions expire. Google's last one week. On 404, start a new session; dedupe means stored blocks cost nothing.
  • Cross-user dedupe has a privacy cost. If blocks are shared across accounts, a hash probe reveals whether someone already stored a given file. Say so, and say that end-to-end encryption mostly defeats cross-user dedupe.

Memory: never load a file into a ByteArray or Data. Stream it with one reused buffer and hash as you read, so peak memory stays near the block size.

5. The hard part, part 2: finding what to upload

The detector decides what enters the queue. Polling the whole library is slow and drains the battery.

Android: use MediaStore change signals. MediaStore.getVersion() tells you if the store changed substantially since your last sync (rescan everything if it did), and MediaStore.getGeneration() returns a monotonically increasing number you can persist and compare to find items added or modified since then. Google's docs call generation numbers more robust than DATE_ADDED and DATE_MODIFIED, which can move when apps or users change timestamps and clocks. Register a ContentObserver for live changes while the process is alive, and run a catch-up scan on each scheduled run. The photo picker is the privacy-friendly route when the user selects items by hand; automatic backup needs READ_MEDIA_IMAGES and READ_MEDIA_VIDEO, which Google Play restricts to qualifying use cases.

iOS: register a PHPhotoLibraryChangeObserver for live changes. It only reports while your app runs, so also save a PHPersistentChangeToken and call fetchPersistentChanges(since:) (iOS 16 and later; it throws) on launch and on background runs. If it fails with persistentChangeTokenExpired, fall back to a full re-sync. Respect limited-library access: if the user shares only some photos, you back up only those, and the UI should say so.

Do not trust modification dates alone. Check size and stamp cheaply, then confirm with a content hash. A renamed file with the same hash is a metadata update, not an upload.

6. The hard part, part 3: who runs the transfer

NeedAndroidiOS
Durable job that survives process deathWorkManager unique workBackground URLSession (system keeps transfers going)
Run only on Wi-Fi, chargingConstraints: UNMETERED, RequiresCharging, BatteryNotLowisDiscretionary (a hint), allowsCellularAccess, BGProcessingTaskRequest
User started it, must finish soonUser-initiated data transfer job (API 34+)Background URLSession, non-discretionary
Hashing and planning in the backgroundWorkerBGProcessingTask (system-scheduled, not guaranteed)

Android specifics. An upload is business-oriented work in the official work-lifetime categories: it must survive process death, so it is never a viewModelScope coroutine. WorkManager is the default, with Hilt injecting the worker's dependencies (@HiltWorker). Constraints combine with AND, and if one stops being true mid-run, WorkManager stops the worker and retries when all hold again, so your upload must be resumable at any instant. A normal worker has roughly a 10 minute execution limit per run; for longer work, call setForeground() and show a notification, which runs the worker as a foreground service. Apps targeting API 34 or higher must declare a foreground service type for long-running workers. Android 16 changes the picture: Google notes that long-running workers can exhaust the app's job quota, and suggests user-initiated data transfer (UIDT) jobs for transfers the user starts. UIDT jobs must be scheduled while the app is visible, need RUN_USER_INITIATED_JOBS and a notification, and are not subject to standby bucket quotas. The system can still stop them, so persist progress and expect onStartJob() to run again. No Jetpack library wraps UIDT, so gate it on API 34 and fall back to a foreground worker below that. Automatic background backup, which the user did not start just now, belongs on ordinary constrained WorkManager jobs, not UIDT.

Backoff. Result.retry() uses exponential backoff (30 second default, 10 second minimum). Retry network errors and 5xx; mark permanent 4xx as FAILED.

iOS specifics. A background URLSession hands the transfer to the system, which continues it while your app is suspended or not running and relaunches the app to deliver events. In general, background uploads must come from a file (check the current docs for your target iOS version). Re-create the session with the same identifier on launch so the delegate receives pending results; store the completion handler from application(_:handleEventsForBackgroundURLSession:completionHandler:) and call it on the main thread from urlSessionDidFinishEvents(forBackgroundURLSession:). Apple documents that a user force-quit cancels background transfers and the app is not relaunched until the user opens it again. BGProcessingTask is for work like hashing and planning; Apple says it runs only when the device is idle and may be interrupted, and requiring power or network narrows when it can run. Say plainly that iOS gives no guarantee of when a backup finishes.

// Sketch: background upload of one block that was written to a temp file
let config = URLSessionConfiguration.background(withIdentifier: "app.backup.uploads")
config.isDiscretionary = true            // system may wait for Wi-Fi and power; applies to transfers started in the foreground
config.sessionSendsLaunchEvents = true
config.allowsCellularAccess = false      // user setting
let session = URLSession(configuration: config, delegate: self, delegateQueue: nil)

var req = URLRequest(url: sessionUri)
req.httpMethod = "PUT"
req.setValue("bytes \(offset)-\(offset + len - 1)/\(total)", forHTTPHeaderField: "Content-Range")
session.uploadTask(with: req, fromFile: blockFileURL).resume()

7. Conflicts and versioning

Backup is mostly conflict-free: the device is the only writer. File sync is not.

  • Never overwrite silently. Every file has a server version. An edit commits with baseVersion. If the server's current version is newer, the commit returns 409.
  • On conflict, keep both. The common client-side answer is a conflicted copy (for example, report (Alex's conflicted copy).docx) so no edit is lost. Offer a merge only for formats you can merge.
  • Keep old versions. Blocks are immutable, so a version is just a block list. Restoring is a metadata write; garbage-collect unreferenced blocks after a retention period.
  • Deletes need tombstones. A delete on one device must not be undone by a stale upload from another. Keep a tombstone with a version so that baseVersion checks still work.

8. Progress UI and security

  • Progress comes from the database. Count confirmed blocks over total blocks, and throttle UI and notification updates to a few per second. Dedupe makes progress jump; that is fine.
  • Show the state, not just a bar: waiting for Wi-Fi, waiting for charger, uploading, paused, failed with a reason. Show "backed up" only after the commit succeeds.
  • In transit: TLS everywhere. Treat resumable session URIs as bearer tokens: short-lived, never logged. Keep the auth token in the Keystore or Keychain.
  • At rest: encrypt blocks on the server with per-user or per-file keys. End-to-end encryption breaks cross-user dedupe and server-side thumbnails and search, so raise it as a follow-up, not a default.

9. Failure modes and edge cases

ScenarioHandling
Process killed mid-uploadItem and confirmedOffset are in the database; the worker or session resumes from the server's offset
Response lost after the server stored a chunkStatus query (bytes */total) returns the real offset; resend from there
Session expiredStart a new session; dedupe skips blocks already stored
File changed while uploadingCompare size and modification stamp before commit; if different, abandon the commit and re-hash
Corrupt blockChecksum mismatch; discard the chunk and resend
Photo deleted locally after being queuedTreat as a missing source: mark the item FAILED with a reason, or drop it if the user deleted it
Force quit on iOSBackground transfers are cancelled; recover on next launch by reading session state and re-queuing

10. Battery, data and platform limits

  • Default to Wi-Fi and charging for bulk backup, with a cellular opt-in. Never poll; use the change APIs from section 5.
  • Upload the newest items first so recent photos are protected within minutes, and let the old library backfill trickle in.
  • Cap concurrency at a few blocks. More parallel connections raise radio and memory use for little gain.
  • Foreground service types and job quotas change by OS version. Verify the rules for your target SDK before relying on any number.

11. How to build photo backup and file sync on iOS (Swift)

The design is identical: a local database of items, blocks, and sessions; a server-owned offset; and a block-and-metadata split. Only the platform primitives change.

ConcernAndroidiOS
Local DBRoomSwiftData or Core Data (or GRDB)
Detect new mediaMediaStore version and generation, ContentObserverPHPhotoLibraryChangeObserver plus persistent change history
Durable transferWorkManager, UIDT jobsBackground URLSession
ConditionsConstraints (network, charging, battery)isDiscretionary, allowsCellularAccess, BGProcessingTaskRequest
Long hashing jobForeground workerBGProcessingTask (not guaranteed)

What is genuinely different:

  • The system owns the transfer, not you. Background URLSession tasks continue after your app is suspended, but you can't run arbitrary code between chunks. Plan blocks ahead, write them to files, and queue several tasks, instead of looping in a coroutine.
  • Uploads should be file-based. Write each block to a temp file and delete it once acknowledged.
  • Force-quit stops everything. Unlike WorkManager, nothing relaunches the app. Say this in the interview and describe the recovery on next launch.
  • No guaranteed schedule. Do cheap work (detect, hash) on launch and foreground, and let the background session carry the bytes.

12. What changes by level

  • Mid-level: a working upload with WorkManager, a progress indicator, retry on failure, and Wi-Fi-only constraints.
  • Senior: resumable sessions with a server-owned offset, block hashing and dedupe, streaming with constant memory, the MediaStore and Photos change APIs, and a clear account of what the OS will and will not run in the background.
  • Staff: conflict and version design across devices, content-defined chunking and its cost, cross-user dedupe privacy and end-to-end encryption trade-offs, upload success metrics and quotas, rollout of a new chunk size without breaking old clients, and cost control on the backend.

Staff answer: the decision and the rollout

Decision: build one shared sync engine (a Kotlin Multiplatform or per-platform core with a shared protocol spec) that several apps consume, owned by a platform team with a small public contract: enqueue, observe progress, resolve conflict. Alternative: each app keeps its own uploader, which is cheaper at first but duplicates the hard parts: resumable sessions, dedupe and background limits. Revisit trigger: a second app needs behavior the shared contract cannot express without branching on app identity. The native vs cross-platform decision guide covers the shared-versus-native trade-off.

What to measure: upload completion rate per attempt and per day, resume-after-interruption success, bytes re-sent, and time to fully synced. Guardrail: no increase in data loss or duplicate-file reports, and battery and cellular-data complaints must not rise for the new engine's cohort.

Rollback on mobile: you cannot recall a build, so the server must accept both the old and new protocol for as long as old clients exist. Roll out a new chunk size behind a server-controlled flag, keep the old path in the binary, and let the server reject nothing until old-client traffic is negligible. Local state migrations need a downgrade-safe format or a forward-only migration with the flag off by default. See the staff mobile loop guide.

Opinion vs verified: the background-execution limits are covered with Apple and Android docs earlier on this page; the ownership model, protocol-compatibility rule and metrics are my opinion.

13. Follow-up questions to expect

  1. How would you back up a 4 GB video on a flaky connection? (Block upload, server-owned offset, constraints, foreground or UIDT work, progress in the database.)
  2. How do you avoid re-uploading a photo the user already backed up from another device? (Whole-file hash lookup, then block-level dedupe.)
  3. Two devices edit the same file offline. What happens? (baseVersion, 409, conflicted copy, no silent overwrite.)
  4. How would you add end-to-end encryption? (Encrypt before upload, effect on dedupe, thumbnails, and search, key recovery.)
  5. How do you show "backup complete" honestly? (Count only committed items; show reasons for the rest.)
  6. How do you measure it? (Time to first backed-up photo, backlog size, failure rate, bytes saved by dedupe.)

Further reading (official docs)

Verification notes (last verified 13 Oct 2026)

Android architecture audited against the official guidance (recommendations, data layer, offline-first, UI layer) on 2026-10-13; remaining deviations: Room entities appear in snippets for brevity, and conflicts use version checks plus conflicted copies instead of the official last-write-wins example. Verified against Apple docs JSON: force-quit cancels background transfers and blocks relaunch, same-identifier session recreation, urlSessionDidFinishEvents and main-thread completion handler, isDiscretionary (foreground-started transfers only), sessionSendsLaunchEvents (default true), BGProcessingTask idle-only behavior, fetchPersistentChanges(since:) (iOS 16+, token-expired error). Unverified: that background uploads must be file-based (not found in current docs; widely reported), BGProcessingTask run frequency, limited-library behavior. Also unverified: the roughly 10 minute WorkManager limit for normal workers beyond what the long-running page implies, Google Photos or Drive internals (the block and dedupe design follows common interview write-ups, not confirmed internals), and the content-defined chunking and keyed-hash dedupe points, which are general knowledge. The 4 MiB block size, the Wi-Fi and charging defaults and the concurrency cap are example values, not rules. Check foreground service type and UIDT rules against your target SDK; the Android 16 quota note comes from the WorkManager long-running page. Kotlin snippets compile-checked offline on 2026-10-13 against Kotlin 2.3.21 (AGP 9.1.0, compileSdk 36), Room 2.8.4, androidx.core 1.17.0, with stubs only for app-defined placeholders. Swift snippets type-checked on 2026-10-13 against the iOS 27 simulator SDK (Swift 6.4, language modes 5 and 6) with stubs only for app-defined placeholders. The top-level Swift statements were checked inside a method. Added a staff-answer section (section 12) on 2026-10-13; rollback facts were checked against the Play staged-rollout help page; the shared-engine ownership model and protocol compatibility rule are opinion.

Keep reading

Follow new case studies

Subscribe with any RSS reader. Found a mistake? Open an issue on GitHub.