Commercial risk
A single 500 MB upload on a metered mobile connection is real money out of the user's pocket — an out-of-bundle charge they never approved, on a connection your app chose for them. In markets where most users are on prepaid data, that one upload can cost more than a month of your subscription.
It is also a crash and a churn event. Loading a large file into memory triggers an out-of-memory kill that reads to the user as “the upload failed for no reason,” so they retry — re-reading and re-sending the whole file, multiplying both the crash risk and the data charge each time.
This is the mobile failure mode the Engineering Authority Playbook calls the reverse-billing out-of-bundle trap, and it hides inside the most innocuous-looking line in the codebase: `let data = try Data(contentsOf: url)`. That single call reads an entire file into RAM, and everything downstream inherits its two liabilities — unbounded memory and unbounded, unconsented data usage.
The reason it survives review is that it works flawlessly for the files developers test with: a few megabytes over office Wi-Fi. The failure only appears with a real user's 2 GB video on a prepaid cellular connection — which is to say, in exactly the conditions you cannot see from your desk.
1. The whole-file-into-memory read
Reading a file into a single in-memory buffer allocates as many bytes as the file is large. A 2 GB video allocates 2 GB of RAM on a device that may have far less available to a single app. The OS fires a memory warning, evicts what caches it can, and when that isn't enough it kills the app outright. From the user's side there is no error dialog and no explanation — the app simply vanishes mid-upload, and the content they thought they were posting never arrives.
The fix is to never hold the whole file. Stream it: hand the upload API a file URL or an input stream and let the networking layer read and transmit in bounded chunks. `URLSession`'s `uploadTask(with:fromFile:)` does exactly this, with a flat memory profile regardless of file size, and it is barely more code than the version that crashes.
// Wrong: entire file resident in memory before a single byte is sent.
let data = try Data(contentsOf: fileURL) // 2 GB allocation
var request = URLRequest(url: uploadURL)
request.httpBody = data
URLSession.shared.dataTask(with: request).resume()
// Right: stream from disk — flat memory, any file size.
let task = URLSession.shared.uploadTask(with: request, fromFile: fileURL)
task.resume()2. No network-type check: spending the user's bundle without asking
The second liability is that the upload uses whatever network is active, with no check and no consent. If the user is on cellular, a large upload is charged against their data bundle — and if the bundle is exhausted, against out-of-bundle rates, which are typically punitive. The app made an expensive financial decision on the user's behalf and never told them.
The platform gives you the controls to prevent this; the failure is not using them. `URLSessionConfiguration.allowsCellularAccess = false` for large transfers forces Wi-Fi. `isDiscretionary` with a background session lets the OS wait for favourable conditions. At minimum, check the interface type and prompt before spending a user's data on a large upload. Consent is a one-line gate that turns a silent charge into a user's informed choice.
let config = URLSessionConfiguration.background(withIdentifier: "uploads")
config.allowsCellularAccess = false // large media: Wi-Fi only by default
config.isDiscretionary = true // let the OS pick a good moment
config.sessionSendsLaunchEvents = true
// Or, if cellular is genuinely needed, make it the user's decision:
if path.usesInterfaceType(.cellular), fileSize > largeUploadThreshold {
// Prompt: "This is a 480 MB upload on mobile data. Continue?"
}3. No resumability: every failure re-bills the whole file
The third liability compounds the first two. Without resumable uploads, a transfer that fails at 99% must start over from zero — re-reading the file into memory (crash risk again) and re-sending every byte (data charge again). A user on a flaky connection can pay for the same 500 MB three or four times before the upload completes or they give up.
Resumable, chunked upload protocols — tus, S3 multipart, or a simple ranged-append endpoint of your own — mean a failure costs only the unsent remainder. Combined with a background session that survives the app being suspended, the upload becomes something the user can start and forget, instead of something they must babysit and pay for repeatedly.
One line — read the whole file into memory and send it over the active network — carries three liabilities: an out-of-memory crash, an unconsented data charge, and a re-billed retry loop. None of them appear in a Wi-Fi demo with a small file, and all of them appear for a real user with a real video on a real prepaid connection.
Stream from disk instead of buffering, default large transfers to Wi-Fi and make cellular the user's explicit choice, and use resumable chunks so a failure costs the remainder and not the whole. The user's data bundle is their money; an upload that spends it silently is a defect, not a feature.