S3 Cost Traps We Keep Falling Into (And How to Spot Them)

S3 Cost Traps We Keep Falling Into (And How to Spot Them)

Arthur

S3 is incredibly cheap. Until it isn't. Here are the cost traps we keep seeing in real production accounts.

Why S3 bills sneak up on you

S3 storage at £0.018/GB/month feels too cheap to worry about. And per-gigabyte, it is. The bill explodes from the things around the storage: requests, data transfer, lifecycle policies that nobody set, and a slow accumulation of half-finished multipart uploads sitting in your buckets like forgotten browser tabs.

Below are the patterns we see most often when we audit cloud bills.

Trap 1 — Listing the same prefix a billion times

LIST requests are £0.004 per 1,000. Sounds free. It isn't. A common pattern:

# Every page render
keys = s3.list_objects_v2(Bucket=b, Prefix=user_prefix)

If your app calls that on every page load and you have 100k daily users, you're at 3M LISTs/day = ~£12/day = ~£360/month, just for listings. Multiply by an aggressive S3-as-a-database codebase and you're well into four figures.

Fix: store metadata in a real database (Postgres, DynamoDB, even an SQS queue if you're event-driven) and only LIST when you genuinely need filesystem semantics — typically batch jobs, never page renders.

Trap 2 — Multipart upload graveyards

S3 lets you upload large objects as multipart uploads. If a client starts an upload and never completes it, the parts stay there, billed as storage, invisible in the normal object listing. We've audited buckets with 30TB of completed objects and 80TB of forgotten parts. Same bucket, same bill, no UI surface to spot it.

Two minutes of fix:

{
  "Rules": [{
    "ID": "abort-incomplete-multipart",
    "Status": "Enabled",
    "AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 7 }
  }]
}

Every bucket you own should have this lifecycle rule. There is no downside.

Trap 3 — Cross-region transfer because someone copy-pasted a region

Data transfer between AWS regions is £0.016/GB. Between AZs in the same region: free for most paths. Out to the internet: £0.07/GB and up.

The classic foot-gun: a Lambda in us-east-1 reading objects from a bucket in eu-west-2 because someone parameterised the bucket name and not the region. At 1TB/day that's £16/day = £480/month, on a single misconfiguration.

How to spot it: the Cost Explorer "Data Transfer" line item, broken down by region pair. If you see any cross-region traffic you didn't explicitly architect for, hunt it.

Trap 4 — Versioning enabled "for safety," never tidied

Bucket versioning is great — accidental deletes are recoverable. But every PUT to an existing key creates a new version, and old versions are billed at the same per-GB rate as live ones. Three years later: you have 12x the storage you think you have.

The lifecycle rule you want:

{
  "Rules": [{
    "ID": "expire-old-versions",
    "Status": "Enabled",
    "NoncurrentVersionExpiration": { "NoncurrentDays": 30 },
    "Filter": { "Prefix": "" }
  }]
}

Pick a window that matches your real "oh no, undo" need. 30 days is generous; 7 is fine for most teams.

Trap 5 — Storage class amnesia

S3 has at least seven storage classes, but most buckets get filed in S3 Standard and stay there forever. Two zero-effort wins:

  • S3 Intelligent-Tiering. One option, two settings. AWS moves objects between access tiers automatically based on access patterns. Costs an extra £0.002/1k objects/month for monitoring; pays for itself the moment 30% of your data goes cold.

  • Glacier Instant Retrieval. For objects you're keeping for compliance but rarely read, Glacier IR is ~70% cheaper than Standard with the same single-digit-ms read latency.

The combination — Intelligent-Tiering for unknown access patterns, Glacier IR for known-cold archive — typically takes 30–50% off the storage line for a mature bucket.

Trap 6 — Logging buckets that grow forever

S3 access logs, ALB logs, CloudFront logs, VPC flow logs — every one of them dumps to a bucket. Without a lifecycle rule they accumulate forever. Logs you'll never read again are the easiest cost to delete.

For most teams: 90 days hot in Standard, archive to Glacier Deep Archive after that, expire after 1 year. Adjust for compliance needs.

How to triage your own bill

Cost Explorer with "S3" filter, grouped by Usage Type, last 30 days. The line items tell the story:

  • TimedStorage-ByteHrs — your actual storage cost. If high, focus on lifecycle and storage classes.

  • Requests-Tier1 / Tier2 — PUT/GET/LIST. If high, find the chatty client.

  • DataTransfer-Out-Bytes — egress. Usually means CloudFront would be cheaper, or the Lambda-in-wrong-region issue above.

  • EarlyDelete-ByteHrs — you're moving things to Glacier and deleting them before the minimum storage duration. Stop.

Storage Lens (free tier) gives you the same picture per-bucket, with anomaly detection thrown in. Turn it on.

The depressing punchline

Almost every "S3 cost shock" we audit comes down to a missing lifecycle rule and a chatty client. Not a fundamental architecture problem, not a need for a different storage system — just two settings nobody got around to. Spend an afternoon on lifecycle rules across all your buckets. It's the highest-ROI work you'll do this quarter.

We use cookies to analyse site traffic and improve your experience. See our Privacy Policy for details.