Skip to main content
SEMastery
ASP.NETintermediate

Large File Uploads and Downloads in Azure Blob Storage with .NET

Learn to upload and download large files to Azure Blob Storage in .NET using block blobs, streaming, chunked transfers, and SAS tokens — with simple diagrams and code.

12 min readUpdated December 4, 2025

Moving a sofa through a narrow door

Imagine you bought a big sofa, and you need to bring it into your flat. The main door is narrow. If you try to shove the whole sofa through in one go, it gets stuck — and you are stuck with it, sweating in the stairwell.

A smarter way is to take the sofa apart. You carry the cushions, then the legs, then the frame, one piece at a time. Inside the flat, you put all the pieces back together. Now even a huge sofa fits through a small door.

Uploading a large file to Azure Blob Storage works exactly like this. The "narrow door" is your network and your server's memory. If you try to push a 2 GB video through in one piece, you can run out of memory or time out. So you break the file into blocks, send the blocks one by one (or several at once), and then tell Azure to join them back into one file.

This article shows you how to do that in .NET — both directions, upload and download — with simple pictures and real code. We will use the Azure.Storage.Blobs SDK.

The two kinds of "big" problems

When we say "large file", we are really fighting two enemies:

  1. Memory — loading a whole 2 GB file into a byte[] will eat 2 GB of RAM. Do that for ten users at once and your server falls over.
  2. Time and failure — a long single upload can time out or break halfway. If it breaks, you have to start again from zero. Painful.

Block blobs solve both. You stream small pieces, so memory stays low. And if one block fails, you retry just that one block — not the whole file.

A whole file is split into blocks, each block is staged, then all blocks are committed into one blob.

How a block blob is built

A block blob is the most common blob type in Azure. The name gives it away: it is built from blocks. Each block has a small ID that you choose, and the upload happens in two clear phases.

  • Stage — you upload each block with its ID. Staged blocks sit in a temporary, uncommitted area. They are not part of the blob yet. Nobody can see them.
  • Commit — you send Azure an ordered list of block IDs. Azure stitches the blocks together in that order, and only now does the blob appear.

This two-step design is what lets you upload blocks out of order, in parallel, and retry just the broken ones.

Here are the limits worth remembering.

LimitValue
Max size of one block100 MiB (4 GiB in preview)
Max number of blocks per blob50,000
Max block blob sizeabout 4.75 TiB
Common block size for uploads4 MiB to 8 MiB

You do not always have to stage blocks by hand. For small files the SDK does it for you. You only reach for manual StageBlock when you want full control — like showing a progress bar or uploading from a stream you are reading piece by piece.

Step 1: Connect to Azure Blob Storage

First, install the SDK package.

dotnet add package Azure.Storage.Blobs
dotnet add package Azure.Identity

Then register a BlobServiceClient in your ASP.NET Core app. The cleanest way uses DefaultAzureCredential, which works with your developer login locally and with a managed identity in production — no secrets in code.

using Azure.Identity;
using Azure.Storage.Blobs;
 
var builder = WebApplication.CreateBuilder(args);
 
builder.Services.AddSingleton(_ =>
{
    var accountUrl = builder.Configuration["Storage:AccountUrl"]!;
    // e.g. https://myaccount.blob.core.windows.net
    return new BlobServiceClient(new Uri(accountUrl), new DefaultAzureCredential());
});
 
var app = builder.Build();

BlobServiceClient is thread-safe, so register it once as a singleton and reuse it everywhere. Creating a new client per request is wasteful.

Step 2: The easy upload (let the SDK do the work)

For most files, you do not need to think about blocks at all. Just hand the SDK a stream and it splits, stages, and commits behind the scenes.

public class BlobUploader(BlobServiceClient blobService)
{
    public async Task UploadAsync(
        string containerName, string blobName, Stream content, CancellationToken ct)
    {
        var container = blobService.GetBlobContainerClient(containerName);
        await container.CreateIfNotExistsAsync(cancellationToken: ct);
 
        var blob = container.GetBlobClient(blobName);
 
        var options = new BlobUploadOptions
        {
            TransferOptions = new Azure.Storage.StorageTransferOptions
            {
                // Size of the first piece sent in one request.
                InitialTransferSize = 8 * 1024 * 1024,   // 8 MiB
                // Size of each block when the SDK splits the file.
                MaximumTransferSize = 8 * 1024 * 1024,   // 8 MiB
                // How many blocks to upload at the same time.
                MaximumConcurrency = 4
            }
        };
 
        await blob.UploadAsync(content, options, ct);
    }
}

The magic is in StorageTransferOptions. If the stream is smaller than InitialTransferSize, the SDK sends it in a single request. If it is larger, the SDK switches to staging blocks of MaximumTransferSize and uploads up to MaximumConcurrency of them at once. You get parallel, low-memory uploads for free.

What UploadAsync decides

Read stream
Compare size
Single PUT
Stage + Commit

Steps

1

Read stream

Open the source

2

Compare size

vs InitialTransferSize

3

Single PUT

Small files

4

Stage + Commit

Large files, parallel

The SDK picks single-shot or chunked based on size.

Step 3: Manual block upload (full control)

Sometimes you want to drive the blocks yourself — for example to report progress, or because you are reading the data from somewhere in pieces. Here is the manual version using StageBlockAsync and CommitBlockListAsync.

public async Task UploadInBlocksAsync(
    string containerName, string blobName, Stream content, CancellationToken ct)
{
    var container = blobService.GetBlobContainerClient(containerName);
    await container.CreateIfNotExistsAsync(cancellationToken: ct);
    var blob = container.GetBlockBlobClient(blobName);
 
    const int blockSize = 4 * 1024 * 1024;      // 4 MiB
    var buffer = System.Buffers.ArrayPool<byte>.Shared.Rent(blockSize);
    var blockIds = new List<string>();
    var index = 0;
 
    try
    {
        int read;
        while ((read = await content.ReadAsync(buffer.AsMemory(0, blockSize), ct)) > 0)
        {
            // Block IDs must be the same length and base64 encoded.
            var blockId = Convert.ToBase64String(
                System.Text.Encoding.UTF8.GetBytes(index.ToString("D6")));
 
            using var blockStream = new MemoryStream(buffer, 0, read);
            await blob.StageBlockAsync(blockId, blockStream, cancellationToken: ct);
 
            blockIds.Add(blockId);
            index++;
        }
 
        // Join the staged blocks, in order, into the final blob.
        await blob.CommitBlockListAsync(blockIds, cancellationToken: ct);
    }
    finally
    {
        System.Buffers.ArrayPool<byte>.Shared.Return(buffer);
    }
}

Two important details:

  • Block IDs must all be the same length. That is why we use index.ToString("D6") — it pads the number to six digits so 1 becomes 000001. Mixing lengths makes Azure reject the commit.
  • We rent the buffer from ArrayPool<byte>.Shared and return it in a finally. This reuses memory instead of allocating a fresh 4 MiB array for every block.
Sequence of a manual block upload from your API to Azure.

Step 4: Streaming the upload in ASP.NET Core

There is a trap here. By default, ASP.NET Core buffers the whole request body before your action runs. For a big upload, that means the entire file lands in memory or a temp file first. We do not want that.

To stream straight through, disable the form value model binding and read the multipart request as it arrives. A simpler and very common pattern is to accept the raw body stream and pass it directly to the uploader.

app.MapPost("/files/{container}/{name}", async (
    string container, string name,
    HttpRequest request, BlobUploader uploader, CancellationToken ct) =>
{
    // request.Body is a stream. We never load the whole file at once.
    await uploader.UploadAsync(container, name, request.Body, ct);
    return Results.Ok(new { container, name });
})
.DisableAntiforgery();

Because we hand request.Body straight to the SDK, the bytes flow from the client, through your server in small buffers, and into Azure — without ever sitting whole in memory. That is the goal.

Streaming upload path

Client
API body stream
SDK blocks
Azure blob

Steps

1

Client

Sends file

2

API body stream

Read in chunks

3

SDK blocks

Stage in parallel

4

Azure blob

Commit

Bytes flow in small buffers, never all at once.

Step 5: Downloading large files

Downloads have the same memory trap in reverse. If you call DownloadContentAsync, the whole blob comes into a byte[]. Fine for a 50 KB image, terrible for a 2 GB video.

Instead, use streaming. DownloadStreamingAsync (or OpenReadAsync) gives you a stream you copy directly into the HTTP response.

app.MapGet("/files/{container}/{name}", async (
    string container, string name,
    BlobServiceClient blobService, CancellationToken ct) =>
{
    var blob = blobService
        .GetBlobContainerClient(container)
        .GetBlobClient(name);
 
    if (!await blob.ExistsAsync(ct))
        return Results.NotFound();
 
    var props = await blob.GetPropertiesAsync(cancellationToken: ct);
    var stream = await blob.OpenReadAsync(cancellationToken: ct);
 
    return Results.Stream(
        stream,
        contentType: props.Value.ContentType ?? "application/octet-stream",
        fileDownloadName: name,
        enableRangeProcessing: true);
});

Setting enableRangeProcessing: true is a gift to your users. It lets clients ask for byte ranges. A video player can jump to the middle of a film, and a download manager can resume after a dropped connection — instead of starting from zero.

MethodLoads into memory?Use it for
DownloadContentAsyncWhole blobSmall files only
DownloadStreamingAsyncSmall bufferLarge files, full control
OpenReadAsyncSmall bufferLarge files, simple Stream
DownloadToAsyncSmall bufferSaving straight to disk
A range request lets a client fetch just one slice of a large blob.

Step 6: Let clients talk to Azure directly with SAS tokens

So far every byte travels through your API. That works, but for very large files it makes your server a bottleneck — it pays for the bandwidth and holds connections open.

A better pattern for big uploads and downloads is the SAS token (Shared Access Signature). A SAS is a small, time-limited, scoped permission slip. Your API hands the client a special URL, and the client uploads or downloads straight to Azure. Your server never touches the bytes.

Think of it like a cloakroom ticket. You do not carry the customer's coat yourself — you give them a numbered ticket that only works for their coat, only today. That is a SAS.

Prefer a user delegation SAS, which is signed with Microsoft Entra credentials, over an account-key SAS. It is safer because it does not expose your storage account key and you can revoke the underlying identity.

public async Task<Uri> CreateUploadSasAsync(
    string container, string blobName, CancellationToken ct)
{
    var blob = blobService
        .GetBlobContainerClient(container)
        .GetBlobClient(blobName);
 
    // Get a short-lived key from Entra, not the account key.
    var delegationKey = await blobService.GetUserDelegationKeyAsync(
        DateTimeOffset.UtcNow,
        DateTimeOffset.UtcNow.AddMinutes(15),
        ct);
 
    var sas = new Azure.Storage.Sas.BlobSasBuilder
    {
        BlobContainerName = container,
        BlobName = blobName,
        Resource = "b",                       // a single blob
        StartsOn = DateTimeOffset.UtcNow,
        ExpiresOn = DateTimeOffset.UtcNow.AddMinutes(15)
    };
    sas.SetPermissions(Azure.Storage.Sas.BlobSasPermissions.Write);
 
    var uri = new Azure.Storage.Blobs.BlobUriBuilder(blob.Uri)
    {
        Sas = sas.ToSasQueryParameters(
            delegationKey.Value, blobService.AccountName)
    };
    return uri.ToUri();
}

The client receives this URL and uploads to it directly. Keep the expiry short — fifteen minutes is plenty for most uploads — and grant only the permission needed (Write for upload, Read for download).

SAS upload flow

Client asks API
API issues SAS
Client uploads to Azure
Azure stores blob

Steps

1

Client asks API

Request URL

2

API issues SAS

Short-lived, scoped

3

Client uploads to Azure

Direct, no API

4

Azure stores blob

Done

The API only issues a ticket; bytes go client to Azure.

Picking the right approach

There is no single "best" way — it depends on your file sizes and who you trust to hold the bytes.

  • Small files (under ~50 MB), simple app — one UploadAsync call. Easy and fine.
  • Large files through your API — stream request.Body to UploadAsync with tuned StorageTransferOptions. Low memory, parallel blocks.
  • Very large files, busy server — hand out a SAS and let clients go straight to Azure. Your API stays light.
  • Need a progress bar or piecewise reading — manual StageBlock / CommitBlockList.

Common mistakes to avoid

A few traps catch people again and again.

  • Reading the whole file into a byte[]. This is the number one cause of out-of-memory errors. Always stream.
  • Block IDs of different lengths. Pad them. D6 formatting fixes this.
  • Forgetting to commit. Staged blocks that are never committed silently disappear after seven days. No commit, no blob.
  • Long-lived SAS tokens. A SAS that lasts a year is a leaked key waiting to happen. Keep expiry short and permissions minimal.
  • Not setting Content-Type. Set it on upload so downloads and browsers know what the file is.
  • Ignoring cancellation. Pass the CancellationToken through so a dropped client connection actually stops the work.

Quick recap

  • A block blob is built from small blocks. You stage blocks, then commit an ordered list of their IDs to form the final blob.
  • For most files, UploadAsync with tuned StorageTransferOptions splits, parallelises, and commits for you — keeping memory low.
  • For full control (progress bars, piecewise data), use StageBlockAsync and CommitBlockListAsync, and remember block IDs must be the same length.
  • For downloads, stream with OpenReadAsync or DownloadStreamingAsync, and turn on enableRangeProcessing so clients can seek and resume.
  • In ASP.NET Core, stream request.Body straight to the uploader so the file never sits whole in memory.
  • For very large files, hand clients a short-lived user delegation SAS so bytes flow directly to Azure and your server stays light.
  • Use ArrayPool<byte>.Shared for buffers, set Content-Type, keep SAS expiry short, and always pass a CancellationToken.

References and further reading

Related Posts