Table of Contents

Uploading Objects

The R2 client provides multiple upload methods with intelligent strategy selection based on file size.

Overview

public class UploadService(IR2Client r2)
{
    public async Task UploadFileAsync(string key, string filePath)
    {
        // Automatically selects single-part or multipart based on size
        var result = await r2.UploadAsync("my-bucket", key, filePath);

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

Automatic Upload

The UploadAsync method automatically chooses the best upload strategy:

  • Files under 50 MiB: Single PUT request
  • Files of 50 MiB or more, and non-seekable streams: Multipart upload

From File Path

var result = await r2.UploadAsync(
    bucketName: "my-bucket",
    objectKey: "documents/report.pdf",
    filePath: "/path/to/report.pdf");

Console.WriteLine($"Uploaded: {result.IngressBytes} bytes");

From Stream

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

var result = await r2.UploadAsync(
    bucketName: "my-bucket",
    objectKey: "archives/file.zip",
    fileStream: stream);

With Custom Part Size

For multipart uploads, you can specify the part size (5 MiB - 5 GiB):

var result = await r2.UploadAsync(
    bucketName: "my-bucket",
    objectKey: "large-file.bin",
    filePath: "/path/to/large-file.bin",
    partSize: 100 * 1024 * 1024); // 100 MiB parts

Single-Part Upload

Force a single PUT request for files up to 5 GiB:

From File Path

var result = await r2.UploadSinglePartAsync(
    bucketName: "my-bucket",
    objectKey: "images/photo.jpg",
    filePath: "/path/to/photo.jpg");

From Stream

await using var stream = new MemoryStream(Encoding.UTF8.GetBytes("Hello, R2!"));

var result = await r2.UploadSinglePartAsync(
    bucketName: "my-bucket",
    objectKey: "text/hello.txt",
    inputStream: stream);

From In-Memory Data

byte[] data = GetFileData();
await using var stream = new MemoryStream(data);

var result = await r2.UploadSinglePartAsync(
    bucketName: "my-bucket",
    objectKey: "data.bin",
    inputStream: stream);

Content Type

Every upload method has an overload taking a contentType, so the stored object carries the MIME type a CDN or browser needs to serve it correctly. Without it, R2 stores its own default (application/octet-stream):

// Automatic strategy selection, with the type recorded either way:
var result = await r2.UploadAsync(
    bucketName: "my-bucket",
    objectKey: "thumbnails/photo-small.webp",
    filePath: "/path/to/photo-small.webp",
    partSize: null,
    contentType: "image/webp");

// Single PUT:
await r2.UploadSinglePartAsync("my-bucket", "thumbnails/photo-small.webp", stream, "image/webp");

// Multipart (the type travels on the initiate request, the only place S3 reads it from):
await r2.UploadMultipartAsync("my-bucket", "videos/clip.mp4", filePath, null, "video/mp4");

Passing null or a blank string leaves the property unset, so the overload behaves exactly like the one without the parameter. The value is applied verbatim: the client never infers a type from the file extension, the bytes, or the object key.

Cache-Control

Every upload method also takes an optional cacheControl. R2 stores the value on the object and serves it on every GET, which is what drives Cloudflare edge and browser caching for objects served through a bucket's custom domain:

// The stored object is served with "Cache-Control: public, max-age=3600", so no cache
// may serve it stale for more than an hour after it changes or is deleted.
await r2.UploadSinglePartAsync(
    "my-bucket", "configs/tenant-42.json", stream,
    contentType: "application/json",
    cacheControl: "public, max-age=3600");

The same null-or-blank rule applies: no value leaves the header unset. On the multipart path the value travels on the initiate request, like the content type, because the assembled object's headers come from the initiate call and never from the parts.

Checksums

A single-part upload can be bound to a digest of its bytes. R2 hashes what actually arrives and fails the upload with 400 BadDigest, storing nothing, when the bytes do not hash to the stated digest. All five R2ChecksumAlgorithm values are verified on this path:

var fileBytes = await File.ReadAllBytesAsync("/path/to/photo-small.webp");
var checksum  = UploadChecksum.FromDigestBytes(R2ChecksumAlgorithm.Sha256, SHA256.HashData(fileBytes));

await using var stream = new MemoryStream(fileBytes);

await r2.UploadSinglePartAsync("my-bucket", "thumbnails/photo-small.webp", stream, "image/webp", checksum);

A checksum digests the whole object, but a multipart upload is verified per part and the client does not compute per-part digests. UploadAsync therefore throws ArgumentException when a checksum accompanies an input that would go multipart (50 MiB or more, or a non-seekable stream); use UploadSinglePartAsync for objects up to 5 GiB, or upload without a checksum. For presigned uploads, where checksums guard against an untrusted client, see Checksum Verification.

R2Result

Upload operations return R2Result with metrics:

var result = await r2.UploadAsync("bucket", "key", filePath);

Console.WriteLine($"Class A Operations: {result.ClassAOperations}"); // 1 for single-part
Console.WriteLine($"Class B Operations: {result.ClassBOperations}"); // Always 0 for uploads
Console.WriteLine($"Ingress Bytes: {result.IngressBytes}");          // File size
Console.WriteLine($"Egress Bytes: {result.EgressBytes}");            // Always 0 for uploads

Error Handling

try
{
    await r2.UploadAsync("bucket", "key", filePath);
}
catch (FileNotFoundException)
{
    Console.WriteLine("File not found");
}
catch (ArgumentException ex)
{
    // File exceeds size limits
    Console.WriteLine($"Invalid file: {ex.Message}");
}
catch (CloudflareR2OperationException ex)
{
    Console.WriteLine($"Upload failed: {ex.Message}");
    // Access partial metrics if available
    Console.WriteLine($"Bytes uploaded before failure: {ex.PartialMetrics?.IngressBytes}");
}

Common Patterns

Upload with Progress

public async Task UploadWithProgressAsync(string bucket, string key, string filePath)
{
    var fileInfo = new FileInfo(filePath);
    var totalBytes = fileInfo.Length;

    // For small files, use single-part
    if (totalBytes <= 5L * 1024 * 1024 * 1024)
    {
        Console.WriteLine("Uploading...");
        await r2.UploadSinglePartAsync(bucket, key, filePath);
        Console.WriteLine("Complete!");
    }
    else
    {
        // For large files, track multipart progress
        // See Multipart Uploads documentation
        await r2.UploadMultipartAsync(bucket, key, filePath);
    }
}

Upload Multiple Files

public async Task<R2Result> UploadDirectoryAsync(string bucket, string prefix, string localDir)
{
    var total = new R2Result();

    foreach (var file in Directory.GetFiles(localDir, "*", SearchOption.AllDirectories))
    {
        var relativePath = Path.GetRelativePath(localDir, file);
        var key = $"{prefix}/{relativePath.Replace('\\', '/')}";

        var result = await r2.UploadAsync(bucket, key, file);
        total += result;

        Console.WriteLine($"Uploaded: {key}");
    }

    return total;
}

Upload with Retry

public async Task<R2Result> UploadWithRetryAsync(
    string bucket, string key, string filePath, int maxRetries = 3)
{
    for (int attempt = 1; attempt <= maxRetries; attempt++)
    {
        try
        {
            return await r2.UploadAsync(bucket, key, filePath);
        }
        catch (CloudflareR2OperationException) when (attempt < maxRetries)
        {
            Console.WriteLine($"Attempt {attempt} failed, retrying...");
            await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)));
        }
    }

    throw new InvalidOperationException($"Upload failed after {maxRetries} attempts");
}

Upload JSON Data

public async Task<R2Result> UploadJsonAsync<T>(string bucket, string key, T data)
{
    var json = JsonSerializer.Serialize(data);
    var bytes = Encoding.UTF8.GetBytes(json);

    await using var stream = new MemoryStream(bytes);
    return await r2.UploadSinglePartAsync(bucket, key, stream, "application/json");
}

Size Limits

Upload Type Maximum Size
Single-part (PUT) 5 GiB
Multipart 5 TiB
Minimum part size 5 MiB
Maximum part size 5 GiB