OkHttp moves files in both directions with the same request and response machinery you use for small calls, but with one discipline that matters more than everything else combined: stream the data. Uploads are built as multipart bodies that read from the file as they send, and downloads arrive as a byte stream you copy to disk a chunk at a time. Follow that pattern and a five-megabyte photo and a five-hundred-megabyte archive are handled by the same calm code.
The problems start when the streaming discipline breaks. The moment a whole file is loaded into memory as a string or a byte array, the size of the file becomes the size of your risk. With OkHttp, none of that is necessary, because both directions were designed for data that never fully sits in memory. This article covers the upload shape, the download shape, progress reporting, timeouts, and the failure handling that large transfers demand.
Why Streaming Matters More Than Anything Else
Memory is the hard limit in file transfer. An app that reads an entire download into a single string needs at least that much free heap, usually more once decoding and copying are counted, and on a phone that budget is shared with everything else the app is doing. Streaming replaces that single huge allocation with a small fixed buffer reused thousands of times, so the memory cost of a transfer stays flat no matter how large the file grows.
Streaming also changes how failures feel. A transfer that processes data as it flows can report steady progress, write partial results to disk, and fail without having consumed the device. The code is only slightly longer than the naive version, and it behaves correctly at every size, which is why experienced developers write the streaming version even for files they expect to stay small.
Uploads Are Built as Multipart Bodies
File uploads use the multipart form format, the same one web forms use when a page accepts an attachment. A multipart body is a sequence of parts separated by boundary markers. Each part has its own small set of headers and its own content. One part typically carries the file itself, complete with the filename the server should see, while other parts carry ordinary form fields such as a title, an identifier, or a category.
In OkHttp you assemble this with a multipart body builder: set the form type, add each text field as a form data part, and add the file as a part whose body is created from the file on disk. That file-backed body reports its length from the file size and writes its bytes by reading the file when the request is actually sent. The file never becomes a string in your code, and the request can be built long before the transfer begins.
Each Part Declares Its Own Content Type
Every part in a multipart body can state what it contains, and the file part should. A photo part declares an image media type, a document part declares its document type, and generic data falls back to a binary type. Servers lean on these declarations to route the upload: storing images differently from documents, scanning them, or rejecting types they never accept.
Getting the type wrong rarely fails loudly. More often the upload succeeds and the confusion surfaces later, when a file cannot be previewed or is treated as an anonymous download. Match the declared type to the actual bytes, and give the part the real filename too, since many servers use its extension as a second signal. Both details are cheap to set at build time and expensive to debug after the fact.
Downloads Arrive as a Byte Stream
A download is a response whose body is read as a stream rather than consumed as text. From the response body you obtain an input stream or a buffered source, and you read from it until it reports the end. The client handles the connection, any decompression, and the network buffering underneath, while your loop sees a steady flow of bytes in whatever size chunks you ask for.
The simplest correct shape is a copy loop:
response.body.byteStream().use { input ->
FileOutputStream(destination).use { output ->
val buffer = ByteArray(8192)
var read: Int
while (input.read(buffer).also { read = it } != -1) {
output.write(buffer, 0, read)
}
}
}
Everything important is visible in those few lines. A modest buffer does the carrying, the stream’s use block guarantees closing, and the output file is complete only when the loop ends normally. If the connection drops halfway, the exception escapes the loop and you know the partial file is incomplete, rather than mistaking it for a finished download.
Writing to Disk a Little at a Time
Downloads should land on disk incrementally, exactly as the loop above writes them. Each bufferful goes straight to the output file, so at any moment the disk holds everything received so far and memory holds almost nothing. When the transfer finishes, close the output before treating the file as usable, since the final bytes may still sit in a buffer until the stream is flushed and closed.
One practical refinement is to download into a temporary name and rename it to the final name only after the stream completes. Anything reading the destination path then sees either the old complete file or the new complete file, never a half-written one. Apps that skip this step eventually ship a bug where a preview or a player opens a partial file left behind by an interrupted transfer and fails in ways that look unrelated.
Reporting Progress to the User
Long transfers need a progress indicator, and progress is just arithmetic: bytes transferred so far divided by the total. For uploads, the total is the body length the client already knows. Wrapping the request body in a forwarding body that counts bytes as they are written gives a live numerator. For downloads, the total is the response body’s content length, and the copy loop can count each buffer it writes.
The counting wrapper for uploads can be hand-written once and reused, or observed from a network interceptor that watches the bytes pass. Either way, two cautions apply. Progress callbacks fire on a background thread, so hand the number to the interface deliberately. And servers occasionally omit a content length, so progress code must tolerate an unknown total by showing activity without a percentage.
Timeouts Tuned for Large Transfers
The timeout defaults that suit quick API calls are wrong for big files, because a large transfer is supposed to take a long time. What matters is not the total duration but the gaps. A read timeout that fires after a period without any incoming bytes is healthy even on a slow download, because a progressing download keeps resetting it with every chunk that arrives. The same logic applies to the write timeout during an upload.
The overall call timeout is the exception: it caps the entire exchange from start to finish, so for large transfers it must be raised generously or disabled, or it will cancel healthy transfers that simply had a lot of data to move. Tune the three as a set, a concept explored further in connection with small payloads like a POST request with JSON data, where the right values are far smaller. Large-file work simply plays by different numbers.
When a Transfer Fails Halfway
Networks drop, servers restart, and users walk out of coverage, so large transfers need a failure story. OkHttp itself does not resume an interrupted download automatically, and it cannot restart an upload’s file part from the middle. What it gives you is a clean exception at the exact point of failure, plus the partial file your loop already wrote. Deciding what to do with that partial file is your application’s policy.
For downloads, some servers support range requests, where a new request asks for bytes starting from an offset; when supported, you can append to the partial file instead of starting over. When it is not supported, the honest strategy is to discard the partial file and retry the whole transfer, ideally when conditions improve. Either way, never present a partial file as a completed one: track completion explicitly, not by the file’s mere existence.
Transfers Belong Off the Main Thread
Everything said about posting small requests applies with extra force to files: a transfer must never run on the interface thread. A synchronous download executed there freezes the app for the entire transfer, and on Android the platform forbids network activity on that thread outright. Run transfers through the asynchronous call API or on a background worker your app controls.
Background execution also raises the lifetime question. A user can rotate the device, leave the screen, or close the app mid-transfer, and the design should say in advance what happens then. Whether you cancel the call when its screen goes away or let a dedicated background component own the transfer to completion, decide it as a policy rather than discovering the behaviour by accident.
The Three Mistakes Behind Most File Bugs
The first mistake is reading a whole download as text. A body consumed with a single string call loads every byte into memory at once, which works in testing with small files and collapses in production with real ones. If a body might be a file, stream it, full stop. The second mistake is leaving streams unclosed. An unclosed response body holds its connection hostage, and repeated leaks starve the connection pool until unrelated requests start hanging.
The third mistake is placing the transfer on the main thread or in a component with the wrong lifetime, producing freezes, crashes, or transfers that die silently when the user navigates away. All three mistakes share a trait: the code looks fine and passes casual testing. They are caught by reviewing transfer code against the streaming checklist, not by hoping small test files reveal them.
Frequently Asked Questions
Can OkHttp resume an interrupted download on its own?
No, resuming is not built in. The client reports the failure and stops. Resuming is possible only as an application-level strategy, and only when the server supports range requests that return the remaining bytes from an offset. Without that support, the partial download must be discarded and fetched again in full.
Is there a maximum file size OkHttp can handle?
The library imposes no practical file-size limit, because both uploads and downloads stream. The real limits are the device’s storage, the server’s upload policy, and your timeout settings. A transfer that streams with sensible timeouts can move files far larger than the device’s memory without difficulty.
Why did my uploaded file arrive without its name or type?
Those details live on the file part inside the multipart body, not in the request as a whole. If the part was added without its filename, or with a generic binary content type, the server receives anonymous data and stores it that way. Set both the filename and the part’s media type when you add the file part.
How do I cancel a transfer that is taking too long?
Calls can be cancelled through the call object itself, and cancelling closes the underlying streams so a blocked read or write fails promptly instead of hanging. Keep a reference to any call you may need to cancel, such as when the user presses a cancel button, and treat the resulting failure as an expected outcome rather than an error.
Conclusion
File transfer with OkHttp is a study in one idea applied twice. Uploads stream out of a file through a multipart body, downloads stream into a file through a buffered copy loop, and neither direction asks memory to hold more than a small buffer. Progress reporting, generous stall-based timeouts, and an explicit completion policy complete the picture.
Review any transfer code against that shape and the common failures become predictable: whole-body reads, unclosed streams, wrong-thread execution. Avoid those three, and files of any size move with the same reliability as the smallest API call.