androidengineers.Book a session

Case Study: Design Instagram

Media Upload/Transcode Pipelines

article25 minHard

Instagram-style media upload must be resilient: compress before upload, retry on failure, show progress, and survive process death. WorkManager is the right foundation.

Upload Pipeline Architecture

User picks photo/video
        ↓
Compress (background thread)
        ↓
WorkManager UploadWorker
        ↓
Multipart upload with progress
        ↓
Server transcodes (async)
        ↓
Polling or webhook → mark as processed
        ↓
Show in feed

Step 1: Local Compression Before Upload

class MediaCompressor(private val context: Context) {

    suspend fun compressImage(sourceUri: Uri, targetMaxSizeKb: Int = 500): File =
        withContext(Dispatchers.IO) {
            val original = MediaStore.Images.Media.getBitmap(context.contentResolver, sourceUri)
            val outputFile = File(context.cacheDir, "compressed_${System.currentTimeMillis()}.jpg")

            var quality = 90
            do {
                outputFile.outputStream().use { out ->
                    original.compress(Bitmap.CompressFormat.JPEG, quality, out)
                }
                quality -= 10
            } while (outputFile.length() > targetMaxSizeKb * 1024 && quality > 30)

            outputFile
        }

    suspend fun compressVideo(sourceUri: Uri): File = withContext(Dispatchers.IO) {
        // Use android.media.MediaTranscoder (API 29+) or a library
        val outputFile = File(context.cacheDir, "compressed_${System.currentTimeMillis()}.mp4")

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            val transcoder = MediaTranscoder.getInstance()
            val format = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, 1280, 720).apply {
                setInteger(MediaFormat.KEY_BIT_RATE, 3_000_000)  // 3Mbps
                setInteger(MediaFormat.KEY_FRAME_RATE, 30)
            }
            // transcoder.transcodeVideo(...)
        }

        outputFile
    }
}

Step 2: WorkManager Upload Worker

class MediaUploadWorker @AssistedInject constructor(
    @Assisted context: Context,
    @Assisted params: WorkerParameters,
    private val api: MediaApi,
    private val postDao: PostDao
) : CoroutineWorker(context, params) {

    override suspend fun doWork(): Result {
        val localFilePath = inputData.getString("local_file_path") ?: return Result.failure()
        val postId = inputData.getString("post_id") ?: return Result.failure()

        setForeground(createForegroundInfo())

        return try {
            val file = File(localFilePath)
            if (!file.exists()) return Result.failure(
                workDataOf("error" to "File not found: $localFilePath")
            )

            // Upload with progress
            var lastReportedProgress = 0
            val mediaUrl = api.uploadMedia(file) { bytesSent, totalBytes ->
                val progress = (bytesSent * 100 / totalBytes).toInt()
                if (progress > lastReportedProgress + 5) {
                    setProgress(workDataOf("progress" to progress))
                    lastReportedProgress = progress
                }
            }

            // Update local DB with server URL
            postDao.updateMediaUrl(postId, mediaUrl)
            postDao.updateStatus(postId, PostStatus.UPLOADED)

            // Clean up local cache file
            file.delete()

            Result.success(workDataOf("media_url" to mediaUrl))
        } catch (e: IOException) {
            postDao.updateStatus(postId, PostStatus.UPLOAD_FAILED)
            if (runAttemptCount < 3) Result.retry() else Result.failure(workDataOf("error" to e.message))
        }
    }

    private fun createForegroundInfo(): ForegroundInfo {
        val notification = NotificationCompat.Builder(applicationContext, "uploads")
            .setContentTitle("Uploading post…")
            .setSmallIcon(R.drawable.ic_upload)
            .setOngoing(true)
            .setProgress(100, 0, true)
            .build()
        return ForegroundInfo(1001, notification)
    }
}

Step 3: Enqueue Upload from ViewModel

fun enqueueUpload(localFilePath: String, postId: String) {
    val work = OneTimeWorkRequestBuilder<MediaUploadWorker>()
        .setConstraints(Constraints.Builder()
            .setRequiredNetworkType(NetworkType.CONNECTED)
            .build())
        .setInputData(workDataOf(
            "local_file_path" to localFilePath,
            "post_id" to postId
        ))
        .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
        .addTag("upload_$postId")
        .build()

    workManager.enqueueUniqueWork("upload_$postId", ExistingWorkPolicy.KEEP, work)
}

Step 4: Observe Upload Progress

// In ViewModel
val uploadProgress: Flow<Int> = workManager
    .getWorkInfosByTagFlow("upload_$postId")
    .map { infos ->
        val info = infos.firstOrNull() ?: return@map 0
        when (info.state) {
            WorkInfo.State.RUNNING -> info.progress.getInt("progress", 0)
            WorkInfo.State.SUCCEEDED -> 100
            WorkInfo.State.FAILED -> -1
            else -> 0
        }
    }

// In Compose
val progress by viewModel.uploadProgress.collectAsStateWithLifecycle()
LinearProgressIndicator(progress = progress / 100f)

Key Takeaways

PatternRule
Compress firstReduce file size before upload; target < 500KB for images
WorkManagerSurvives process death; built-in retry; constraint-aware
setForegroundRequired for uploads > a few seconds (prevents system kill)
ExistingWorkPolicy.KEEPDon't restart an in-progress upload
Progress via setProgressObserve with getWorkInfosByTagFlow; update UI without polling
Exponential backoffBackoffPolicy.EXPONENTIAL with 30s initial delay

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Media Upload/Transcode Pipelines | Android System Design | Android Engineers