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
| Minutes | Step | What I have drawn by the end | Skip if late |
|---|---|---|---|
| 0 to 4 | Scope and constraints | Scope table, what the phone does | Fewer questions |
| 4 to 14 | Data and contracts | Feed row, upload calls, saved IDs | The packaging table |
| 14 to 22 | Client structure | Player pool, upload manager, media budget | One line per box |
| 22 to 40 | Make the hard part work | Preload window, post states, photo sizes | iOS bullets |
| 40 to 45 | Break it, ship it, roll it back | Top failures, remote settings, what I watch | Two 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.
confirmedOffsetis 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.
LIVEnever goes back toPROCESSING. 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 safeI 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:
| Answer | Meaning | What the phone does |
|---|---|---|
| 204 or 200 | Chunk saved | Save the new confirmed offset, send the next chunk |
| 401 or 403 | Token or signed session no longer accepted | Refresh the token, ask for the session of the same uploadId, ask the offset, continue |
| 404 or 410 | Session is gone | Create a new session with the same uploadId. Start from byte 0 if the server holds nothing |
| 408, 429, 5xx, or no answer | Server or network trouble | Ask the offset first, then retry with backoff and jitter (photo sync case) |
| 409 or 416 (offset mismatch) | The phone and the server disagree | Trust 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
| Ask | Why the client needs it |
|---|---|
| 2 or 3 renditions per reel: for example 360p, 540p and 720p, vertical, H.264 | Weak 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 MP4 | The first frame arrives after one small download |
| A key frame at the start of every segment | Seeking 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 preview | The cell is never black |
| Duration and aspect ratio in the feed row | The cell has its size before any video loads |
| A stable rendition name and reel ID | The 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 fallback | The grid asks for the size of its cell, the viewer for the screen |
| Loudness-normalised audio | The user does not jump when the next reel is louder |
| A maximum length and size, rejected with a reason | The 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 version | Runner | Notes |
|---|---|---|
| 14 and later | User-initiated data transfer job | Needs 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 lower | WorkManager with a foreground service | The 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 |
| Any | A retry on the next app start | Read 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.
| Photo | Pixels | Decoded size (4 bytes a pixel) |
|---|---|---|
| Grid thumbnail | 360 x 360 | 0.5 MB |
| Viewer copy | 1080 x 1440 | 6.2 MB |
| 12 MP original | 4000 x 3000 | 48 MB |
| 48 MP original | 8000 x 6000 | 192 MB |
| One zoom tile | 512 x 512 | 1 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
| Limit | Android | iOS | What it changes |
|---|---|---|---|
| Hardware video decoders at once | Limited per codec; the capabilities object reports a maximum (not verified) | Not documented in a page I found | Pool of two or three, never one player per cell |
| Memory for a decoded photo | Width x height x 4 bytes | Same | Decode to the target size |
| App ended for memory | Out-of-memory crash, or the system ends the process | The system ends the app, often with no warning | Stay small before any warning, shrink on the warning |
| Long upload in the background | User-initiated data transfer job (14+), WorkManager foreground fallback | Background URLSession from files; BGContinuedProcessingTask on 26+ | Save the offset after every chunk |
| User force-stops the app | The process ends; user-initiated jobs are not rescheduled | Active background transfers are cancelled (from the video case, not re-checked) | Rows and files on disk, resume on the next launch |
| Cache folders | May be cleared when storage is low | May be cleared when storage is low (not verified) | Pending uploads live in the files folder |
11. What to measure
| Metric | Why |
|---|---|
| Time to first frame | From the swipe settling to the first frame drawn. The number users feel |
| Stall share | Share of watch time spent stalled |
| Preload bytes never played | If it is high, the window is too deep |
| Playback start failures, by device class | Finds decoder shortages and old phones |
| Low-memory crashes and system kills, by device class and screen | Finds the pool or viewer cost |
| Upload success rate, resume count, bytes sent twice | Shows if the offset logic works. Bytes sent twice should be near zero |
| Time from Post to Live | What the creator waits for. Processing is the usual part |
12. More practice prompts
Practice prompts, not a claim about what any interviewer asks.
- 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.)
- 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.)
- 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.)
- 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.)
- 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.)
- 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)
- Design reels, video and photos for a travel app: the 45 minute answer
- Android Developers: preloading with Media3, part 1
- Media3 Transformer
- User-initiated data transfer jobs
- Loading large bitmaps efficiently
- Downloading files in the background
- BGContinuedProcessingTask
- AVPlayerItem preferredForwardBufferDuration
- CGImageSourceCreateThumbnailAtIndex
- Google Cloud Storage: resumable uploads
- Amazon S3: multipart upload limits
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.