When the request body is a stream, not a blob
You build a form. A user drags a 50MB video file onto the upload button and clicks submit. The request hits your server. The body isn't a simple JSON string you can deserialize with serde. It's a stream of binary chunks wrapped in boundaries, headers, and metadata. If you try to read it like text, you get garbage. If you try to load the whole thing into memory at once, your server crashes under the weight of concurrent uploads. File uploads in Rust require handling streams, parsing multipart boundaries, and writing to disk efficiently.
The multipart tape recorder
HTTP file uploads usually use multipart/form-data. Think of it like a tape recorder with a special marker sound between songs. The browser records the filename, the content type, and the binary data, then plays a marker sound, then records the next field. The server has to listen to the stream, detect the markers, extract the metadata, and save the binary payload.
The Content-Type header tells the parser where the markers are. It looks like this:
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
The boundary is a random string. The parser scans the stream for ------WebKitFormBoundary... to find the start of each field. Each field begins with headers, followed by a blank line, followed by the data. The Content-Disposition header inside the field carries the filename:
Content-Disposition: form-data; name="file"; filename="photo.jpg"
Rust doesn't buffer the whole request by default. You get a stream. You process chunk by chunk. This keeps memory usage flat even for gigabyte files. The framework gives you a Stream of fields, and each field is a Stream of byte chunks. You drive the streams with async loops.
Minimal upload handler
The actix-multipart crate integrates with Actix Web to parse these streams. Add actix-web and actix-multipart to your Cargo.toml. The futures-util crate provides the StreamExt trait, which gives you the next() method on streams.
use actix_web::{web, App, HttpServer, HttpResponse, Responder};
use actix_multipart::Multipart;
use futures_util::StreamExt;
use std::io::Write;
/// Handles incoming multipart file uploads and saves them to disk.
async fn upload_handler(mut payload: Multipart) -> impl Responder {
// Iterate over each field in the multipart request.
// The payload stream yields Field items one by one.
while let Some(item) = payload.next().await {
let mut field = item.unwrap(); // Unwrap for brevity; production code should handle errors.
// Extract the filename from the Content-Disposition header.
// content_disposition() returns a ContentDisposition struct.
let filename = field.content_disposition().unwrap().get_filename().unwrap();
// Create a file on disk. Using unwrap here assumes the path is writable.
let mut file = std::fs::File::create(filename).unwrap();
// Stream the chunks from the request body to the file.
// field.next() yields Bytes objects, not Strings.
while let Some(chunk) = field.next().await {
let data = chunk.unwrap();
file.write_all(&data).unwrap();
}
}
HttpResponse::Ok().body("Upload complete")
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| {
App::new().route("/upload", web::post().to(upload_handler))
})
.bind("127.0.0.1:8080")?
.run()
.await
}
Stream the chunks. Never buffer the whole file in RAM.
What happens under the hood
The Multipart extractor parses the Content-Type header to find the boundary string. It yields a stream of Field items. Each Field has headers and a data stream. You call next() on the payload to get a field. You call next() on the field to get chunks of bytes. The while let Some loop drives the async stream. When next() returns None, the stream is exhausted. The file is written incrementally. The server memory footprint stays constant regardless of file size.
The chunks are Bytes objects, not Vec<u8>. Bytes is a reference-counted buffer. It supports zero-copy slicing. When you write a chunk to the file, you aren't copying the data. You're handing the file descriptor a pointer to the buffer. This avoids allocation overhead. If you try to convert a chunk to a String using String::from_utf8, the code panics on binary data. Stick to Bytes or Vec<u8> for the payload.
Convention aside: the community expects Bytes in streaming contexts. If you see Vec<u8> in an upload handler, someone is allocating unnecessarily. Use Bytes when you can. It's the standard for network buffers in the Tokio ecosystem.
Realistic handler with validation
Production code needs error handling, path safety, and size limits. The filename comes from the client. You can't trust it. A malicious user can send filename="../../../etc/passwd" to overwrite system files. You must sanitize the path. You also need to limit the upload size to prevent disk exhaustion.
use actix_web::{web, App, HttpServer, HttpResponse, Responder};
use actix_multipart::Multipart;
use futures_util::StreamExt;
use std::io::Write;
use std::path::Path;
/// Validates filename and streams upload to a safe directory.
async fn safe_upload(
mut payload: Multipart,
upload_dir: web::Data<std::path::PathBuf>,
) -> impl Responder {
while let Some(item) = payload.next().await {
// Handle parse errors from the multipart stream.
let mut field = match item {
Ok(f) => f,
Err(e) => return HttpResponse::BadRequest().body(format!("Parse error: {}", e)),
};
// Extract filename safely.
let filename = match field.content_disposition().and_then(|cd| cd.get_filename()) {
Some(name) => name,
None => return HttpResponse::BadRequest().body("Missing filename"),
};
// Sanitize filename to prevent directory traversal.
// file_name() strips the path, leaving only "photo.jpg".
let safe_name = Path::new(filename).file_name().unwrap_or_default().to_string_lossy();
let dest_path = upload_dir.join(safe_name);
// Create the file. Handle IO errors.
let mut file = match std::fs::File::create(&dest_path) {
Ok(f) => f,
Err(e) => return HttpResponse::InternalServerError().body(format!("Disk error: {}", e)),
};
let mut size = 0u64;
let max_size = 100 * 1024 * 1024; // 100MB limit
while let Some(chunk) = field.next().await {
let data = match chunk {
Ok(d) => d,
Err(e) => return HttpResponse::InternalServerError().body(format!("Stream error: {}", e)),
};
size += data.len() as u64;
if size > max_size {
return HttpResponse::PayloadTooLarge().body("File too large");
}
if let Err(e) = file.write_all(&data) {
return HttpResponse::InternalServerError().body(format!("Write error: {}", e));
}
}
}
HttpResponse::Ok().body("Saved")
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let upload_dir = std::path::PathBuf::from("./uploads");
std::fs::create_dir_all(&upload_dir)?;
HttpServer::new(move || {
App::new()
.app_data(web::Data::new(upload_dir.clone()))
.route("/upload", web::post().to(safe_upload))
})
.bind("127.0.0.1:8080")?
.run()
.await
}
Sanitize the filename. Trust nothing from the client.
Pitfalls and compiler errors
File upload code trips on a few common edges.
Path traversal is the big one. If you use the raw filename to construct the path, a request with filename="../../../../tmp/exploit" writes outside your upload directory. Always call file_name() on the path to strip directory components. If the filename is empty or invalid, file_name() returns None. Handle that case.
Memory leaks happen when you collect chunks into a Vec instead of streaming them to disk. If you write let mut buffer = Vec::new(); buffer.extend(chunk);, you are buffering the entire file. The stream pattern exists to avoid this. Write each chunk immediately.
Blocking I/O can stall your server. std::fs::File::create and write_all are blocking calls. Actix Web runs on a thread pool, so blocking calls don't freeze the event loop. They just occupy a worker thread. For small files, this is fine. For heavy disk I/O with many concurrent uploads, consider tokio::fs or offloading to a blocking thread pool. If you block the executor, latency spikes.
The compiler catches type mismatches. If you forget to import StreamExt, you get E0599 (no method named next found). The error points to the stream type and suggests importing the trait. If you try to return a String from a handler that expects impl Responder without wrapping it, you get E0308 (mismatched types). Wrap the string in HttpResponse::Ok().body(...).
Convention aside: cargo fmt formats every file the same way. Don't argue style; argue logic. If your upload handler has inconsistent indentation, run the formatter. It keeps the codebase readable for everyone.
Treat the SAFETY comment as a proof. If you can't write it, you don't have one. This applies to any unsafe blocks you might add later for performance. Most upload code stays safe.
When to use which tool
Use actix-multipart when you are building with Actix Web and need a mature, battle-tested multipart parser that integrates directly with the framework's extractors. Use multer when you are using Axum and want an API similar to the Node.js multer library, with middleware-style configuration. Use hyper with the multipart crate when you need low-level control over the HTTP stack and don't want framework abstractions, such as when building a custom server or a library. Use direct Bytes streaming when the client sends raw binary without multipart boundaries, such as in a custom API protocol where the body is just the file data. Reach for tokio::fs when your upload handler performs heavy disk I/O and you want to avoid blocking the executor's worker threads, especially in high-concurrency scenarios.
Pick the crate that matches your framework. Don't fight the ecosystem.