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 |
Related
- Multipart Uploads - Large file handling
- Presigned URLs - Direct browser uploads
- Downloading Objects - Download files