Mobile Design Rounds
Build guideAndroid + iOS18 min read

Build it: photo backup and file sync on Android and iOS

Last reviewed . Drafted with AI assistance. Checked against official documentation where noted (how this is checked). Part of: Design photo backup and file sync (Android and iOS): resumable uploads and conflicts · The framework.

This page holds the build details behind the photo backup and file sync interview case. It covers:

  • The schema and a REST sketch (the HTTP calls between app and server).
  • The block upload code and change detection.
  • Who runs the transfer, plus conflicts and failure handling.
  • The iOS version.

None of it belongs in the 45 minutes. I draw it only if asked.

The snippets use Room 2.x (androidx.room, Android's local database library). The official Room guide now shows Room 3 (androidx.room3, KSP only, SQLiteDriver required). The design is the same either way.

1. 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,
)

A block is a fixed-size piece of a file. The server's missing list (the blocks it does not have) is the truth about what is stored. A block is UPLOADED only after the server confirmed it. Treat anything the server did not confirm as unsent.

2. REST (sketch)

POST /v1/files/{id}/uploads         body: { size, blockHashes: [...] }
     → { sessionUri, missing: [3, 4, 9] }          (blocks the server does not have)
PUT  <sessionUri>?block=3   Content-Range: bytes 12582912-16777215/52428800
     X-Block-Hash: <sha256>   → 200 { stored: true } | 422 (hash mismatch, resend)
POST /v1/files/{id}/uploads  again with the same body        (after a failure: returns the current missing list)
POST /v1/files/{id}/commit          body: { baseVersion, blockHashes }
     → 200 { version } | 409 { currentVersion }     (conflict)
                                                   (a retry with the same block list returns the version it already created)
GET  /v1/changes?cursor=<opaque>                   (what changed in the cloud; the server returns the next cursor)

The session borrows from Google's resumable upload model (a session URI, chunks with Content-Range) and from the tus protocol (an open resumable-upload protocol). I changed one thing.

Drive's resumable upload and core tus both take one continuous stream of bytes. After a failure they ask the server for its offset (a byte count). tus has a separate concatenation extension for parallel parts. This model cannot skip blocks the server already has.

So in my sketch each block is its own PUT into the session. The server owns the missing list instead of an offset. That is my design, not Google's or tus's behavior. Whichever shape you pick, say why: the server, not the client, decides what is stored.

A commit response can be lost like any other response. So I would make the commit idempotent: doing it twice has the same result as doing it once. The key is the hash of the block list. A retry with the same list returns the existing version. It does not reach the 409 branch.

3. Resumable block upload

The core flow, per item:

  1. Detect the new or changed item and insert it as DETECTED (see section 4).
  2. Split the file into blocks. Read it as a stream and hash each block (a hash is a short fingerprint of the data). Do not read the whole file into memory.
  3. Send the block hashes. The server replies with the blocks it is missing. It already has some blocks, from this user or from an earlier version of the file. You never upload those.
  4. Upload only the missing blocks, in order, one PUT each. Check that the block still has the hash you sent in step 3. After the server confirms each block, mark its BlockEntity UPLOADED.
  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: upload only the blocks the server said it is missing. Constant memory: one reused buffer.
// 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 uploadMissing(item: ItemEntity, session: UploadSessionEntity, missing: List<Int>) {
    contentResolver.openInputStream(item.localUri.toUri())!!.use { input ->
        val buf = ByteArray(BLOCK_SIZE)                    // for example 4 MiB
        var pos = 0L                                       // where the stream is now
        for (index in missing.sorted()) {
            val block = dao.block(item.itemId, index)      // hash and length saved while hashing
            val start = index.toLong() * BLOCK_SIZE
            input.skipNBytes(start - pos)                  // API 33+; throws EOFException if the file shrank
            val n = input.readNBytes(buf, 0, block.length) // API 33+; on older versions loop on read()
            pos = start + n
            check(n == block.length && sha256(buf, n) == block.hash) { "file changed since hashing" }
            val ack = api.putBlock(session.sessionUri, index, buf, n, start, item.size)  // Content-Range
            check(ack.stored) { "server did not keep block $index" }
            dao.setBlockState(item.itemId, index, BlockState.UPLOADED)
        }
    }
}

What I would point out:

  • A changed file fails with an error. The new hash before each PUT catches a photo edited after step 2. The sketch throws an error, the worker sets the item back to HASHING, and nothing is committed. If the file shrank, skipNBytes throws EOFException. It does not stop quietly.
  • Block size is a trade-off. Small blocks dedupe better (identical data is stored once) and lose less on failure. They add cost for each request. A few MiB is a normal start. The 4 MiB in the sketch is only an example.
  • Google's block rules. Google's resumable upload docs require chunks (the same as our blocks) in multiples of 256 KB, except the last one. They advise using the largest practical size. My block-per-request shape keeps that multiple.
  • Fixed-size blocks fail when bytes are inserted. Add bytes near the start of a file and every later block hash changes. Content-defined chunking (a rolling hash chooses where each block ends) keeps most blocks the same. Photos and videos are rarely edited in place, so fixed blocks are usually enough. For documents, mention content-defined chunking.
  • Verify what you sent. Send a checksum for each block (tus defines an Upload-Checksum extension). A corrupt block that is shared is corrupt for every file that uses it.
  • Sessions expire. Google's resumable session URIs last one week. On 404, start a new session. Stored blocks cost nothing, because the server already has them.
  • Cross-user dedupe has a privacy cost. If accounts share blocks, a hash probe shows whether someone already stored a given file. Say so. Also say that end-to-end encryption mostly defeats cross-user dedupe.

Block hashing, dedupe, resumable sessions and conflicted copies are common ideas in file-sync design. They are not anyone's confirmed internals. The Drive and tus pages in the reading list back the upload protocol. The rest is my own design reasoning. For the source of the write and conflict choices, see the offline-first guide. The lost-acknowledgement walkthrough is in the staff mobile loop guide.

Memory: never load a file into a ByteArray or Data. Stream it with one reused buffer and hash block by block. Then peak memory stays near the block size. The app reads the file twice, once to hash and once to upload the missing blocks. I accept that cost.

4. Finding what changed

The detector decides which items need upload. Checking the whole library again and again is slow and drains the battery.

Android: use MediaStore (Android's shared media database) change signals.

  • MediaStore.getVersion() (API 29) tells you if the store changed a lot since your last sync. Check it when the process starts. If it changed, scan everything again.
  • MediaStore.getGeneration() (API 30) returns a number that only goes up. Save it and compare it later to find items added or changed since then. Google's docs say generation numbers are more reliable than DATE_ADDED and DATE_MODIFIED. Those dates can change when apps or users change timestamps and clocks.
  • Register a ContentObserver for live changes while the process is running. Run a catch-up scan on each scheduled run.

Android access. The photo picker protects privacy when the user selects items by hand. Automatic backup needs broad media access (READ_MEDIA_IMAGES, READ_MEDIA_VIDEO). Play allows it only when the picker cannot provide your core function and you declare it in Play Console. Check that your use case qualifies under the photo and video permissions policy.

Partial access. On Android 14 and later, users can also allow only selected photos (READ_MEDIA_VISUAL_USER_SELECTED). Check this with checkSelfPermission on each run. The partial access docs say not to store the state. Show "backing up N selected items" instead of "library backed up".

iOS: register a PHPhotoLibraryChangeObserver for live changes. It reports only while your app runs.

So also save a PHPersistentChangeToken (a saved position in the change history). Call fetchPersistentChanges(since:) (iOS 16 and later; it can throw an error) on launch and on background runs. If it fails with persistentChangeTokenExpired, do a full sync again. Respect limited-library access. If the user shares only some photos, you back up only those, and the UI should say so.

The cloud can also send a push, but only as a hint. A push message can tell a device to fetch /v1/changes sooner. The push can be late or lost. So the cursor (a saved position) stays the source of truth. The push SDK case study covers this.

What counts as an item. A photo can be several resources: the original, an edited version, and a paired video for a Live Photo. I would decide early which of these are backed up and say so. The choice decides what "restore" and "delete" mean. That is my design decision, not a platform rule.

Do not trust modification dates alone. Check the size and date first, because that is cheap. Then confirm with a hash of the content. A renamed file with the same hash is a metadata update, not an upload.

5. Who runs the transfer

NeedAndroidiOS
Saved job that survives process death (the system ends the app process)WorkManager unique workBackground URLSession (the system keeps transfers going)
Run only on Wi-Fi, chargingConstraints: UNMETERED, RequiresCharging, BatteryNotLowisDiscretionary (a hint), allowsCellularAccess, BGProcessingTaskRequest
Automatic backup of library photosConstrained WorkManager jobsPhotos background-upload extension with PHAssetResourceUploadJob (iOS 26.1+), else background URLSession
User started it, must finish soonUser-initiated data transfer job (API 34+)Background URLSession, non-discretionary; on iOS 26+ also BGContinuedProcessingTask (see below)
Hashing and planning in the backgroundWorkerBGProcessingTask (the system schedules it, with no guarantee)

Android specifics. I choose by who started the upload. A backup the user did not just ask for uses ordinary constrained WorkManager jobs. A transfer the user starts uses a user-initiated data transfer (UIDT) job on API 34 and up. Below that, it uses a foreground worker.

  • WorkManager is the default. An upload must survive process death, so it is never a viewModelScope coroutine. Hilt can inject the worker's dependencies (@HiltWorker). Constraints combine with AND, so all of them must be true. If one stops being true during a run, WorkManager stops the worker. It retries when all constraints hold again. So the upload must be able to resume at any moment.
  • Long runs. WorkManager treats work that can run longer than about 10 minutes as long-running. It expects setForeground() with a notification. This runs the worker as a foreground service (a service with a visible notification). Apps targeting API 34 or higher must declare a foreground service type.
  • The six-hour cap. A dataSync foreground service has a limit of 6 hours per 24 hours for apps targeting Android 15 or later. The system calls onTimeout(), and the service must stop. So each run must save its block states as it goes (a checkpoint) and expect to be scheduled again. See the timeout docs.
  • UIDT jobs. Google notes that on Android 16 long-running workers can use up the app's job quota. It suggests UIDT jobs for transfers the user starts. You must schedule them while the app is visible. They need RUN_USER_INITIATED_JOBS and a notification. Standby bucket quotas do not limit them.
  • UIDT limits. The system can still stop them. So save progress and expect onStartJob() to run again. No Jetpack library wraps UIDT. Use it only on API 34 and up, and use a foreground worker below that.
  • Quotas still apply to ordinary jobs. Android 16 also ties the runtime quota of regular and expedited jobs to the standby bucket. So plain workers can also be stopped for quota. Log WorkInfo.getStopReason(). Keep blocks small so a quota stop costs one block.

Backoff and retries. Backoff means waiting longer after each failure. Result.retry() uses exponential backoff (the wait doubles each time; 30 seconds by default, 10 seconds at minimum).

  • Temporary errors: IOException, 408, 429 and 5xx. Follow Retry-After when the server sends it.
  • Permanent errors: other 4xx responses. The item becomes FAILED.
  • Protocol cases: 404 on a session means start a new one. 409 on commit is a conflict (section 6).

iOS specifics. There are two transfer routes and two kinds of background task.

Photos background upload (iOS 26.1 and later). For library photos, Apple has a path built for this. It is a Photos background-upload extension. In it, you create PHAssetResourceUploadJobs for PHAssetResources. The system runs the extension when it can.

  • PHAssetResourceUploadJob.jobLimit limits how many jobs run at once. The docs page states no value, and I could not find the entitlement requirements. Check them before you choose this route.
  • The extension protocol is marked deprecated in the current docs. Confirm the replacement name for your target SDK.

Background URLSession. Use it for your own files and older OS versions. It hands the transfer to the system. The system continues it while your app is suspended or not running. It starts the app again to deliver events.

  • In general, background uploads must come from a file (check the current docs for your target iOS version).
  • Create the session again with the same identifier on launch, so the delegate receives pending results.
  • Store the completion handler from application(_:handleEventsForBackgroundURLSession:completionHandler:). Call it on the main thread from urlSessionDidFinishEvents(forBackgroundURLSession:).
  • Apple documents that a user force-quit cancels background transfers. The app does not start again until the user opens it.

BGProcessingTask. It is for work like hashing and planning. Apple says it runs only when the device is idle and may be interrupted. If you require power or network, it runs less often.

BGContinuedProcessingTask (iOS 26). It starts in the foreground and can continue in the background. Its progress shows in a Live Activity that the person can cancel.

  • It should start from a user action.
  • The system can still end it when resources are low.
  • Closing the app from the app switcher cancels it, so report progress and save state.

It suits work the user starts, like a visible upload. It does not suit automatic backup. Say clearly that iOS gives no guarantee of when a backup finishes.

6. Conflicts and versioning

Backup has almost no conflicts, because the device is the only writer. File sync is different.

  • Never overwrite without a warning. 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 usual client 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 never change, so a version is just a block list. Restoring is a metadata write. Delete unused blocks after a retention period (garbage collection).
  • A delete must leave a marker. The marker (a tombstone) carries a version. Then an old upload from another device cannot bring the file back, and baseVersion checks still work.

7. Progress UI and security

  • Progress comes from the database. Count confirmed blocks over total blocks. Limit UI and notification updates to a few per second. Dedupe makes progress jump, and 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.
  • Account limits. If the storage quota is full or the auth token expires during an upload, say so in the UI and pause. Do not waste retries. Refresh the token once and resume with the blocks still missing. A full quota is a state the user must fix, not an error to retry.
  • Freeing space. Offer to delete local copies only for items that are committed and confirmed by the server, and ask first.
  • In transit: use TLS everywhere. Treat resumable session URIs as bearer tokens (anyone who holds one can use it): short-lived and never logged. Keep the auth token in the Keystore or Keychain.
  • At rest: encrypt blocks on the server with keys for each user or each file. End-to-end encryption breaks cross-user dedupe and server-side thumbnails and search. So raise it as a follow-up, not a default.

8. Failure modes and edge cases

ScenarioHandling
Process killed mid-uploadItem and block states are in the database. The worker asks the server for the missing list again and resumes from there
Response lost after the server stored a blockAsk for the missing list again. The block is not in it, so the app does not send it again
Session expiredStart a new session. The server already has the stored blocks, so they are skipped
File changed while uploadingThe app hashes each block again just before its PUT. A mismatch throws an error, the item goes back to HASHING and nothing is committed
Corrupt blockChecksum mismatch. Discard the block and send it again
Photo deleted locally after the item was addedThe source is gone. Mark the item FAILED with a reason, or drop it if the user deleted it
Quota full or token expired mid-uploadPause the item with a visible reason. Refresh the token once and resume with the blocks still missing
Force quit on iOSBackground transfers are cancelled. At the next launch, read the session state and add the transfers again

9. Battery, data and platform limits

  • Default to Wi-Fi and charging for bulk backup, with a cellular opt-in. Never check the library again and again. Use the change APIs from section 4.
  • Upload the newest items first, so recent photos are safe within minutes. Let the old library fill in slowly after that.
  • Limit parallel uploads to a few blocks. More parallel connections use more radio and memory for little gain.

10. Photo backup on iOS (Swift)

The design is the same: a local database of items, blocks and sessions, a missing list owned by the server, and a split between blocks and metadata. The platform tools change. The background URLSession sketch from the transfer step is here.

ConcernAndroidiOS
Local DBRoomSwiftData or Core Data (or GRDB)
Detect new mediaMediaStore version and generation, ContentObserverPHPhotoLibraryChangeObserver plus persistent change history
Durable transferWorkManager, UIDT jobsPhotos upload jobs on iOS 26.1+, background URLSession
ConditionsConstraints (network, charging, battery)isDiscretionary, allowsCellularAccess (Low Data Mode has its own switches), BGProcessingTaskRequest
Long hashing jobForeground workerBGProcessingTask (not guaranteed)

What really changes:

  • The system owns the transfer, not you. Background URLSession tasks continue after your app is suspended, but you cannot count on running code between blocks. Plan blocks ahead, write them to files, and create several tasks. Do not loop in a coroutine.
  • Uploads should come from files. Write each block to a temporary file. Delete it after the server confirms the block.
  • Force-quit stops everything. Unlike WorkManager, nothing starts the app again. Say this in the interview and describe the recovery at the next launch.
  • No guaranteed schedule. Do cheap work (detect, hash) at launch and in the foreground. Let the background session carry the bytes.
  • Delegate setup. A SwiftUI-lifecycle app needs @UIApplicationDelegateAdaptor to receive application(_:handleEventsForBackgroundURLSession:completionHandler:).

This sketch uploads one block that was written to a temporary file in the background:

// One session per identifier, created once (for example in the SDK singleton), never per request.
static let backgroundSession: URLSession = {
    let config = URLSessionConfiguration.background(withIdentifier: "app.backup.uploads")
    config.isDiscretionary = true            // bulk backup: system may wait for Wi-Fi and power; applies to transfers started in the foreground. For a user-started upload set it to false
    // sessionSendsLaunchEvents already defaults to true for background configurations
    config.allowsCellularAccess = false      // user setting
    return URLSession(configuration: config, delegate: UploadDelegate.shared, delegateQueue: nil)
}()

func uploadBlock(sessionUri: URL, offset: Int, len: Int, total: Int, blockFileURL: URL) {
    var req = URLRequest(url: sessionUri)
    req.httpMethod = "PUT"
    req.setValue("bytes \(offset)-\(offset + len - 1)/\(total)", forHTTPHeaderField: "Content-Range")
    Self.backgroundSession.uploadTask(with: req, fromFile: blockFileURL).resume()
}

11. More practice prompts

  1. How would you back up a 4 GB video on a flaky connection? (Block upload, server-owned missing list, 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. The user's storage fills halfway through a 4 GB upload. What do they see, and what does the uploader do? (Pause with a reason, no retry loop, resume with the blocks still missing.)
  6. How would a "free up space" button decide what is safe to delete? (Only committed, server-confirmed items, with a confirmation.)
  7. How do you show "backup complete" honestly? (Count only committed items; show reasons for the rest.)

Further reading (official docs)

Verification notes (last reviewed 13 Oct 2026)

this guide is the 'Build it' part of the photo backup and file sync case, moved to its own page on 2026-10-14 so the interview page stays inside 45 minutes. Its code blocks, REST sketch and platform text are carried over unchanged from the verified page; only the section numbers (1 to 11), this intro and the cross-references changed. Re-fetched on 2026-10-14: the Android foreground service timeout page (dataSync, 6 hours in a 24-hour period, the service has a few seconds to call stopSelf) and the Google Drive resumable upload page (session URI expires after one week, chunks in multiples of 256 KB except the last). 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), and uploads from file only. Applied on 2026-10-13 after the audit and re-fetched from the docs: the Android 14 partial photo access page (READ_MEDIA_VISUAL_USER_SELECTED, check permissions each time), the foreground service timeout page (dataSync 6 hours per 24 hours on Android 15, onTimeout), the Android 16 behavior changes page (runtime quota for regular and expedited jobs, getStopReason()), the WorkManager long-running page (longer than 10 minutes, setForeground(), foreground service type on target 34), and the Apple PHAssetResourceUploadJob page (iOS 26.1, jobLimit, extension protocol marked deprecated). The BGContinuedProcessingTask user-action and force-quit details, the Play photo-permission policy reading and the Play staged-rollout page come from the audit's fetches and were not re-read in this pass. Unverified: BGProcessingTask run frequency, limited-library behavior, entitlement requirements for the Photos upload extension, the Play policy position on backup apps (check your own use case), @HiltWorker, Google Photos or Drive internals (block hashing, dedupe and conflicted copies are common file-sync ideas, 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, the concurrency cap and the 45-minute split are example values, not rules. Check foreground service type and UIDT rules against your target SDK. On 2026-10-13, after the code audit, I rewrote the upload sketch to upload only the server's missing blocks (re-hashing each block before sending it, so a changed file fails loudly), removed confirmedOffset from the schema, changed the REST sketch to match, and moved the Swift background URLSession to a single static session. Compiled now: the Room entities and the Kotlin upload sketch with Gradle 9.8.1 offline, Kotlin 2.3.21, AGP 9.1.0, compileSdk 36, Room 2.8.4 (the entities also went through KSP 2.3.6 with room-compiler 2.8.4 without errors), androidx.core 1.17.0. Type-checked now: the Swift snippet with Swift 6.4 against the iPhoneSimulator 27.0 SDK, language modes 5 and 6, inside a class. Stubbed: the app-defined placeholders (Api, Dao, Ack, UploadDelegate, BLOCK_SIZE, sha256). @HiltWorker (Hilt) is mentioned in the prose and was not compiled; this page has no Retrofit code, and the Api stub stands in for what would be a Retrofit interface. The REST sketch, including the block-per-PUT session, is my own design and was not tested against a server; readNBytes and skipNBytes need API 33 and Android Lint was not run. The staff answer and the level split moved to the interview page; the ownership model, protocol-sharing rule and level split are opinion. Review pass 2026-10-14: the four code blocks were diffed against the earlier verified page and are byte-identical. Prose changed only here: the dataSync cap now says it applies to apps targeting Android 15 or later (per the timeout page), the iOS remark on running code between chunks now says you cannot count on it (Apple's page does not state it), the tus remark says core tus and notes the concatenation extension, the failure-table row for a changed file now describes the per-block re-hash the code does instead of a size-and-stamp check the schema does not store, and the iOS section is retitled. Also re-read on 2026-10-14: the MediaStore reference (getVersion added in API 29, getGeneration in API 30), the define-work page (backoff 30 seconds default, 10 seconds minimum; constraints combine with AND), the user-initiated data transfer page and the Apple background URLSession page (no numeric limits stated). A plain-English pass on 2026-10-14 rewrote the prose in simple English and defined or unified the terms block, hash, missing list, dedupe, backoff, checkpoint, cursor and foreground service. It changed no code block, link, number or claim.

Questions or corrections?

Add them to the discussion thread for this case on GitHub. To post, you need a free GitHub account. What you write there is public and follows GitHub's terms. Good answers are added to this page.

Keep reading

Get the next case study when it is published.

You get an email when a new case study is published, and nothing else.

You can unsubscribe at any time. Privacy.