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:

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

POST /api/uploads

Creates a session and records file metadata

PUT /api/uploads/{id}

Appends a chunk at the expected byte offset

GET /api/uploads/{id}

Returns the server's confirmed offset

POST /api/uploads/{id}/complete

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:

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

Limitations

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.