Table of Contents

Multipart Uploads

Handle large file uploads (up to 5 TiB) using R2's multipart upload API.

Overview

Multipart uploads split large files into smaller parts that can be uploaded independently and assembled on the server.

public class LargeFileService(IR2Client r2)
{
    public async Task UploadLargeFileAsync(string key, string filePath)
    {
        // Automatic multipart for files > 5 GiB
        var result = await r2.UploadAsync("my-bucket", key, filePath);

        Console.WriteLine($"Uploaded {result.IngressBytes} bytes");
        Console.WriteLine($"Class A operations: {result.ClassAOperations}");
    }
}

Automatic Multipart

The UploadAsync method automatically uses multipart for large files:

// Files > 5 GiB are automatically uploaded as multipart
var result = await r2.UploadAsync("bucket", "large-file.zip", "/path/to/large-file.zip");

Explicit Multipart Upload

Force multipart upload regardless of file size:

From File Path

var result = await r2.UploadMultipartAsync(
    bucketName: "my-bucket",
    objectKey: "backup/database.sql",
    filePath: "/path/to/database.sql",
    partSize: 100 * 1024 * 1024); // 100 MiB parts

From Stream

await using var stream = File.OpenRead("/path/to/large-file.bin");

var result = await r2.UploadMultipartAsync(
    bucketName: "my-bucket",
    objectKey: "data/large-file.bin",
    inputStream: stream, // Must be seekable
    partSize: 50 * 1024 * 1024); // 50 MiB parts

Part Size Configuration

The partSize parameter controls chunk sizes (default is calculated based on file size):

Part Size When to Use
5 MiB (minimum) Many small files, limited memory
50-100 MiB General purpose
500 MiB - 1 GiB Very large files, fast networks
5 GiB (maximum) Minimize operations for huge files
// Small parts for memory-constrained environments
await r2.UploadMultipartAsync("bucket", "key", filePath,
    partSize: 5 * 1024 * 1024); // 5 MiB

// Large parts for fast connections
await r2.UploadMultipartAsync("bucket", "key", filePath,
    partSize: 1024 * 1024 * 1024); // 1 GiB

Manual Multipart Control

For fine-grained control over the upload process:

Initiating Upload

var result = await r2.InitiateMultipartUploadAsync(
    bucketName: "my-bucket",
    objectKey: "uploads/large-file.bin");

var uploadId = result.Data;
Console.WriteLine($"Upload ID: {uploadId}");

Setting the Object's Content-Type and Cache-Control

S3 reads the assembled object's Content-Type and Cache-Control from the call that starts the upload and never from the individual parts. A caller that hands out presigned part URLs therefore has no later opportunity to set them, so supply them here:

var result = await r2.InitiateMultipartUploadAsync(
    bucketName:   "my-bucket",
    objectKey:    "uploads/report.pdf",
    contentType:  "application/pdf",
    cacheControl: "public, max-age=3600");

Omitting either argument, or passing null or a blank string, leaves that header unset. For the content type R2's default is application/octet-stream, which is what browsers download rather than display; without a stored Cache-Control, caches fall back to their own heuristics.

Listing Parts

Check which parts have been uploaded:

var partsResult = await r2.ListPartsAsync(
    bucketName: "my-bucket",
    objectKey: "uploads/large-file.bin",
    uploadId: uploadId);

foreach (var part in partsResult.Data)
{
    Console.WriteLine($"Part {part.PartNumber}: {part.Size} bytes");
    Console.WriteLine($"  ETag: {part.ETag}");
}

Listing Open Uploads

ListMultipartUploadsAsync reports every multipart upload that was started in a bucket and has not yet been completed or aborted. Object listing cannot see these uploads, because their parts only become an object once the upload completes, so this is the only way to discover them:

var open = await r2.ListMultipartUploadsAsync(
    bucketName: "my-bucket",
    prefix:     null); // or restrict to a key prefix

foreach (var upload in open.Data)
{
    Console.WriteLine($"{upload.Key} started {upload.Initiated:O}");
    Console.WriteLine($"  UploadId: {upload.UploadId}");
}

The parts of an open upload still consume storage you are billed for, and R2 refuses to delete a bucket that has any upload left open. Pass the UploadId from this listing to AbortMultipartUploadAsync to release them:

foreach (var upload in open.Data)
{
    await r2.AbortMultipartUploadAsync("my-bucket", upload.Key, upload.UploadId);
}
Important

R2 has been observed to report an upload identifier in this listing that differs from the one it returned when the upload was started. Always abort using the UploadId that this listing supplied, not one you recorded earlier.

The method walks every page of results before returning, so the returned list is complete.

Completing Upload

After all parts are uploaded:

// Collect ETags from each uploaded part
var partETags = new List<PartETag>
{
    new("etag-from-part-1", 1),
    new("etag-from-part-2", 2),
    new("etag-from-part-3", 3)
};

var result = await r2.CompleteMultipartUploadAsync(
    bucketName: "my-bucket",
    objectKey: "uploads/large-file.bin",
    uploadId: uploadId,
    parts: partETags);

Aborting Upload

Cancel an incomplete multipart upload (free operation):

await r2.AbortMultipartUploadAsync(
    bucketName: "my-bucket",
    objectKey: "uploads/large-file.bin",
    uploadId: uploadId);
Note

Aborting deletes all uploaded parts. This is a free operation.

Error Handling

try
{
    await r2.UploadMultipartAsync("bucket", "key", filePath);
}
catch (NotSupportedException ex)
{
    // Stream is not seekable
    Console.WriteLine($"Stream error: {ex.Message}");
}
catch (ArgumentException ex)
{
    // File too large (> 5 TiB)
    Console.WriteLine($"Size error: {ex.Message}");
}
catch (CloudflareR2OperationException ex)
{
    // Upload failed
    Console.WriteLine($"Upload failed: {ex.Message}");
    Console.WriteLine($"Partial metrics: {ex.PartialMetrics}");

    // Consider cleanup
    if (ex.Message.Contains("uploadId"))
    {
        // Extract uploadId and abort if needed
    }
}

Common Patterns

Upload with Progress

public async Task UploadWithProgressAsync(
    string bucket, string key, string filePath, IProgress<double> progress)
{
    var fileInfo = new FileInfo(filePath);
    var totalSize = fileInfo.Length;
    var partSize = 100 * 1024 * 1024L; // 100 MiB

    // Initiate
    var initResult = await r2.InitiateMultipartUploadAsync(bucket, key);
    var uploadId = initResult.Data;

    try
    {
        var partETags = new List<PartETag>();
        var partNumber = 1;
        var uploadedBytes = 0L;

        await using var stream = File.OpenRead(filePath);
        var buffer = new byte[partSize];

        while (true)
        {
            var bytesRead = await stream.ReadAsync(buffer);
            if (bytesRead == 0) break;

            // Upload part (would need presigned URL or S3 SDK for this)
            // partETags.Add(new PartETag(etag, partNumber));

            uploadedBytes += bytesRead;
            progress.Report((double)uploadedBytes / totalSize * 100);
            partNumber++;
        }

        // Complete
        await r2.CompleteMultipartUploadAsync(bucket, key, uploadId, partETags);
        progress.Report(100);
    }
    catch
    {
        await r2.AbortMultipartUploadAsync(bucket, key, uploadId);
        throw;
    }
}

Resume Incomplete Upload

public async Task ResumeUploadAsync(
    string bucket, string key, string filePath, string uploadId)
{
    // List already uploaded parts
    var partsResult = await r2.ListPartsAsync(bucket, key, uploadId);
    var existingParts = partsResult.Data;

    var completedPartNumbers = existingParts
        .Select(p => p.PartNumber)
        .ToHashSet();

    Console.WriteLine($"Resuming upload with {existingParts.Count} parts already uploaded");

    // Continue uploading missing parts...
    // Then complete
}

Cleanup Incomplete Uploads

R2 applies a lifecycle rule that expires incomplete uploads after seven days, but you can release their storage immediately by discovering the open uploads and aborting each one:

public async Task<int> CleanupIncompleteUploadsAsync(string bucket, string? prefix = null)
{
    var open = await r2.ListMultipartUploadsAsync(bucket, prefix);

    foreach (var upload in open.Data)
    {
        await r2.AbortMultipartUploadAsync(bucket, upload.Key, upload.UploadId);
    }

    return open.Data.Count;
}

ClearBucketAsync already does exactly this after it deletes the objects, so calling it is enough to leave a bucket that R2 will let you delete. See Deleting Objects for the parameter that turns that cleanup off.

Size Limits

Limit Value
Maximum object size 5 TiB
Minimum part size 5 MiB
Maximum part size 5 GiB
Maximum parts per upload 10,000

Part Count Calculation

MaxObjectSize = MaxParts × MaxPartSize = 10,000 × 5 GiB = 50 TiB (theoretical)
ActualLimit = 5 TiB (R2 limit)

R2 Pricing

Operation Cost
InitiateMultipartUpload Class A
UploadPart Class A
CompleteMultipartUpload Class A
AbortMultipartUpload Free
ListParts Class A
ListMultipartUploads Class A per page