Uploading a large file can take several minutes, especially when users have a slow internet connection or are working from a mobile device. If the connection drops near the end of the upload, a traditional implementation may force the user to start again from the beginning.
This is frustrating for users and wasteful for applications. The server may have already received most of the file, but the client has no reliable way to continue from where the upload stopped.
Resumable file uploads solve this problem by dividing a file into smaller chunks and tracking how much data the server has safely received. When a connection fails, the client can reconnect and continue from the last confirmed position instead of retransmitting the entire file.
In this article, we will look at how resumable uploads work, how to implement the core workflow in ASP.NET Core, and what developers should consider before using the approach in production.
Why Traditional File Uploads Are Not Enough
A traditional upload endpoint often accepts a file through a multipart HTTP request. The server reads the request, validates the file, and saves it to storage.
For small files, this approach is usually sufficient. However, a single long-running request creates several problems for larger files:
A network interruption can prevent the request from completing.
A browser refresh or application restart may lose the upload's progress.
The server may need to process a large request again after a failure.
Retrying the entire upload increases bandwidth usage.
Long requests can consume server resources and make timeout handling more difficult.
Consider a user uploading a 500 MB video. If the connection fails after 450 MB, a traditional upload may require retransmitting all 500 MB. With a resumable upload, the client can continue from the last confirmed byte, assuming the server has safely stored the earlier data.
How Resumable File Uploads Work
A resumable upload typically has three stages: creating an upload session, sending chunks, and completing the upload.
1. Create an upload session
The server returns an upload ID and expected file metadata.
2. Upload chunks
Each chunk is stored and its confirmed position is recorded.
3. Recover after a failure
The client asks the server for the current offset and resumes there.
4. Complete and validate
The server verifies the complete file before making it available.
The upload ID identifies a particular upload session. The server also tracks the expected file size, the number of bytes received, the storage location, and the session's expiration time.
The most important rule is that the client should trust the server's confirmed offset, not simply assume that a chunk was saved because the request was sent.
For example, if the client sends bytes from position 0 to position 1,048,575, the server should report an offset of 1,048,576 only after those bytes have been safely persisted.
Design the Upload API
A simple resumable upload API can expose three endpoints:
Endpoint | Purpose |
|---|---|
| Creates a session and records file metadata |
| Appends a chunk at the expected byte offset |
| Returns the server's confirmed offset |
| Validates and finalizes the uploaded file |
For a production API, authenticate these requests and ensure that each upload session belongs to the requesting user or tenant.
The create endpoint should accept metadata such as the original filename, expected size, and optionally a whole-file checksum. The server should generate the upload ID rather than trusting a client-supplied storage path.
Track Upload Sessions in ASP.NET Core
Start with a model that represents the state of an upload:
public sealed class UploadSession
{
public required string Id { get; init; }
public required string OwnerId { get; init; }
public required string OriginalFileName { get; init; }
public required string TemporaryPath { get; init; }
public required long TotalBytes { get; init; }
public long Offset { get; set; }
public bool IsCompleted { get; set; }
public DateTimeOffset ExpiresAt { get; init; }
}The Offset property represents the number of bytes confirmed by the server. If the expected file size is 20 MB and the offset is 8 MB, the next chunk must begin at byte 8 MB.
The session data should be stored durably in a database or another reliable metadata store. An in-memory dictionary may be useful for a small demonstration, but it is not suitable when application instances can restart or requests can reach different servers.
Also, the temporary file and the session metadata must remain consistent. If the server writes bytes but fails before updating the offset, it needs a recovery strategy rather than blindly appending more data.
Upload a Chunk at a Specific Offset
The client should send the starting offset with each chunk. The server verifies that the supplied offset matches its current state before writing anything.
The following example demonstrates the core validation and append operation. It assumes that IUploadSessionStore provides durable session storage and a per-session exclusive operation that prevents concurrent writes.
public interface IUploadSessionStore
{
Task<UploadSession?> FindAsync(
string id,
CancellationToken cancellationToken);
Task SaveAsync(
UploadSession session,
CancellationToken cancellationToken);
// The implementation must serialize changes to this session
// across all application instances.
Task<T> WithExclusiveLockAsync<T>(
string id,
Func<CancellationToken, Task<T>> operation,
CancellationToken cancellationToken);
}The endpoint can then use that store:
app.MapPut("/api/uploads/{id}", async (
string id,
HttpRequest request,
IUploadSessionStore sessions,
CancellationToken cancellationToken) =>
{
if (!long.TryParse(
request.Headers["Upload-Offset"],
out long requestedOffset) ||
requestedOffset < 0)
{
return Results.BadRequest("Invalid upload offset.");
}
return await sessions.WithExclusiveLockAsync(
id,
async token =>
{
var session = await sessions.FindAsync(id, token);
if (session is null ||
session.IsCompleted ||
session.ExpiresAt <= DateTimeOffset.UtcNow)
{
return Results.NotFound();
}
// In a real application, verify that the authenticated
// user owns this session before permitting the write.
if (requestedOffset != session.Offset)
{
return Results.Conflict(new
{
Message = "Upload offset does not match.",
ExpectedOffset = session.Offset
});
}
long remaining = session.TotalBytes - session.Offset;
if (remaining <= 0)
{
return Results.Conflict(
"The expected file size has already been reached.");
}
string temporaryPath = session.TemporaryPath;
await using var output = new FileStream(
temporaryPath,
FileMode.OpenOrCreate,
FileAccess.Write,
FileShare.None,
bufferSize: 81920,
options: FileOptions.Asynchronous);
if (output.Length != session.Offset)
{
return Results.Conflict(
"Stored file size does not match the session offset.");
}
output.Position = session.Offset;
byte[] buffer = new byte[81920];
long received = 0;
while (true)
{
int read = await request.Body.ReadAsync(
buffer.AsMemory(), token);
if (read == 0)
{
break;
}
if (received + read > remaining)
{
return Results.BadRequest(
"Chunk exceeds the remaining file size.");
}
await output.WriteAsync(
buffer.AsMemory(0, read), token);
received += read;
}
if (received == 0)
{
return Results.BadRequest("The chunk is empty.");
}
await output.FlushAsync(token);
session.Offset += received;
await sessions.SaveAsync(session, token);
return Results.Ok(new
{
session.Id,
session.Offset,
session.TotalBytes
});
},
cancellationToken);
});This is a core workflow example, not a complete production-ready upload service. The session-store interface is application-defined, and the exclusive operation must coordinate requests across all instances, not just within one process.
The example also highlights a failure that needs explicit handling: if bytes are written successfully but updating the session metadata fails, the physical file length may be ahead of the recorded offset. Production implementations should reconcile this state, typically by truncating unconfirmed bytes back to the durable offset or by using a storage protocol with atomic chunk commits.
For stronger durability, use a storage abstraction that explicitly defines when a chunk is committed. A successful response should mean that the chunk and its confirmed offset can survive the failures your application promises to tolerate.
Resume After a Network Failure
The client should query the current upload offset after a timeout, connection loss, or ambiguous response. It can then seek to that position in the original file and continue uploading.
For example, if the file is 50 MB and the server reports an offset of 12 MB, the next request should start at byte 12 MB rather than resending the first 12 MB.
A browser client can implement the basic idea with the File API:
async function uploadChunk(uploadId, file, offset, chunkSize) {
const end = Math.min(offset + chunkSize, file.size);
const chunk = file.slice(offset, end);
const response = await fetch(
`/api/uploads/${uploadId}`,
{
method: "PUT",
headers: {
"Upload-Offset": String(offset)
},
body: chunk
}
);
if (response.status === 409) {
// Query the server for its confirmed offset before retrying.
throw new Error("Upload offset conflict");
}
if (!response.ok) {
throw new Error(`Chunk upload failed: ${response.status}`);
}
return await response.json();
}The caller should update its local offset only from the server's successful response. After a failure, it should query the session status endpoint and resume from the offset returned by the server.
For browser refreshes or application restarts, the client must also retain the upload ID and enough information to identify the original file. Browser storage can preserve the upload ID, but the server must still verify ownership and session validity. A browser cannot always regain access to the same local file automatically after a restart, so the user may need to select it again.
Complete and Validate the File
Receiving the expected number of bytes is necessary, but it is not enough to establish that the uploaded file is valid.
Before publishing the file, the server should verify that the recorded offset equals the expected size and that the physical file length matches the session metadata. If a checksum was supplied, it should verify the checksum against the completed file.
The finalization process should also:
Validate the file type using its content, not only its extension.
Scan uploaded content for malware when appropriate.
Move the completed file into its final storage location.
Mark the session as completed so additional chunks are rejected.
Ensure that retries of the completion request do not publish duplicate files.
For local disk storage, the final move should be performed within the same filesystem when atomic rename behavior is required. For object storage, use the provider's multipart-upload completion mechanism where possible.
Common Mistakes to Avoid
Mistake | Why it causes problems | Better approach |
|---|---|---|
Trusting the client's offset | A client may skip or overwrite bytes | Compare it with the server's recorded offset |
Updating progress before data is durable | A crash can leave the session ahead of the file | Commit bytes before advancing the confirmed offset |
Allowing concurrent chunk writes | Chunks can overwrite each other or corrupt the file | Serialize writes or use a concurrency-safe storage protocol |
Storing sessions only in memory | Restarting the server loses progress | Persist session metadata |
Accepting unlimited chunk sizes | Large requests can exhaust memory or disk | Apply chunk, request, and total-file size limits |
Keeping incomplete uploads forever | Temporary storage can fill up | Expire abandoned sessions and clean up their files |
Trusting filenames from the client | Paths and names can be malicious | Generate server-side storage names and validate metadata |
Treating a timeout as proof of failure | The server may have committed the chunk before the response was lost | Query the server's current offset before retrying |
Best Practices for Production Applications
Choose a chunk size that balances network overhead and recovery cost. Small chunks are easier to retry but increase the number of requests. Larger chunks reduce request overhead but may require retransmitting more data after a failure.
Enforce authentication, ownership checks, upload expiration, file-size limits, and storage quotas. Generate unpredictable upload IDs, but do not treat an upload ID as an authentication credential.
Use durable session metadata and define how the application recovers when file writes succeed but metadata updates fail. Monitor abandoned sessions, failed chunk requests, storage usage, and upload completion rates.
Finally, test interruptions at different stages: during a chunk write, after the chunk is committed but before the response reaches the client, during finalization, and while two requests attempt to write to the same session.
Advantages and Limitations
Advantages
Users can continue uploads after temporary network failures.
Only missing data needs to be retransmitted.
Large files are easier to manage with bounded request sizes.
Upload progress can be tracked and displayed.
Interrupted uploads can survive application restarts when state is stored durably.
Limitations
Session tracking and cleanup add implementation complexity.
Storage consistency must be managed carefully.
Upload IDs and metadata need authorization and expiration policies.
Chunked uploads require more HTTP requests than a single upload.
Final validation, file scanning, and publication still need to be implemented.
Summary
Resumable uploads improve reliability by separating a large file into smaller chunks and tracking the server's confirmed byte offset. When a network failure occurs, the client can query that offset and continue without starting from the beginning.
A reliable implementation needs more than chunking. It must handle concurrent requests, persist upload state, reconcile partial writes, validate the completed file, and remove abandoned sessions.
By treating each upload as a durable session with clear commit and recovery rules, ASP.NET Core applications can support large files without forcing users to repeat work after every interruption.

Join the conversation! Your thoughts help the community grow.