Design a File Sync App

System Design Round

Design a File Sync App (Dropbox / Google Drive)

File sync apps test your ability to design a client-side sync engine that moves files between local storage and the server reliably. The focus is on chunked transfers, conflict resolution, and offline handling.

What core features should a file sync app support on the client side?

The essential features are upload and download of files, automatic sync across devices, a file browser for navigating folders, and file sharing with other users. The user should be able to browse files even when offline, and any local changes should sync automatically when connectivity returns.

Start by scoping the interview around single-user sync first. Multi-user sharing and collaboration add complexity and should come later. Mention them in requirements but don’t try to design everything at once.

What are the key non-functional requirements?

Conflict resolution is the biggest one. When the same file is edited on two devices before either syncs, the app needs a strategy to handle it without losing data. Large file handling matters because users will sync videos, design files, and archives that can be hundreds of megabytes. The app must not load entire files into memory. Battery and bandwidth efficiency is critical on mobile. Sync should respect battery state, prefer Wi-Fi over metered connections, and avoid redundant transfers by using delta sync when possible.

Reliability is non-negotiable. If a transfer fails halfway, it must resume from where it stopped. The user should never lose data because of a network drop.

What should we exclude from scope for this interview?

Exclude real-time collaborative editing (that is a separate problem closer to Google Docs). Exclude server-side design — focus entirely on the Android client. Also exclude media previews, in-app document editing, and full-text search across files. These are real features but they don’t test the core sync engine design that the interviewer is looking for.

How would you structure the client architecture?

The architecture has three main layers. The file manager handles the UI — a file browser with folder navigation, sync status indicators, and upload/download controls. The sync engine is the core component that coordinates between local and remote state. It detects local changes, fetches remote changes, resolves conflicts, and queues transfers. The local database (Room) stores file metadata and sync state so the app can work offline.

The sync engine sits between the UI and the network. It reads from and writes to the local database, which is the single source of truth. The UI observes the database through ViewModels. Incoming remote changes are written to the database first and then reflected in the UI. Outgoing local changes are queued in the database and picked up by the sync engine for upload.

What API endpoints does the client need?

The client needs three groups of endpoints. Metadata endpoints handle listing folder contents, creating folders, renaming, moving, and deleting files. Transfer endpoints handle uploading and downloading file content. Sync endpoints return a list of changes since the client’s last sync cursor.

interface FileSyncApi {
    @GET("/files/list")
    suspend fun listFolder(
        @Query("path") path: String,
        @Query("cursor") cursor: String?
    ): FolderListResponse

    @POST("/files/upload/session/start")
    suspend fun startUploadSession(): UploadSession

    @PUT("/files/upload/session/{sessionId}/chunk")
    suspend fun uploadChunk(
        @Path("sessionId") sessionId: String,
        @Query("offset") offset: Long,
        @Body chunk: RequestBody
    ): ChunkResponse

    @POST("/files/upload/session/{sessionId}/finish")
    suspend fun finishUpload(
        @Path("sessionId") sessionId: String,
        @Body metadata: FileMetadata
    ): FileEntry

    @GET("/files/download/{fileId}")
    @Streaming
    suspend fun downloadFile(@Path("fileId") fileId: String): ResponseBody

    @POST("/sync/changes")
    suspend fun getChanges(@Body request: SyncRequest): SyncResponse
}

Uploads use a session-based chunked approach. The client starts a session, uploads chunks with byte offsets, and finishes the session with file metadata. The @Streaming annotation on downloads prevents OkHttp from buffering the entire response in memory.

What data models does the client need?

The client needs three core models. FileMetadata represents a file or folder in the local database. SyncState tracks the sync status of each file. ChangeLogEntry records operations that need to be synced to the server.

@Entity(tableName = "file_metadata")
data class FileMetadata(
    @PrimaryKey val fileId: String,
    val name: String,
    val path: String,
    val isFolder: Boolean,
    val sizeBytes: Long,
    val localModifiedAt: Long,
    val remoteModifiedAt: Long,
    val remoteVersion: Long,
    val checksum: String?,
    val syncState: SyncState,
    val localPath: String?
)

enum class SyncState {
    SYNCED, PENDING_UPLOAD, PENDING_DOWNLOAD,
    UPLOADING, DOWNLOADING, CONFLICTED, ERROR
}

@Entity(tableName = "change_log")
data class ChangeLogEntry(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val fileId: String,
    val operation: ChangeOperation,
    val timestamp: Long,
    val dependsOn: Long? = null
)

enum class ChangeOperation {
    CREATE, MODIFY, DELETE, RENAME, MOVE
}

The SyncState enum drives the UI. Each file shows a sync icon based on its current state. The change log acts as a queue — local operations are recorded here and processed by the sync engine in order.

How does delta sync work with version vectors?

Instead of re-uploading entire files on every change, delta sync only transfers the parts that changed. The client keeps a sync cursor — a token from the server representing the last known state. On each sync cycle, the client sends the cursor and the server returns only the changes since that point.

Each file has a remoteVersion that increments on every server-side modification. The client compares its local version against the remote version. If the remote version is higher and the local file has not been modified, it is a straightforward download. If both versions changed, it is a conflict. If only the local version changed, it is an upload.

The sync cycle runs in three phases. First, pull remote changes and apply downloads. Second, push local changes as uploads. Third, update the sync cursor. This order minimizes conflicts because you see the latest remote state before pushing your changes.

How should the local file storage be structured?

Store synced files in the app’s internal storage under a directory structure that mirrors the remote folder hierarchy. Use a flat naming scheme internally — store files by their fileId rather than their user-visible name to avoid path length limits and special character issues. The FileMetadata table maps each fileId to its display path and local file path.

class LocalFileStorage(private val context: Context) {

    private val syncRoot = File(context.filesDir, "sync_files")

    fun getLocalFile(fileId: String): File {
        return File(syncRoot, fileId)
    }

    fun hasLocalContent(fileId: String): Boolean {
        return getLocalFile(fileId).exists()
    }

    fun availableSpaceBytes(): Long {
        return syncRoot.usableSpace
    }
}

Keep a separate temp directory for in-progress downloads. When a download completes, move the temp file to its final location atomically. This prevents the user from opening a partially downloaded file.

How would the sync engine coordinate everything?

The sync engine runs a loop: detect local changes, pull remote changes, resolve conflicts, then process the transfer queue. It exposes a sync() function that can be called by WorkManager on a schedule or triggered manually by the user.

class SyncEngine(
    private val api: FileSyncApi,
    private val db: SyncDatabase,
    private val storage: LocalFileStorage
) {
    suspend fun sync() {
        val localChanges = db.changeLogDao().getPending()
        val remoteChanges = api.getChanges(
            SyncRequest(cursor = db.syncCursorDao().getCursor())
        )

        val conflicts = detectConflicts(localChanges, remoteChanges)
        resolveConflicts(conflicts)

        processDownloads(remoteChanges.entries)
        processUploads(localChanges)

        db.syncCursorDao().updateCursor(remoteChanges.newCursor)
    }

    private fun detectConflicts(
        local: List<ChangeLogEntry>,
        remote: SyncResponse
    ): List<ConflictPair> {
        val remoteFileIds = remote.entries.map { it.fileId }.toSet()
        return local.filter { it.fileId in remoteFileIds }
            .map { ConflictPair(it, remote.entries.first { r -> r.fileId == it.fileId }) }
    }
}

The engine processes operations in dependency order. Folder creates run before file uploads into those folders. Deletes run in reverse — files first, then empty folders.

How would you implement chunked uploads with resume on failure?

Split files into fixed-size chunks (2-4 MB each). Start an upload session with the server, upload each chunk with its byte offset, and finish the session when all chunks are sent. Store the session ID and last completed offset in Room so the upload can resume after a crash or network failure.

suspend fun uploadFileChunked(file: File, metadata: FileMetadata) {
    val chunkSize = 2 * 1024 * 1024L
    val session = db.uploadSessionDao().getSession(metadata.fileId)
        ?: api.startUploadSession().also {
            db.uploadSessionDao().insert(UploadSessionEntity(metadata.fileId, it.sessionId, 0L))
        }

    var offset = session.completedOffset
    RandomAccessFile(file, "r").use { raf ->
        raf.seek(offset)
        val buffer = ByteArray(chunkSize.toInt())
        while (offset < file.length()) {
            val bytesRead = raf.read(buffer)
            val chunk = buffer.copyOf(bytesRead).toRequestBody()
            api.uploadChunk(session.sessionId, offset, chunk)
            offset += bytesRead
            db.uploadSessionDao().updateOffset(metadata.fileId, offset)
        }
    }

    api.finishUpload(session.sessionId, metadata)
    db.uploadSessionDao().delete(metadata.fileId)
}

On resume, the client reads the saved offset, seeks to that position in the file, and continues uploading. The server should handle duplicate chunks idempotently in case the client crashes right after uploading a chunk but before updating the local offset.

How does chunked download with resume work?

Use HTTP range requests. If a download stops at byte 5,000,000, resume with Range: bytes=5000000-. Write to a temp file during download and move it to the final location only on completion.

suspend fun downloadFileResumable(fileId: String) {
    val tempFile = File(storage.tempDir, fileId)
    val downloadedBytes = if (tempFile.exists()) tempFile.length() else 0L

    val request = Request.Builder()
        .url("${baseUrl}/files/download/$fileId")
        .header("Range", "bytes=$downloadedBytes-")
        .build()

    client.newCall(request).execute().use { response ->
        if (response.code == 200) {
            tempFile.delete() // file changed, restart
        }
        tempFile.appendingSink().buffer().use { sink ->
            sink.writeAll(response.body!!.source())
        }
    }

    tempFile.renameTo(storage.getLocalFile(fileId))
}

If the server returns 200 instead of 206 Partial Content, the file has changed since the partial download began. In that case, discard the partial file and restart from scratch. Always check available disk space before starting a download — failing halfway wastes bandwidth and battery.

How would you handle conflict resolution?

Conflicts occur when the same file is modified on two devices before either syncs. Detect them during the sync cycle by checking if a file has both local changes and a new remote version. There are three strategies, and the right one depends on the file type.

Last-write-wins picks whichever modification has the later timestamp. Simple but can lose data. Use this only for non-critical files like app preferences or auto-generated thumbnails. Keep both saves the conflicting version as a separate file — report (conflicted copy - Device A).docx. This is what Dropbox does. No data loss, but the user has to merge manually. User prompt shows both versions with metadata (size, modified date, device name) and lets the user choose which to keep.

suspend fun resolveConflict(local: FileMetadata, remote: FileEntry) {
    when (getResolutionStrategy(local)) {
        Strategy.LAST_WRITE_WINS -> {
            if (local.localModifiedAt > remote.modifiedAt) {
                queueUpload(local)
            } else {
                queueDownload(remote)
            }
        }
        Strategy.KEEP_BOTH -> {
            val conflictName = "${local.name} (conflicted copy)"
            renameLocalFile(local, conflictName)
            queueDownload(remote)
        }
        Strategy.USER_PROMPT -> {
            db.fileMetadataDao().updateState(local.fileId, SyncState.CONFLICTED)
        }
    }
}

For binary files like images and PDFs, keep-both or user-prompt are the only reasonable options because you cannot merge them. For config files and small text files, last-write-wins is usually fine.

How should background sync be scheduled?

Use WorkManager for periodic sync because it guarantees execution even if the app is killed or the device restarts. Schedule a periodic worker every 15 minutes with network and battery constraints. For immediate sync when the user saves a file, enqueue a one-time expedited work request.

fun schedulePeriodicSync(context: Context) {
    val constraints = Constraints.Builder()
        .setRequiredNetworkType(NetworkType.CONNECTED)
        .setRequiresBatteryNotLow(true)
        .build()

    val syncWork = PeriodicWorkRequestBuilder<SyncWorker>(
        15, TimeUnit.MINUTES
    ).setConstraints(constraints).build()

    WorkManager.getInstance(context).enqueueUniquePeriodicWork(
        "periodic_sync",
        ExistingPeriodicWorkPolicy.KEEP,
        syncWork
    )
}

For large file uploads (50 MB+), use a foreground service instead of WorkManager. The system is more likely to kill a WorkManager task than a foreground service with an active notification. Show upload progress in the notification and let the user cancel from there. Use ExistingPeriodicWorkPolicy.KEEP to avoid resetting the timer when the sync is already scheduled.

How would you detect local file changes?

On Android, there is no reliable file system watcher for app-scoped storage. The practical approach depends on who modifies the files. For files your app manages directly, track modifications through your own write operations — whenever your app saves a file, update the FileMetadata entry and add a ChangeLogEntry. This is cheap and reliable.

For files modified by other apps (shared folders via SAF), poll the last modified timestamp on each sync cycle and compare it against the stored value. If the timestamp changed, compute a checksum to confirm the file actually changed (timestamps can update without content changes during file moves). Run this check as part of the periodic WorkManager sync, not continuously — continuous polling drains battery.

How would you optimize bandwidth usage?

Three techniques matter most. First, compression — compress file content before uploading using gzip or zstd. This helps significantly for text-based files but adds CPU overhead. Skip compression for already-compressed formats like JPEG, PNG, and ZIP. Second, Wi-Fi-only sync for large files — check the network type before starting a transfer. For files above a configurable threshold (say 10 MB), only sync on Wi-Fi unless the user explicitly overrides. Third, delta transfers — instead of uploading the entire file when a small part changes, compute the diff and upload only the changed blocks. This requires the server to support block-level deduplication.

fun shouldSyncNow(file: FileMetadata, networkType: NetworkType): Boolean {
    if (networkType == NetworkType.UNMETERED) return true
    if (file.sizeBytes < METERED_THRESHOLD) return true
    return db.settingsDao().isMobileDataSyncEnabled()
}

Batch small metadata updates into a single request instead of making one API call per file rename or move. This reduces the number of network round trips and saves battery.

How should offline editing work?

When the device is offline, the user can still browse and open any file that has local content. Edits are saved to the local file and a ChangeLogEntry is recorded with the operation type and timestamp. The sync engine detects connectivity changes through a ConnectivityManager callback and processes the pending queue when the network returns.

The key challenge is conflict potential. The longer the device stays offline, the higher the chance that someone else modifies the same file remotely. To reduce this risk, process uploads before downloads on reconnection — push local changes first, then pull remote changes. If a conflict is detected, fall back to the conflict resolution strategy for that file type. Show a badge on the app icon or a notification to tell the user how many files are pending sync.

How should files be encrypted at rest and in transit?

In transit, all API calls go over HTTPS with TLS 1.3 and certificate pinning. Use OkHttp’s CertificatePinner to pin the server’s public key. This prevents man-in-the-middle attacks even on compromised Wi-Fi networks.

At rest, encrypt sensitive files on the device using Android’s EncryptedFile from the Jetpack Security library. It uses AES-256-GCM under the hood with keys stored in the Android Keystore. Not every file needs encryption — let the user mark sensitive folders, and only encrypt those. Full-disk encryption at the app level is expensive and usually unnecessary since Android already provides file-based encryption at the OS level.

How would you implement selective sync?

Selective sync lets the user choose which folders sync to the device. Files in unselected folders appear in the browser with an “online-only” badge but have no local content. When the user opens an online-only file, the client downloads it on demand and caches it temporarily.

Store the sync preference per folder in Room. The sync engine skips unselected folders during the periodic sync cycle but still fetches their metadata so the file browser stays up to date. For cache eviction, use an LRU strategy — when local storage exceeds a configurable limit, evict the least recently accessed files that are not in a selected folder. Never evict files in folders the user explicitly chose to sync offline.

How would you handle very large files without running out of memory?

Never load the entire file into memory. For uploads, read the file in chunks using RandomAccessFile or InputStream and upload each chunk as a stream. For downloads, write directly to disk as bytes arrive using OkHttp’s @Streaming annotation. For checksum computation, use a streaming hash.

fun computeChecksum(file: File): String {
    val digest = MessageDigest.getInstance("SHA-256")
    val buffer = ByteArray(8192)
    file.inputStream().use { stream ->
        var bytesRead: Int
        while (stream.read(buffer).also { bytesRead = it } != -1) {
            digest.update(buffer, 0, bytesRead)
        }
    }
    return digest.digest().joinToString("") { "%02x".format(it) }
}

For files over 1 GB, always check available disk space before starting the download. Show a progress indicator with estimated time remaining, calculated from the transfer rate of the last few chunks. Let the user pause and resume at any time. If the device runs low on storage mid-download, pause the transfer and notify the user rather than crashing or corrupting the file.

Common Follow-ups