Mobile Design Rounds
Build guideAndroidiOS20 min read

Build it: reels, video and photos on Android and iOS

Last reviewed . Drafted with AI assistance. Checked against official documentation where noted (how this is checked). Part of: Design reels, video and photos for a travel app · Feeds and media · The five-step method.

This is the implementation material behind the reels, video and photos interview case. It covers:

  • the local tables and the REST calls, with the IDs that make a retry safe
  • what packaging and the backend must give the client
  • the player pool, the preload policy and the disk cache
  • the post pipeline: compress, save, upload in chunks, resume
  • large photos: decode to a target size, and the memory budget
  • the iOS side in Swift, and the platform limits

None of it fits in a 45 minute round. I draw it only if the interviewer asks.

Versions. The Kotlin code uses Media3 (the Android media library) DefaultPreloadManager as shown in the Android Developers Blog, and Compose. I read the preload blog post. The post says its snippets have typos, and it names no Media3 version, so compile against your version before you copy anything. The cache classes (SimpleCache, CacheDataSource) are from my knowledge of Media3 and I did not re-read their pages. The Swift code is a sketch and has not been compiled.

45-minute plan

MinutesStepWhat I have drawn by the endSkip if late
0 to 4Scope and constraintsScope table, what the phone doesFewer questions
4 to 14Data and contractsFeed row, upload calls, saved IDsThe packaging table
14 to 22Client structurePlayer pool, upload manager, media budgetOne line per box
22 to 40Make the hard part workPreload window, post states, photo sizesiOS bullets
40 to 45Break it, ship it, roll it backTop failures, remote settings, what I watchTwo failures and the flag

1. Local schema (Room)

@Entity(tableName = "reels")
data class ReelEntity(
    @PrimaryKey val id: String,
    val posterUrl: String,
    val previewColor: Int,             // dominant colour, shown before the poster loads
    val durationMs: Long,
    val aspect: Float,                 // width / height, so the cell has its size before the video
    val position: Long,                // order in the feed; new reels are appended after the current one
    val removed: Boolean = false,      // tombstone: a stale page must not bring it back
)

@Entity(tableName = "pending_posts")
data class PendingPostEntity(
    @PrimaryKey val postId: String,    // UUID made when the user taps Post
    val caption: String,
    val placeId: String?,
    val state: String,                 // SAVED, UPLOADING, WAITING, PROCESSING, LIVE, REJECTED
    val createdAt: Long,
    val rejectReason: String? = null,
)

@Entity(tableName = "pending_uploads", indices = [Index("postId")])
data class PendingUploadEntity(
    @PrimaryKey val uploadId: String,  // UUID made with the post, never remade
    val postId: String,
    val localPath: String,             // app files folder, never a cache folder
    val bytes: Long,
    val confirmedOffset: Long,         // last offset the server confirmed
    val sessionUrl: String?,           // may expire; ask again with the same uploadId
    val sessionExpiresAt: Long?,
)

Four details matter:

  • The post and its uploads are written in one transaction. If the app is killed between the two writes, there must not be an upload without a post or a post without an upload.
  • confirmedOffset is the server's number. I save it after every chunk answer. I never save "bytes I sent", because the server may not have kept them.
  • States only move forward. LIVE never goes back to PROCESSING. An old push that arrives late is ignored.
  • On iOS the same three tables fit SwiftData models or GRDB tables. The rules are the same.

An unique index on the file hash is optional. I use it only to warn the user about picking the same video twice. The server still dedupes by post ID.

2. REST

GET  /v1/reels?cursor=<opaque>&limit=10
     → { items: [{ id, posterUrl, previewColor, durationMs, aspect }], nextCursor }

GET  /v1/reels/{id}/playback
     → { url, expiresAt, renditions?: [...] }          // 404 or 410 when the reel is gone

POST /v1/uploads                                        // idempotent by uploadId
     { uploadId, kind: "video"|"photo", bytes, mime }
     → { sessionUrl, chunkSize, expiresAt }

HEAD /v1/uploads/{uploadId}                             // or GET: how much do you hold?
     → Upload-Offset: 12582912

PATCH sessionUrl                                        // one chunk
     Upload-Offset: 12582912      Content-Length: 5242880
     → 204, Upload-Offset: 17825792                     // the server ignores bytes it already has

POST /v1/posts                                          // idempotent by postId
     { postId, uploadIds: [...], caption, placeId, takenAt }
     → 201 (or 200 with the same post)

GET  /v1/posts/{postId}
     → { state: "processing"|"ready"|"rejected", reason? }

DELETE /v1/posts/{postId}                               // repeat is safe

I made these calls up for this guide, in the style of resumable upload protocols. Real services differ in names but not in the two things the phone needs: an offset it can ask for, and a session call that accepts the same ID twice. Google Cloud Storage chunks must be a multiple of 256 KiB, and its session lives one week (resumable uploads). Amazon S3 multipart parts must be 5 MiB or more except the last, with at most 10,000 parts (S3 limits). With 5 MiB parts, 10,000 parts allow about 48 GiB, which is far above any reel.

Status codes I plan for on a chunk:

AnswerMeaningWhat the phone does
204 or 200Chunk savedSave the new confirmed offset, send the next chunk
401 or 403Token or signed session no longer acceptedRefresh the token, ask for the session of the same uploadId, ask the offset, continue
404 or 410Session is goneCreate a new session with the same uploadId. Start from byte 0 if the server holds nothing
408, 429, 5xx, or no answerServer or network troubleAsk the offset first, then retry with backoff and jitter (photo sync case)
409 or 416 (offset mismatch)The phone and the server disagreeTrust the server offset and continue from it

Rows are my design. Google documents 410 and 404 for an expired session; the rest is how I would handle each answer.

3. What packaging and the backend must give the client

AskWhy the client needs it
2 or 3 renditions per reel: for example 360p, 540p and 720p, vertical, H.264Weak links need a low rung. Old phones decode H.264
Segments of about 2 seconds for HLS, or the index at the front of an MP4The first frame arrives after one small download
A key frame at the start of every segmentSeeking and quality changes are clean, and a preload of N seconds is exactly N seconds
A poster image for every reel, in 2 sizes, and a dominant colour or tiny previewThe cell is never black
Duration and aspect ratio in the feed rowThe cell has its size before any video loads
A stable rendition name and reel IDThe client builds the cache key from them, not from the signed address
Photo variants by width (for example 360, 720, 1080, 2160), in a modern format with a JPEG fallbackThe grid asks for the size of its cell, the viewer for the screen
Loudness-normalised audioThe user does not jump when the next reel is louder
A maximum length and size, rejected with a reasonThe client can show the reason and keep the local file

Photo variants through the address (for example a width parameter) are a common CDN feature. The exact parameter names depend on the CDN, so I say "ask for the width of the cell in pixels" and leave the spelling to the backend.

4. A player pool owned outside the UI (Android)

class ReelPlayerPool(
    private val builder: DefaultPreloadManager.Builder,
    private val size: Int,                      // 2 on low-RAM phones, 3 otherwise
) {
    private val free = ArrayDeque<ExoPlayer>()
    private val leased = mutableMapOf<String, ExoPlayer>()   // reelId -> player

    init { repeat(size) { free.add(builder.buildExoPlayer()) } }

    /** Lease a player for a reel. Returns null if none is free: the cell keeps its poster. */
    fun lease(reelId: String): ExoPlayer? {
        leased[reelId]?.let { return it }
        val p = free.removeFirstOrNull() ?: return null
        leased[reelId] = p
        return p
    }

    fun release(reelId: String) {
        val p = leased.remove(reelId) ?: return
        p.stop(); p.clearMediaItems()
        free.add(p)
    }

    /** Memory warning: keep one player, give the rest back to the system. */
    fun shrinkTo(n: Int) {
        while (free.size + leased.size > n && free.isNotEmpty()) free.removeFirst().release()
    }

    fun releaseAll() { (free + leased.values).forEach { it.release() }; free.clear(); leased.clear() }
}

The pool lives in the app scope (a singleton in your dependency graph), not in an activity or a ViewModel. A ViewModel is cleared when its screen leaves the back stack. The video case explains why a service is used there. A feed without background audio can use an app-scoped object, because nothing needs to keep playing after the user leaves the feed.

The preload manager and the players must come from the same builder, or their parts are not shared. This is stated in the Media3 preloading post (part 1).

class ReelPreloadPolicy : TargetPreloadStatusControl<Int, DefaultPreloadManager.PreloadStatus> {
    @Volatile var currentIndex = 0
    @Volatile var cheapNetwork = true          // false on cellular or Data Saver

    override fun getTargetPreloadStatus(rankingData: Int): DefaultPreloadManager.PreloadStatus? {
        val d = rankingData - currentIndex     // 1 = next, -1 = previous
        return when {
            d == 1  -> specifiedRangeLoaded(if (cheapNetwork) 5_000L else 2_000L)
            d == -1 && cheapNetwork -> specifiedRangeLoaded(1_000L)
            d == 2 && cheapNetwork -> DefaultPreloadManager.PreloadStatus.TRACKS_SELECTED
            else    -> null                    // nothing for the rest
        }
    }
}

This copies the shape of the example in the Media3 post, with my numbers. The post's own example uses 5 seconds for the next item, 3 seconds for the previous item, and more stages further away. Compile it against your Media3 version: the post says its snippets contain typos, and I did not check the method names against the API reference.

5. Disk cache for video bytes (Android)

val cache = SimpleCache(
    File(context.cacheDir, "reels"),
    LeastRecentlyUsedCacheEvictor(400L * 1024 * 1024),      // 400 MB, my starting point
    StandaloneDatabaseProvider(context),
)

// A stable key: reel + rendition. The signed query string must not be in it.
fun mediaItem(reelId: String, rendition: String, url: String) =
    MediaItem.Builder()
        .setUri(url)
        .setCustomCacheKey("$reelId:$rendition")
        .build()

Without the custom key, every new signed address is a new key, so the bytes already on disk are never found. This is the most common reason a "cached" feed still downloads every reel again. The video cache is allowed to evict. It lives in the cache folder, which is fine, because it holds only copies.

6. Play only the settled page (Compose)

@Composable
fun ReelsPager(reels: List<Reel>, pool: ReelPlayerPool, policy: ReelPreloadPolicy, preload: DefaultPreloadManager) {
    val pager = rememberPagerState { reels.size }

    LaunchedEffect(pager) {
        snapshotFlow { pager.settledPage }             // changes only when the swipe has ended
            .distinctUntilChanged()
            .debounce(150)                             // my number: skip fast flicks
            .collect { page ->
                policy.currentIndex = page
                preload.setCurrentPlayingIndex(page)
                preload.invalidate()
                // give back players that are not current or next
                reels.indices.filter { it != page && it != page + 1 && it != page - 1 }
                    .forEach { pool.release(reels[it].id) }
            }
    }

    VerticalPager(pager, beyondViewportPageCount = 0) { page ->
        ReelCell(reels[page], isCurrent = page == pager.settledPage, pool = pool)
    }
}

beyondViewportPageCount = 0 means the pager composes only the visible page. Preloading is the preload manager's job, not the pager's. settledPage is the page the pager has come to rest on. I wrote this from my knowledge of Compose foundation and did not re-read its page. The cell shows the poster until the player reports that it has drawn the first frame (the onRenderedFirstFrame callback of the player listener). A cell that is not current does not play, even if it holds a prepared player.

7. The posting pipeline

The order matters more than the code. This is the order from the main page, with the code that carries each step.

suspend fun onPostTapped(picked: Uri, caption: String, placeId: String?) {
    // 1. Copy at once. A picker address may stop working later.
    val local = copyToAppFiles(picked)                       // filesDir/outgoing/<uuid>.mp4

    // 2. Check space first. Compress needs room for the output.
    require(freeBytes() > local.length() * 2) { "Not enough space" }

    // 3. Write the IDs and the file path before any network call.
    val postId = UUID.randomUUID().toString()
    val uploadId = UUID.randomUUID().toString()
    db.withTransaction {
        db.posts().insert(PendingPostEntity(postId, caption, placeId, "SAVED", now()))
        db.uploads().insert(PendingUploadEntity(uploadId, postId, local.path, local.length(), 0, null, null))
    }

    // 4. Compress on the phone (Media3 Transformer), then hand over to the runner.
    compressTo1080p(local, uploadId)
    uploadRunner.enqueue(postId)            // user-initiated job on Android 14+, WorkManager fallback below
}

Media3 Transformer trims and transcodes, and uses MediaCodec for hardware encoding (Transformer). Its overview does not mention pause or resume, so I treat a compress as all-or-nothing: if the app is killed during it, the next start deletes the partial output and starts the compress again from the copied file. That is why step 1 copies the original into the app files folder first.

suspend fun runUpload(postId: String) {
    val post = db.posts().get(postId) ?: return
    for (up in db.uploads().forPost(postId)) {
        var session = up.sessionUrl?.takeIf { (up.sessionExpiresAt ?: 0) > now() }
            ?: api.createUpload(up.uploadId, up.bytes).also { db.uploads().saveSession(up.uploadId, it) }.sessionUrl

        // Ask the server, never trust our own counter after a restart.
        var offset = api.offset(up.uploadId)
        db.uploads().saveOffset(up.uploadId, offset)

        RandomAccessFile(File(up.localPath), "r").use { f ->
            while (offset < up.bytes) {
                val n = minOf(CHUNK, up.bytes - offset).toInt()
                val buf = ByteArray(n).also { f.seek(offset); f.readFully(it) }
                when (val r = api.putChunk(session, offset, buf)) {
                    is Ok -> { offset = r.serverOffset; db.uploads().saveOffset(up.uploadId, offset) }
                    is AuthExpired -> { auth.refresh(); session = api.createUpload(up.uploadId, up.bytes).sessionUrl; offset = api.offset(up.uploadId) }
                    is SessionGone -> { session = api.createUpload(up.uploadId, up.bytes).sessionUrl; offset = api.offset(up.uploadId) }
                    is Retry -> delay(r.backoffMs)
                }
            }
        }
    }
    api.createPost(postId, db.uploads().forPost(postId).map { it.uploadId }, post.caption, post.placeId)
    db.posts().setState(postId, "PROCESSING")
}

Three things to notice. The upload ID never changes. The offset comes from the server at the start and after every error. And the post is created only after all bytes are in, with the post ID the user's tap made. A double tap or a restart cannot make a second post.

What starts runUpload:

Android versionRunnerNotes
14 and laterUser-initiated data transfer jobNeeds RUN_USER_INITIATED_JOBS, a visible notification, and a visible app when it is scheduled. No Jetpack library supports it. If the user stops the app from the task manager, onStopJob() is not called, so the offset must already be saved (docs)
13 and lowerWorkManager with a foreground serviceThe fallback named in the same page. A foreground service of the data-sync type has a time limit on Android 15 and later (video case); stop in the timeout callback and go on later
AnyA retry on the next app startRead the rows in SAVED, UPLOADING and WAITING and enqueue them again

On Wi-Fi-only: the first time a user posts a large video on cellular, ask once, and save the answer. The state is then WAITING with the reason "Waiting for Wi-Fi".

8. Large photos and the memory budget

// Android: decode to the target size in one step (API 28+).
fun decodeForViewer(src: ImageDecoder.Source, maxW: Int, maxH: Int): Bitmap =
    ImageDecoder.decodeBitmap(src) { decoder, info, _ ->
        val s = minOf(maxW.toFloat() / info.size.width, maxH.toFloat() / info.size.height, 1f)
        decoder.setTargetSize((info.size.width * s).toInt(), (info.size.height * s).toInt())
    }

Below API 28, use BitmapFactory.Options.inJustDecodeBounds to read the size, compute inSampleSize, and decode again. inSampleSize is rounded down to a power of two (Loading large bitmaps). In practice an image library does this for you: give it the target size of the view. The main point for an interview is that the target size is a decision of the screen, and the file size is not.

PhotoPixelsDecoded size (4 bytes a pixel)
Grid thumbnail360 x 3600.5 MB
Viewer copy1080 x 14406.2 MB
12 MP original4000 x 300048 MB
48 MP original8000 x 6000192 MB
One zoom tile512 x 5121 MB

Width times height times 4. My arithmetic. Some formats use more or less per pixel, and the system may keep a second copy while it converts.

class MediaBudget(context: Context, private val pool: ReelPlayerPool, private val images: ImageCaches) : ComponentCallbacks2 {
    private val am = context.getSystemService(ActivityManager::class.java)
    val lowRam = am.isLowRamDevice
    val poolSize = if (lowRam) 2 else 3
    val imageMemoryBytes = (am.memoryClass * 1024L * 1024L * if (lowRam) 0.10 else 0.15).toLong()  // my numbers

    override fun onTrimMemory(level: Int) {
        if (level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW) {   // check the levels for your target API
            images.dropOffScreenDecoded()
            images.clearMemoryCache()
            pool.shrinkTo(1)
        }
    }
    override fun onConfigurationChanged(c: Configuration) {}
    override fun onLowMemory() { onTrimMemory(ComponentCallbacks2.TRIM_MEMORY_COMPLETE) }
}

isLowRamDevice and memoryClass are standard ActivityManager values. I did not re-read their pages, and I did not verify the trim levels. Treat the percentages as starting points to tune on real low-end phones.

9. How to build it on iOS (Swift)

The design is the same. These are the iOS parts that differ.

// Photos: decode straight to a target size with ImageIO. Never decode the original and shrink it.
func downsampled(_ url: URL, maxPixel: Int) -> CGImage? {
    let srcOpts = [kCGImageSourceShouldCache: false] as CFDictionary
    guard let src = CGImageSourceCreateWithURL(url as CFURL, srcOpts) else { return nil }
    let opts = [
        kCGImageSourceCreateThumbnailFromImageAlways: true,
        kCGImageSourceShouldCacheImmediately: true,
        kCGImageSourceCreateThumbnailWithTransform: true,      // applies the rotation from the metadata
        kCGImageSourceThumbnailMaxPixelSize: maxPixel,
    ] as CFDictionary
    return CGImageSourceCreateThumbnailAtIndex(src, 0, opts)
}

The ImageIO thumbnail function is documented by Apple. I did not read the page for the option keys, so check them. Keep the pixel cost in mind: a decoded CGImage still costs width times height times 4 bytes.

// Preload: assign the item to a paused player so it buffers, and cap the buffer.
func prepare(_ reel: Reel, on player: AVPlayer) {
    let item = AVPlayerItem(url: reel.playbackURL)
    item.preferredForwardBufferDuration = 5       // seconds; low raises stall risk, high costs resources
    player.replaceCurrentItem(with: item)
    player.pause()
}

Apple documents preferredForwardBufferDuration as the seconds the player should buffer ahead, and says a low value raises the chance of stalls while a high value raises the demand on system resources. Whether a paused player buffers the full amount before it plays is my reading, so measure time to first frame on a real phone. For a poster, use AVPlayerLayer.isReadyForDisplay to know that the first frame is on screen. The iOS limit on the number of players at once is not documented in a page I found, so keep the pool at two or three and test.

// Upload: a background session, one task per chunk, each chunk from its own file.
final class Uploader: NSObject, URLSessionTaskDelegate {
    private lazy var session: URLSession = {
        let c = URLSessionConfiguration.background(withIdentifier: "app.media.upload")
        c.sessionSendsLaunchEvents = true
        return URLSession(configuration: c, delegate: self, delegateQueue: nil)
    }()

    func sendNextChunk(upload: PendingUpload, offset: Int64) throws {
        let chunkURL = try writeChunkFile(of: upload.localURL, from: offset, length: chunkSize)   // in Application Support
        var req = URLRequest(url: upload.sessionURL); req.httpMethod = "PATCH"
        req.setValue("\(offset)", forHTTPHeaderField: "Upload-Offset")
        let task = session.uploadTask(with: req, fromFile: chunkURL)
        task.taskDescription = upload.uploadId          // read it back after a relaunch
        task.resume()
    }

    func urlSession(_ s: URLSession, task: URLSessionTask, didCompleteWithError e: Error?) {
        // Save the server offset from the response, delete the chunk file, send the next one.
        // On 401 or 403: refresh the token, ask for the session of the same uploadId.
    }
}

Apple says background uploads must come from a file, that the session must have a delegate, and that after the system ends the app you recreate the session with the same identifier (docs). Apple also says the system limits how fast a relaunched app can start new background tasks, and advises one session that starts many tasks. For a long video, cutting it into chunk files up front is one way to use that advice; the other is to send fewer, larger chunks. I chose a task per chunk because the server offset is the truth after each one.

iOS 26 adds BGContinuedProcessingTask for work the user starts in the foreground and that may continue in the background. The system shows its progress in a Live Activity and the person can cancel it, and the system may end it when resources are short (Apple). I would use it for the compress step, and the background session for the transfer. I did not read the request side of the API, so check it.

Picking media on iOS: the system picker gives the app an item to copy. Copy the file into the app's own folder at once, as on Android. Do not keep only the picker's reference. I did not verify how long the picker's temporary files last.

10. Platform limits that change the design

LimitAndroidiOSWhat it changes
Hardware video decoders at onceLimited per codec; the capabilities object reports a maximum (not verified)Not documented in a page I foundPool of two or three, never one player per cell
Memory for a decoded photoWidth x height x 4 bytesSameDecode to the target size
App ended for memoryOut-of-memory crash, or the system ends the processThe system ends the app, often with no warningStay small before any warning, shrink on the warning
Long upload in the backgroundUser-initiated data transfer job (14+), WorkManager foreground fallbackBackground URLSession from files; BGContinuedProcessingTask on 26+Save the offset after every chunk
User force-stops the appThe process ends; user-initiated jobs are not rescheduledActive background transfers are cancelled (from the video case, not re-checked)Rows and files on disk, resume on the next launch
Cache foldersMay be cleared when storage is lowMay be cleared when storage is low (not verified)Pending uploads live in the files folder

11. What to measure

MetricWhy
Time to first frameFrom the swipe settling to the first frame drawn. The number users feel
Stall shareShare of watch time spent stalled
Preload bytes never playedIf it is high, the window is too deep
Playback start failures, by device classFinds decoder shortages and old phones
Low-memory crashes and system kills, by device class and screenFinds the pool or viewer cost
Upload success rate, resume count, bytes sent twiceShows if the offset logic works. Bytes sent twice should be near zero
Time from Post to LiveWhat the creator waits for. Processing is the usual part

12. More practice prompts

Practice prompts, not a claim about what any interviewer asks.

  1. The user taps Post, turns on airplane mode, and kills the app. What does the profile show, and what happens at the next launch with network? (The post shows as Posting. The rows and file are on disk. The runner reads them and asks for the offset of the same ID.)
  2. A creator posts a 40 minute video by mistake. Where do you stop it? (In the picker and the compress step, with a clear reason, before any bytes leave the phone. The server also rejects it with a reason.)
  3. Reels stall only on one phone brand after an update. How do you find out why? (Split the data by device class and decoder. Check if the pool leased a player that failed to get a decoder. Lower the pool with a remote setting.)
  4. How would you add a "download for offline" button? (Different cache, never evicted, with a license if the content needs one. See the video case.)
  5. How would the design change for a gallery of 5,000 photos from the phone? (Page the picker, thumbnails from the system, upload each in the background with its own ID, and the photo sync case for the rest.)
  6. The same photo is uploaded from two phones. What happens? (Two upload IDs, so two uploads. Dedupe by content hash on the server is a product choice, not a retry rule.)

Further reading (official docs)

No outside reviewer yet. The iOS parts are not reviewed by an iOS engineer. The code is a sketch and was not compiled. Checked 20 Oct 2026.

Keep reading

Get the free one-page cheat sheet

One page: the five steps, a 45-minute time plan and two checklists. Print it or keep it on your phone.

We email you the cheat sheet after you confirm your address. You can also open it now from the Start here page. You also get a short email each week with a follow-up round, and a note when a new case is published. Unsubscribe any time. Privacy.