ST
← All writing · 15 min read

377 videos, two bugs: making big uploads survive iOS

A single real shoot's 377 videos wouldn't upload from my React Native app. One bug was the letter case of a header; the other was iOS clearing the picker's cache.

  • React Native
  • iOS
  • Uploads
  • Debugging

theReelshoot is a React Native app I built for event reel shoots. Creators film on their phones, then upload the footage from the same phone so it can be edited. Photos uploaded fine. Then a single real shoot produced a batch of 377 videos, and not one of them would go up.

There were two bugs, and the second only showed itself once the first was fixed. Neither is exotic, and both are worth knowing about if you move large files off an iPhone.

How a video gets from the phone to storage

The app never uploads through my own server. Bytes go straight from the phone to Cloudflare R2, an S3-compatible object store, using URLs that the server signs only after checking that the creator is actually assigned to that shoot. The server handles two small JSON calls per file: one to get signed URLs, and one to say the upload is finished.

Small files, which in practice means photos, go up as a single signed PUT. Anything over 64 MB goes up in parts. On a phone that matters for memory as much as for the network: the app has to hold a part in memory while it slices the file, so the parts need to be small.

Transfers run as iOS background sessions through expo-file-system's upload tasks. A creator can lock the phone or switch apps and the current transfer keeps going. A background session also keeps retrying through a patchy connection instead of failing outright, which is the normal state of the signal at a venue.

above this, a file goes multipart
64 MB
above this, a file goes multipart
part size the phone asks for
8 MB
part size the phone asks for
life of each signed URL
1 hour
life of each signed URL
largest video accepted
5 GB
largest video accepted

The phone asks for 8 MB parts. The server clamps whatever it's asked for to between 5 MB, the smallest part S3-style storage accepts, and 32 MB, the default it uses when nobody asks. A signed URL lives for an hour because a multi-gigabyte file on venue Wi-Fi is slow.

Presigned multipart uploads, step by step

Multipart upload is part of the S3 API, and R2 implements it. You open an upload, send the file as numbered parts, and then ask storage to stitch the parts into one object. With presigned URLs, the phone does the heavy lifting without ever holding a storage credential.

  1. 01Create the upload
  2. 02Presign every part
  3. 03PUT each part to R2
  4. 04Collect each part's ETag
  5. 05Complete the upload
One large video, end to end. The highlighted step is where the first bug lived.
  1. Create. The phone asks the server to sign an upload and sends the file's name, type, size and preferred part size. The server calls CreateMultipartUpload on R2, gets back an upload ID, and records the file as pending.
  2. Presign. The server works out how many parts the file needs, Math.ceil(bytes / partSize), and signs one PUT URL per part number.
  3. PUT. The phone slices the video and sends each slice to its URL, straight to R2.
  4. ETag. For each part, R2 replies with an ETag header. It is storage's receipt for that part.
  5. Complete. The phone tells the server it's done. The server calls CompleteMultipartUpload with every part number and its ETag, and R2 assembles the object.

The server side is two small functions on top of the AWS SDK:

r2.ts
export async function createMultipartUpload(key: string, contentType: string) {
  const out = await s3.send(
    new CreateMultipartUploadCommand({ Bucket: bucket, Key: key, ContentType: contentType }),
  )
  if (!out.UploadId) throw new Error('R2 did not return an upload id')
  return out.UploadId
}
 
export async function presignParts(key: string, uploadId: string, partCount: number) {
  return Promise.all(
    Array.from({ length: partCount }, async (_unused, index) => {
      const partNumber = index + 1
      const url = await getSignedUrl(
        s3,
        new UploadPartCommand({ Bucket: bucket, Key: key, UploadId: uploadId, PartNumber: partNumber }),
        { expiresIn: 60 * 60 },
      )
      return { partNumber, url }
    }),
  )
}

On the phone, each part is cut out of the video with a ranged read, written to a small temporary file, and uploaded from that file:

mediaUpload.ts
const etags: { partNumber: number; etag: string }[] = []
for (const part of signed.parts) {
  const start = (part.partNumber - 1) * signed.partSize
  const length = Math.min(signed.partSize, file.bytes - start)
  const chunkUri = `${FileSystem.cacheDirectory}upload-part-${assetId}-${part.partNumber}`
 
  // Copy just this slice of the video into its own file.
  const base64 = await FileSystem.readAsStringAsync(file.uri, {
    encoding: 'base64',
    position: start,
    length,
  })
  await FileSystem.writeAsStringAsync(chunkUri, base64, { encoding: 'base64' })
 
  try {
    const etag = await putFile(part.url, chunkUri, '', onPartProgress)
    if (etag) etags.push({ partNumber: part.partNumber, etag })
  } finally {
    await FileSystem.deleteAsync(chunkUri, { idempotent: true })
  }
}
return await finish(assetId, etags)

Two choices here are deliberate. Parts go one at a time, because a phone holding several chunks at once is how you get an out-of-memory crash on a mid-range Android. And each part is written to a file first, which suits an iOS background session: it uploads from a file, not from memory.

putFile is a thin wrapper around an expo-file-system upload task:

mediaUpload.ts
const task = FileSystem.createUploadTask(url, fileUri, {
  httpMethod: 'PUT',
  uploadType: FileSystem.FileSystemUploadType.BINARY_CONTENT,
  sessionType: FileSystem.FileSystemSessionType.BACKGROUND,
}, onProgress)
 
const result = await task.uploadAsync()
if (!result) throw new Error('Upload was cancelled')
if (result.status < 200 || result.status >= 300) {
  throw new Error(`Storage rejected the file (${result.status})`)
}
// ...then read the ETag out of result.headers

That last line is where things went wrong.

Bug 1: the header that changed its spelling

The symptom was blunt. Every video in the batch failed with the app's own error message, "Storage did not confirm a part". Every photo went through.

The photo half was the clue. Phone photos are well under 64 MB, so they take the single-PUT path, and a single PUT doesn't need an ETag for anything. Only the multipart path reads that header. So whatever was wrong, it was in step 4.

The part loop had a guard that looked roughly like this:

mediaUpload.ts (before, simplified)
const etag = result.headers?.ETag ?? result.headers?.etag
if (!etag) throw new Error('Storage did not confirm a part')

The status check had already passed. R2 had accepted the part and replied 200. Then the guard looked for the header under two spellings, found neither, and threw. The bytes were in storage; the app had decided they weren't.

What iOS actually hands back

expo-file-system types the upload result's headers as Record<string, string>. On iOS, the native side fills it straight from NSHTTPURLResponse's allHeaderFields: a plain dictionary, bridged into a plain JavaScript object. Apple's networking stack normalises some header names into a canonical form before you ever see them, and for this header the key it produced was Etag. Capital E, the rest lower case. Not ETag, the way the S3 documentation writes it. Not etag, the way HTTP/2 sends every header name on the wire.

This is why the bug was so easy to write. Code that reads headers through fetch or XHR never has to think about case, because the API does it for you. The moment headers arrive as a plain object from a native bridge, that guarantee is gone, and the code looks exactly the same as before.

The fix is a case-insensitive scan:

mediaUpload.ts
// Header casing varies by platform — iOS's NSURLSession canonicalises to
// "Etag", HTTP/2 servers send "etag" — so match case-insensitively.
const etagEntry = Object.entries(result.headers ?? {}).find(
  ([key]) => key.toLowerCase() === 'etag',
)
const etag = etagEntry?.[1] ?? ''
return etag.replace(/"/g, '') || null

ETag values arrive wrapped in double quotes, so those are stripped before the value goes anywhere. And the lookup no longer cares which spelling the next platform, library version or protocol picks.

Why didn't it show up sooner? Photos are small enough to go up in a single request, so they never touch this path. Only large videos take the multipart route, and that batch was where it first met a real iPhone at full scale.

Bug 2: the files that disappeared

The fix for bug 1 shipped, the batch was resumed, and a new error appeared. This time readAsStringAsync was failing because the file was "not readable", and the path ended in ImagePicker/ followed by a .MOV file name.

expo-image-picker doesn't hand your app the original from the photo library. It writes a copy into the app's own caches folder, under Library/Caches/ImagePicker/, and returns a file:// URI pointing at that copy. For a video, that is a full copy of the file.

The upload queue persisted those URIs, so a creator could pick a batch, put the phone away, and have the uploads resume later. That made the queue's references only as durable as the folder they pointed into, and Library/Caches is the one folder iOS is allowed to empty. It can clear it when the device runs short on space, and nothing promises its contents will still be there tomorrow.

Between picking and uploading, the phone had taken on a second full copy of 377 videos in that folder, and had installed an app update carrying the fix for bug 1. When the queue came back, the cached copies were gone. The originals were safe in Photos. The queue's references were dead.

There was a second, smaller problem in the same moment. At the time, the queue stopped at the first error of any kind, so one missing file jammed every file behind it.

Staging: move, don't copy

Before

  • The picker writes a copy to Library/Caches/ImagePicker/
  • The queue stores that URI
  • iOS clears Caches when it needs the space
  • The queue wakes up to unreadable files

After

  • The copy is moved to Documents/upload-staging/
  • The queue stores the new URI
  • iOS doesn't clear Documents
  • The staged file is deleted once it has uploaded

Every picked file is now moved out of the picker's cache before anything is queued:

mediaUpload.ts
const STAGING_DIR = `${FileSystem.documentDirectory}upload-staging/`
 
export async function stagePickedFile(file: PickedFile): Promise<PickedFile> {
  const dot = file.fileName.lastIndexOf('.')
  const ext = dot > 0 ? file.fileName.slice(dot) : ''
 
  let uri = file.uri
  try {
    await FileSystem.makeDirectoryAsync(STAGING_DIR, { intermediates: true }).catch(() => {})
    const dest = `${STAGING_DIR}${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`
    await FileSystem.moveAsync({ from: file.uri, to: dest })
    uri = dest
  } catch {
    // Odd URI scheme or permissions: keep the original URI and rely on
    // the availability check at upload time.
  }
 
  // The file on disk is the truth about its size, not the picker's metadata.
  const info = await FileSystem.getInfoAsync(uri)
  const bytes = info.exists && info.size > 0 ? info.size : file.bytes
 
  return { ...file, uri, bytes }
}

The move is a rename inside the app's own container, so it is quick and costs no extra storage. The screen runs it over the picked files one at a time, which keeps the native bridge calm on very large batches. Once a file has uploaded, discardStagedFile deletes it, and it refuses to touch anything outside the staging folder. If a move ever fails, the cleanup leaves the picker's copy alone.

Reading the size from disk matters more than it looks. The real code also fills in a missing file name from the URI, because passthrough picking sometimes omits metadata. The size, though, is load-bearing: the server decides how many parts to sign from the declared size, and the phone slices the file using the same number. If the declared size is wrong, the slicing is wrong. Taking it from disk means declared and real are always the same.

Check before trusting

Staging removes the common failure, not every failure. So before each upload, the queue checks the file is still there:

mediaUpload.ts
/** False when the queued copy no longer exists (purged cache, app update). */
export async function isFileAvailable(uri: string): Promise<boolean> {
  try {
    const info = await FileSystem.getInfoAsync(uri)
    return info.exists === true
  } catch {
    return false
  }
}

A missing file is dropped from the queue and counted. At the end of the run the creator sees one alert, "Some files need re-picking", which says how many files were cleared and that they are still in Photos. One dead file no longer blocks the 376 behind it.

The queue: what survives and what restarts

The queue itself is deliberately small. It is a list in AsyncStorage, and each entry records the intent: which shoot, which event, what kind of media, and the staged file.

uploadQueue.ts
export type QueuedUpload = {
  id: string
  bookingId: string
  bookingEventId: string | null
  kind: MediaKind
  file: PickedFile
  addedAt: number
}
  1. 01Pick from Photos
  2. 02Move to staging
  3. 03Save to the queue
  4. 04Check the file exists
  5. 05Upload
  6. 06Delete the staged copy
The life of one queued file. The two highlighted steps are the bug 2 fixes.

The runner works through one file at a time, and it re-reads the queue from storage on every pass, so a batch added mid-run joins the same run:

CreatorShootMediaScreen.tsx
let pending = await pendingForBooking(bookingId)
while (pending.length > 0) {
  const item = pending[0]
 
  if (!(await isFileAvailable(item.file.uri))) {
    await dequeue(item.id)
    skipped += 1
    pending = await pendingForBooking(bookingId)
    continue
  }
 
  const result = await uploadShootFile(item.file, { bookingId, kind: item.kind, onProgress })
  if (result.ok) {
    await discardStagedFile(item.file.uri)
    await dequeue(item.id)
  } else {
    // Stays queued with its error and a Retry button. Stop the run:
    // an error on one file is usually an error on all (signal, quota).
    showError(item.id, result.error)
    break
  }
  pending = await pendingForBooking(bookingId)
}

Here is what that buys, and what it doesn't.

What survives. If the app is killed, crashes or the battery dies, the queue is still in storage. The next time the creator opens that shoot, leftover files start uploading on their own. A guard stops two runners from going at once.

How retries work. Inside a transfer, the iOS background session retries through a bad connection. At the file level there is no automatic retry loop: a failed file keeps its place, shows its error, and waits for Retry. Stopping the run is on purpose, because if one file failed for lack of signal or storage, the next 300 will too. There is also a Wi-Fi only switch, off by default. When it is on and the phone is on mobile data, the queue pauses with a banner and an "Upload now" button rather than sitting silently at 0%.

What restarts. Resumability is per file, not per part. The queue stores intent, not progress, so a file interrupted halfway restarts from its first part. The leftovers are cleaned up rather than left to pile up. A failed upload tells the server to abort its multipart upload so the parts stop costing money. A daily sweep looks at anything still pending after 24 hours: a single PUT whose bytes did land is finished off, and anything else is aborted and released, so it stops counting against the shoot's storage allowance.

Re-picking is cheap. When the same creator signs a file the shoot already holds, with the same name and size, the server says so and the phone skips the transfer. That is what makes "pick them again" a reasonable recovery for a batch of 377: anything that already landed costs nothing the second time.

The deeper fix: let storage keep the receipts

About two weeks later, I changed who is trusted with the ETags.

The case-insensitive lookup fixed bug 1, but it left the phone as the only record of which parts had landed. That record isn't always available. The comment in the code puts it plainly: an iOS background session that finishes while the app is suspended can resolve without response headers, so the phone never learns its own ETags. The bytes are in storage either way.

So the completion step now asks R2 directly. ListParts returns every part storage holds for that upload, with its ETag, and the client's list is only a fallback:

complete/route.ts
if (asset.uploadId) {
  // Ask storage what it actually holds; the client's ETags are a fallback.
  let parts = await listUploadedParts(asset.key, asset.uploadId).catch(() => [])
  if (parts.length === 0) parts = partsFromClient(body.parts)
  if (parts.length === 0) return fail('Missing uploaded parts')
 
  await completeMultipartUpload(asset.key, asset.uploadId, parts)
}
 
// Confirm the object exists and record its real size. The size the
// phone claimed at signing time is never trusted.
const head = await headObject(asset.key)

And on the phone, a missing ETag stopped being an error:

mediaUpload.ts
const etag = await putFile(part.url, chunkUri, '', onPartProgress)
// A missing ETag is NOT a failure: the server asks R2 for the real ETags
// at completion time. Send what we have; it's only a fallback.
if (etag) etags.push({ partNumber: part.partNumber, etag })

With storage as the authority, bug 1 can't come back in its old form. If some future platform spells the header a fourth way, or drops it entirely, the upload still completes.

What I'd take from it

Header maps from native code are dictionaries, not HTTP. Anything that crosses a bridge as a plain object has lost the case-insensitivity the spec promised. Normalise the names once at the boundary, or scan case-insensitively. Never write headers.SomeName and trust it.

Never persist a URI you don't own. A picker's output is a loan. Move the file somewhere you control before its path goes anywhere durable, and still check it exists before you use it.

Test with the real shape of the data. A photo test doesn't exercise multipart. A handful of short clips doesn't create storage pressure. The first full-size, real-world run of a feature is part of building it, so watch it closely.

Let storage keep the receipts. The phone is the least reliable witness in the system: suspended, offline, killed. Ask R2 which parts it has, and ask it how big the object really is.

Two things are still on my list. The first is part-level resume: keep the upload ID, ask ListParts which parts already landed, and send only the rest, instead of restarting a large file from part one. The second is moving the batch loop into native code. Each transfer runs in a background session, but the loop that starts the next file is JavaScript, so when iOS suspends the app the batch can pause between files. Until then, a very large batch goes best with the app open and the phone plugged in.

I'd also look at backups. Documents is included in device backups by default, so a staging folder that briefly holds gigabytes of video should probably be excluded from them. None of these would have prevented the two bugs above. Those came from two assumptions I didn't know I was making, and a batch of 377 videos found both in a day.

Next post →One number every morning: the maths behind safe-to-spend