Setting Up Media Storage for Mobile Apps (S3/Cloud Storage)
Uploading photos and videos from mobile app — task easily underestimated. Single 50 MB video request through backend proxy — server load, double traffic, slow upload for user. Right architecture: presigned URL → direct upload from client to S3 or GCS, backend only issues token and gets confirmation.
Provider Choice
| Provider | SDK | Strengths |
|---|---|---|
| AWS S3 | aws-sdk-swift, aws-sdk-kotlin, Amplify | Maturity, rich features, multipart out of box |
| Google Cloud Storage | google-cloud-storage (Java/Kotlin), GCS REST API | Good Firebase integration, GCP |
| Cloudflare R2 | S3-compatible API | No egress traffic, cheaper |
| Backblaze B2 | S3-compatible API | Most affordable object storage |
R2 and B2 compatible with S3 API — same client code, just different endpoint.
Upload with Progress
User sees progress bar — non-optional for video. iOS via URLSession.uploadTask with delegate:
class MediaUploader: NSObject, URLSessionTaskDelegate {
private lazy var session: URLSession = {
URLSession(configuration: .default, delegate: self, delegateQueue: nil)
}()
func upload(fileURL: URL, presignedURL: URL,
progress: @escaping (Double) -> Void,
completion: @escaping (Result<Void, Error>) -> Void) {
var request = URLRequest(url: presignedURL)
request.httpMethod = "PUT"
request.setValue(fileURL.mimeType, forHTTPHeaderField: "Content-Type")
let task = session.uploadTask(with: request, fromFile: fileURL)
task.taskDescription = fileURL.lastPathComponent
uploadCompletionHandlers[task.taskIdentifier] = completion
uploadProgressHandlers[task.taskIdentifier] = progress
task.resume()
}
func urlSession(_ session: URLSession, task: URLSessionTask,
didSendBodyData bytesSent: Int64,
totalBytesSent: Int64, totalBytesExpectedToSend: Int64) {
let progress = Double(totalBytesSent) / Double(totalBytesExpectedToSend)
uploadProgressHandlers[task.taskIdentifier]?(progress)
}
}
On Android via OkHttp with custom RequestBody:
class ProgressRequestBody(
private val file: File,
private val contentType: MediaType,
private val onProgress: (Int) -> Unit
) : RequestBody() {
override fun contentType() = contentType
override fun contentLength() = file.length()
override fun writeTo(sink: BufferedSink) {
val buffer = ByteArray(8192)
var uploaded = 0L
file.inputStream().use { input ->
var bytesRead: Int
while (input.read(buffer).also { bytesRead = it } != -1) {
sink.write(buffer, 0, bytesRead)
uploaded += bytesRead
val progress = (uploaded * 100 / file.length()).toInt()
onProgress(progress)
}
}
}
}
Multipart Upload for Video
S3 requires multipart for files over 5 MB (recommended from 100 MB). Advantages: parallel part upload, resume after interruption.
class S3MultipartUploader(private val s3Client: S3Client) {
suspend fun upload(bucketName: String, key: String, file: File): String {
// 1. Initialize multipart upload
val createResponse = s3Client.createMultipartUpload {
bucket = bucketName
this.key = key
contentType = file.detectMimeType()
}
val uploadId = createResponse.uploadId!!
val partSize = 10 * 1024 * 1024L // 10 MB per part
val parts = mutableListOf<CompletedPart>()
try {
file.inputStream().use { stream ->
var partNumber = 1
val buffer = ByteArray(partSize.toInt())
var bytesRead: Int
while (stream.read(buffer).also { bytesRead = it } != -1) {
val partData = buffer.copyOf(bytesRead)
val uploadPartResponse = s3Client.uploadPart {
bucket = bucketName
this.key = key
this.uploadId = uploadId
this.partNumber = partNumber
body = ByteStream.fromBytes(partData)
}
parts.add(CompletedPart {
this.partNumber = partNumber
eTag = uploadPartResponse.eTag
})
partNumber++
}
}
// 3. Complete multipart upload
s3Client.completeMultipartUpload {
bucket = bucketName
this.key = key
this.uploadId = uploadId
multipartUpload = CompletedMultipartUpload { this.parts = parts }
}
return "https://$bucketName.s3.amazonaws.com/$key"
} catch (e: Exception) {
// Cleanup incomplete upload — otherwise charged
s3Client.abortMultipartUpload {
bucket = bucketName
this.key = key
this.uploadId = uploadId
}
throw e
}
}
}
abortMultipartUpload on error — important. Incomplete multipart uploads continue to be billed in AWS. Add Lifecycle Rule to delete incomplete uploads after 7 days as insurance.
Media Processing Before Upload
4K 200 MB video directly to S3 — rarely needed. On client before upload:
-
Images: compression via
UIGraphicsImageRenderer(iOS) orBitmapFactory.Options.inSampleSize(Android). WebP instead of JPEG — better quality/size ratio. -
Video: transcode via AVAssetExportSession (iOS) or MediaCodec/Transformer (Android) to 1080p/720p. For React Native —
react-native-video-processing.
// iOS: compress video before upload
let exporter = AVAssetExportSession(asset: asset, presetName: AVAssetExportPreset1280x720)!
exporter.outputURL = outputURL
exporter.outputFileType = .mp4
exporter.shouldOptimizeForNetworkUse = true
await exporter.export()
// After export — upload outputURL to S3
Client-side compression — tradeoff: less traffic, faster upload, but CPU load on device.
CDN for Distribution
S3 directly — only for private files. Public media (avatars, content) — via CloudFront or Cloudflare CDN. This caching on edge nodes worldwide and HTTPS without additional setup.
Setting up media storage with presigned upload, multipart for video, CDN and compression: 1–2 weeks. Cost calculated individually.







