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.
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:
- 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. - 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.
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.
| Limit | Value |
|---|---|
| Max size of one block | 100 MiB (4 GiB in preview) |
| Max number of blocks per blob | 50,000 |
| Max block blob size | about 4.75 TiB |
| Common block size for uploads | 4 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.IdentityThen 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
Steps
Read stream
Open the source
Compare size
vs InitialTransferSize
Single PUT
Small files
Stage + Commit
Large files, parallel
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 so1becomes000001. Mixing lengths makes Azure reject the commit. - We rent the buffer from
ArrayPool<byte>.Sharedand return it in afinally. This reuses memory instead of allocating a fresh 4 MiB array for every block.
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
Steps
Client
Sends file
API body stream
Read in chunks
SDK blocks
Stage in parallel
Azure blob
Commit
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.
| Method | Loads into memory? | Use it for |
|---|---|---|
DownloadContentAsync | Whole blob | Small files only |
DownloadStreamingAsync | Small buffer | Large files, full control |
OpenReadAsync | Small buffer | Large files, simple Stream |
DownloadToAsync | Small buffer | Saving straight to disk |
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
Steps
Client asks API
Request URL
API issues SAS
Short-lived, scoped
Client uploads to Azure
Direct, no API
Azure stores blob
Done
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
UploadAsynccall. Easy and fine. - Large files through your API — stream
request.BodytoUploadAsyncwith tunedStorageTransferOptions. 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.
D6formatting 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
CancellationTokenthrough 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,
UploadAsyncwith tunedStorageTransferOptionssplits, parallelises, and commits for you — keeping memory low. - For full control (progress bars, piecewise data), use
StageBlockAsyncandCommitBlockListAsync, and remember block IDs must be the same length. - For downloads, stream with
OpenReadAsyncorDownloadStreamingAsync, and turn onenableRangeProcessingso clients can seek and resume. - In ASP.NET Core, stream
request.Bodystraight 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>.Sharedfor buffers, set Content-Type, keep SAS expiry short, and always pass aCancellationToken.
References and further reading
- Upload a blob with .NET — Microsoft Learn
- Performance tuning for uploads and downloads with the Azure Storage client library for .NET — Microsoft Learn
- Implementing Large File Uploads and Downloads in Azure Blob Storage With .NET — antondevtips
- Uploading to Azure Blob Storage with Shared Access Signatures — Mark Heath
- Dos and Don'ts for Streaming File Uploads to Azure Blob Storage — Trailhead Technology
Related Posts
Building Async APIs in ASP.NET Core the Right Way
Learn to build fast, safe async APIs in ASP.NET Core: async/await, CancellationToken, avoiding .Result deadlocks, and thread pool tips.
How to Increase the Performance of Web APIs in .NET
A friendly, step-by-step guide to making your ASP.NET Core Web APIs fast: async, caching, query tuning, compression, and pooling in .NET 10.
Global Error Handling in ASP.NET Core 8 (Beginner Guide)
Learn global error handling in ASP.NET Core 8 with IExceptionHandler, ProblemDetails, and UseExceptionHandler, explained with simple diagrams and clear code.
Top 15 Mistakes Developers Make When Creating Web APIs
A warm, beginner-friendly tour of the 15 most common Web API mistakes in ASP.NET Core, with simple fixes, diagrams, tables, and clear C# examples.
Refit in .NET: Building Robust API Clients in C#
Learn Refit in .NET to build type-safe REST API clients in C#. Define an interface, add attributes, and Refit writes the HttpClient code for you.
Integrate Keycloak with ASP.NET Core Using OAuth 2.0
A beginner-friendly guide to securing an ASP.NET Core API and web app with Keycloak using OAuth 2.0 and OpenID Connect, with diagrams, tables, and copy-paste code.