File uploads are a common feature in modern web applications. Users upload documents, images, videos, reports, backups, and other files through web interfaces and mobile applications. While uploading a small profile picture is usually straightforward, handling files that are hundreds of megabytes or several gigabytes requires a different approach.
One common mistake is reading the entire uploaded file into application memory before saving it. This may work during development with small files, but under production traffic, it can create serious memory pressure. If several users upload large files simultaneously, the application can consume excessive memory, trigger garbage collection frequently, and eventually terminate because of an out-of-memory condition.
ASP.NET Core provides streaming capabilities that allow applications to process uploaded files incrementally instead of loading the complete content into memory. However, choosing the right upload approach also involves understanding multipart form processing, request size limits, buffering, cancellation, validation, temporary storage, and concurrent uploads.
This article explains how to build a reliable large-file upload workflow in ASP.NET Core, with practical examples and production considerations.
Why Large File Uploads Consume Too Much Memory
Consider an application that allows users to upload reports or documents.
A straightforward implementation might look like this:
[HttpPost("upload")]
public async Task<IActionResult> Upload(IFormFile file)
{
using var memoryStream = new MemoryStream();
await file.CopyToAsync(memoryStream);
byte[] fileContent = memoryStream.ToArray();
// Process or save the file
return Ok("File uploaded successfully");
}This implementation has a potential memory problem.
The application copies the uploaded file into a MemoryStream and then calls ToArray to create another byte array containing the file data.
For a large file, the application may temporarily hold multiple copies of the same content.
For example, consider an upload of a 500 MB file. The MemoryStream can consume approximately 500 MB, and ToArray can allocate another large array. Additional memory is also required for the application, request processing, and other operations.
Now imagine that five users upload files of the same size simultaneously.
The application could require several gigabytes of memory just for these file copies.
The problem becomes worse when the application also performs operations such as:
Calculating file hashes.
Extracting ZIP archives.
Processing PDF documents.
Generating image thumbnails.
Scanning uploaded files.
Storing file content in database entities.
The solution is not simply to increase the application's memory allocation. The better approach is to avoid loading the entire file into memory in the first place.
Understand Buffering and Streaming in ASP.NET Core
ASP.NET Core supports two common approaches for receiving uploaded files: buffering and streaming.
Understanding their differences helps you choose the right implementation.
Buffered file uploads
Buffered uploads commonly use IFormFile.
For example:
[HttpPost("upload")]
public async Task<IActionResult> Upload(IFormFile file)
{
if (file == null || file.Length == 0)
{
return BadRequest("No file was uploaded.");
}
var filePath = Path.Combine(
"uploads",
Path.GetRandomFileName());
await using var stream = System.IO.File.Create(filePath);
await file.CopyToAsync(stream);
return Ok("Upload completed.");
}This approach is convenient because ASP.NET Core handles multipart form parsing and makes the uploaded file available through IFormFile.
However, IFormFile uploads are buffered before the action processes them. ASP.NET Core uses memory for smaller buffered content and temporary disk storage for larger content.
The default memory buffer threshold is 64 KB, and the default multipart body length limit for buffered form files is 128 MB. These are framework defaults, not recommended application-wide limits for every deployment.
Buffering is useful for small and moderately sized uploads when the application can handle the expected number of concurrent requests.
Streaming file uploads
Streaming processes incoming request content incrementally.
Instead of buffering the entire file before processing it, the application reads a small portion of the request body, writes that portion to the destination, and repeats the operation until the upload finishes.
The basic flow looks like this:
Client
|
| Upload file
v
ASP.NET Core API
|
| Read a small buffer
v
Destination Stream
|
| Write the buffer
v
File StorageThe application does not need to keep the complete file in memory.
Streaming is particularly useful when:
Users upload large documents or videos.
Multiple users upload files concurrently.
The application has limited memory resources.
Files need to be transferred directly to storage.
The application needs to calculate checksums while receiving data.
Streaming does not automatically make the network transfer faster. Its primary advantage is reducing memory and temporary buffering requirements.
Avoid MemoryStream for Large File Uploads
One of the most important improvements is to avoid copying large files into MemoryStream.
Instead of this:
using var memoryStream = new MemoryStream();
await file.CopyToAsync(memoryStream);
var content = memoryStream.ToArray();Copy the content directly into a destination stream:
await using var destination = new FileStream(
filePath,
FileMode.CreateNew,
FileAccess.Write,
FileShare.None,
bufferSize: 81920,
useAsync: true);
await file.CopyToAsync(destination);This works with IFormFile, but remember that the incoming file has already passed through ASP.NET Core's buffering process.
If your application needs true streaming from the incoming HTTP request, avoid form model binding for the upload and read the request body directly.
Implement Direct Streaming in an ASP.NET Core API
For applications that receive large files from mobile clients, desktop applications, or other services, a raw request-body upload endpoint can be a straightforward solution.
The client sends the file content as the request body instead of wrapping it in multipart form data.
The following implementation streams the request body directly into a temporary file.
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/files")]
public class FileUploadController : ControllerBase
{
private readonly string _uploadDirectory;
public FileUploadController(IConfiguration configuration)
{
_uploadDirectory =
configuration["FileStorage:UploadDirectory"]
?? throw new InvalidOperationException(
"Upload directory is not configured.");
}
[HttpPost("upload")]
[Consumes("application/octet-stream")]
[RequestSizeLimit(524288000)]
public async Task<IActionResult> Upload(
CancellationToken cancellationToken)
{
const long maxFileSize = 500L * 1024 * 1024;
if (Request.ContentLength is > maxFileSize)
{
return StatusCode(
StatusCodes.Status413PayloadTooLarge);
}
Directory.CreateDirectory(_uploadDirectory);
var fileName = Path.GetRandomFileName();
var temporaryPath = Path.Combine(
_uploadDirectory,
fileName + ".uploading");
var finalPath = Path.Combine(
_uploadDirectory,
fileName);
try
{
long totalBytes = 0;
await using (var destination = new FileStream(
temporaryPath,
FileMode.CreateNew,
FileAccess.Write,
FileShare.None,
bufferSize: 81920,
options: FileOptions.Asynchronous |
FileOptions.SequentialScan))
{
var buffer = new byte[81920];
while (true)
{
var bytesRead = await Request.Body.ReadAsync(
buffer.AsMemory(),
cancellationToken);
if (bytesRead == 0)
{
break;
}
totalBytes += bytesRead;
if (totalBytes > maxFileSize)
{
return StatusCode(
StatusCodes.Status413PayloadTooLarge);
}
await destination.WriteAsync(
buffer.AsMemory(0, bytesRead),
cancellationToken);
}
await destination.FlushAsync(cancellationToken);
}
System.IO.File.Move(temporaryPath, finalPath);
return Ok(new
{
FileName = fileName,
Size = totalBytes,
Message = "File uploaded successfully."
});
}
catch (OperationCanceledException)
when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception)
{
if (System.IO.File.Exists(temporaryPath))
{
System.IO.File.Delete(temporaryPath);
}
throw;
}
}
}This example demonstrates several important techniques.
Reading the request body incrementally
The application reads data into a buffer of 81,920 bytes.
var buffer = new byte[81920];It then reads the incoming request body:
var bytesRead = await Request.Body.ReadAsync(
buffer.AsMemory(),
cancellationToken);The ReadAsync method returns the number of bytes actually read. The application writes only those bytes to the destination.
await destination.WriteAsync(
buffer.AsMemory(0, bytesRead),
cancellationToken);This process continues until the request body reaches its end.
The buffer size is an example, not a universal performance setting. You can benchmark different sizes based on your storage system and workload.
Enforcing a maximum file size
The example checks Request.ContentLength before reading the request body.
However, Content-Length is not always available. Some requests can use transfer encoding without a declared content length.
That is why the application also maintains a running byte count:
totalBytes += bytesRead;
if (totalBytes > maxFileSize)
{
return StatusCode(
StatusCodes.Status413PayloadTooLarge);
}The running check prevents an upload from exceeding the application's configured limit even when the request does not provide Content-Length.
Using a temporary file
The upload is initially written to a temporary filename ending in .uploading.
After the stream is completely written and closed, the file is moved to its final name.
This prevents incomplete uploads from appearing as completed files.
For a production implementation, temporary files should also have a cleanup policy so abandoned uploads do not consume storage indefinitely.
The example uses a local filesystem. If your application runs on multiple servers or containers, use shared durable storage or object storage rather than relying on a local directory.
Upload Large Files Using MultipartReader
Many web applications use multipart/form-data because it allows a client to send files together with additional form fields.
For example, a document upload might contain:
The file itself.
A document category.
A description.
A project identifier.
The standard IFormFile model-binding approach buffers uploaded files before the controller processes them.
For true multipart streaming, use MultipartReader.
MultipartReader allows the application to read individual sections from the incoming request without first buffering the complete file through form model binding.
A simplified implementation looks like this:
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Net.Http.Headers;
[HttpPost("upload-multipart")]
[DisableFormValueModelBinding]
[RequestSizeLimit(524288000)]
public async Task<IActionResult> UploadMultipart(
CancellationToken cancellationToken)
{
if (!MediaTypeHeaderValue.TryParse(
Request.ContentType,
out var mediaType))
{
return BadRequest("Invalid content type.");
}
if (!string.Equals(
mediaType.MediaType.Value,
"multipart/form-data",
StringComparison.OrdinalIgnoreCase))
{
return BadRequest("Expected multipart form data.");
}
var boundary = HeaderUtilities.RemoveQuotes(
mediaType.Boundary).Value;
if (string.IsNullOrWhiteSpace(boundary))
{
return BadRequest("Multipart boundary is missing.");
}
var reader = new MultipartReader(
boundary,
Request.Body);
MultipartSection? section;
while ((section = await reader.ReadNextSectionAsync(
cancellationToken)) != null)
{
if (!ContentDispositionHeaderValue.TryParse(
section.ContentDisposition,
out var contentDisposition))
{
continue;
}
if (!contentDisposition.DispositionType.Equals(
"form-data",
StringComparison.OrdinalIgnoreCase))
{
continue;
}
var fileName = contentDisposition.FileNameStar.HasValue
? contentDisposition.FileNameStar.Value
: contentDisposition.FileName.Value;
if (string.IsNullOrWhiteSpace(fileName))
{
continue;
}
var safeName = Path.GetRandomFileName();
var filePath = Path.Combine(
_uploadDirectory,
safeName);
await using var destination = new FileStream(
filePath,
FileMode.CreateNew,
FileAccess.Write,
FileShare.None,
bufferSize: 81920,
useAsync: true);
await section.Body.CopyToAsync(
destination,
cancellationToken);
}
return Ok("Multipart upload completed.");
}The example focuses on the streaming mechanism. A production implementation must also validate the number of files, individual file sizes, allowed file types, form field sizes, and multipart section count. It should also remove incomplete files if a later section fails.
Why disable form model binding?
When an MVC action uses normal form model binding, ASP.NET Core can read and buffer the multipart request before the action executes.
If the action intends to read Request.Body directly through MultipartReader, that automatic form processing must be disabled.
The DisableFormValueModelBinding attribute is a custom resource filter that removes form value and file value provider factories.
For example:
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.ModelBinding;
[AttributeUsage(
AttributeTargets.Class | AttributeTargets.Method)]
public sealed class DisableFormValueModelBindingAttribute
: Attribute, IResourceFilter
{
public void OnResourceExecuting(
ResourceExecutingContext context)
{
var factories = context.ValueProviderFactories;
factories.RemoveType<FormValueProviderFactory>();
factories.RemoveType<FormFileValueProviderFactory>();
factories.RemoveType<JQueryFormValueProviderFactory>();
}
public void OnResourceExecuted(
ResourceExecutedContext context)
{
}
}
Join the conversation! Your thoughts help the community grow.