Open Calculator

Small-File Object Storage Economics: Why Millions of 4KB Objects Inflate Class A PUT and LIST Costs

Published July 03, 2026 · Object Storage Metadata & Shard Consolidation

Object storage engines (AWS S3, Cloudflare R2, Google Cloud Storage, Azure Blob) are architected for high-throughput immutable blobs ranging from 1 MB to 5 TB. Because every stored key requires distributed metadata indexing, consensus replication, and HTTP transaction overhead, providers bill API requests separately from gigabyte capacity—charging 11x to 12.5x more for mutating Class A calls (`PUT`/`LIST`) than read-only Class B calls (`GET`/`HEAD`).

When engineering teams write millions of unbatched 4 KB JSON events, IoT sensor payloads, or small thumbnail fragments directly to object storage, API operation charges routinely exceed raw disk storage charges by 1,000% to 4,500%.

The Small-Object Cost Amplification Paradox (1 TB in 4 KB vs. 64 MB Files)

Consider two applications that both ingest and read exactly **1.0 TB (1,000,000,000,000 bytes)** of data per month on AWS S3 Standard (`$0.023/GB` storage, `$5.00/1M` Class A `PUT`, `$0.40/1M` Class B `GET`):

// Workload A: 1.0 TB Composed of 64 MB Parquet / Video Files (15,625 Objects)
Storage Cost (1,000 GB × $0.023): $23.00
Class A PUT Cost (15,625 calls × $5.00/1M): $0.08
Class B GET Cost (15,625 calls × $0.40/1M): $0.01
Total Monthly Invoice (Workload A): $23.09 / month
// Workload B: 1.0 TB Composed of 4 KB JSON Event Files (250,000,000 Objects)
Storage Cost (1,000 GB × $0.023): $23.00
Class A PUT Cost (250M calls × $5.00/1M): $1,250.00
Class B GET Cost (250M calls × $0.40/1M): $100.00
1 Daily Bucket Backup Sync (`ListObjectsV2`): $37.50 (250k paginated calls/day)
Total Monthly Invoice (Workload B): $1,410.50 / month (61x Higher!)

Complete S3 & R2 API Operation Classification Matrix

Understanding which S3 REST API actions trigger Class A versus Class B billing is essential before deploying backup agents (`rclone`, `restic`, `velero`) or data lake compaction jobs:

Billing Tier S3 REST API Operations Included AWS S3 Std (/1M) AWS S3-IA (/1M) Cloudflare R2 (/1M)
Class A (Mutating / List) `PutObject`, `CopyObject`, `PostObject`, `ListObjectsV2`, `CreateMultipartUpload`, `UploadPart`, `CompleteMultipartUpload` $5.00 $10.00 $4.50
Class B (Read / Metadata) `GetObject`, `HeadObject`, `GetObjectTagging`, `HeadBucket` $0.40 $1.00 $0.36
Lifecycle / Delete `DeleteObject`, `DeleteObjects` (Batch up to 1,000 keys), `AbortMultipartUpload` $0.00 $0.00 $0.00

The `ListObjectsV2` Pagination Multiplier: The S3 `ListObjectsV2` API returns a maximum of **1,000 keys per response page**. Listing a bucket containing 100,000,000 small objects requires **100,000 sequential Class A `LIST` calls** ($0.50 per full scan). If a misconfigured cron job or Kubernetes operator scans that bucket every 5 minutes, that single `LIST` loop costs **$4,320/month**. Use **AWS S3 Inventory** (which outputs a daily CSV/Parquet manifest of all keys for $0.0025 per million objects) instead of running live `ListObjectsV2` scans on massive buckets.

Multipart Upload Chunk Sizing & Orphan Part Cleanup

When uploading large files via S3 Multipart Upload, every individual `UploadPart` call—plus the initial `CreateMultipartUpload` and final `CompleteMultipartUpload`—is billed as a separate Class A operation:

  • Avoid 5 MB Minimum Part Sizes on Huge Files: If you upload a 50 GB database dump using the minimum 5 MB part size, your client executes **10,002 Class A requests** for a single file. Increasing `--s3-chunk-size` to **64 MB or 128 MB** reduces the upload to just 400 Class A calls while saturating 10 Gbps network links.
  • Orphaned Incomplete Multipart Uploads: If a client crashes mid-upload without calling `AbortMultipartUpload`, the uploaded parts remain invisible to standard `ListObjectsV2` queries while **billing you indefinitely for standard storage every month**. Always enforce an `AbortIncompleteMultipartUpload` lifecycle policy on every production bucket:
{
  "Rules": [
    {
      "ID": "Purge-Orphaned-Multipart-Uploads-After-7-Days",
      "Status": "Enabled",
      "Filter": { "Prefix": "" },
      "AbortIncompleteMultipartUpload": {
        "DaysAfterInitiation": 7
      }
    }
  ]
}

Architectural Pattern: Indexed Shard Packing & HTTP Range Reads

To eliminate small-file Class A/B costs without losing random-access read capability, pack 10,000 small blobs into a single 64 MB uncompressed `.tar` or binary packfile accompanied by a lightweight byte-offset index:

  1. Ingestion Buffering: Buffer incoming JSON events in memory (or via Vector / Kinesis Firehose / Kafka Connect) for 60 seconds or until reaching 64 MB before executing a single `PutObject` call.
  2. Random Access via Byte-Range `GET`: When an application needs a single 8 KB sub-item inside a 100 MB packfile, issue a standard `GetObject` request with the HTTP header `Range: bytes=4194304-4202495`. Both S3 and Cloudflare R2 return `HTTP 206 Partial Content` containing only the requested 8 KB slice, billing just 1 Class B operation and 8 KB of transfer.
import boto3

s3 = boto3.client("s3", endpoint_url="https://<ACCOUNT_ID>.r2.cloudflarestorage.com")

def fetch_packed_record(bucket: str, shard_key: str, offset: int, length: int) -> bytes:
    # S3 HTTP Range header is inclusive on both start and end byte offsets
    byte_range = f"bytes={offset}-{offset + length - 1}"
    resp = s3.get_object(Bucket=bucket, Key=shard_key, Range=byte_range)
    return resp["Body"].read()

# Reads exactly 4,096 bytes from a 64 MB shard in ~18ms (1 Class B GET)
payload = fetch_packed_record("telemetry-shards", "2026-07-03/pack-0042.bin", 4194304, 4096)

Plan Zero-Downtime Multi-Cloud Storage Migrations Next

Calculate the exact payback period when paying one-time S3 exit egress fees to move to zero-egress storage.

Storage Migration Guide →