Blob storage
Object storage for system design interviews, from buckets and erasure coding to presigned URLs, multipart uploads, storage tiers and orphan cleanup.
- Read first
- EstimationChoosing a database
The problem it solves
Section titled “The problem it solves”A blob is a lump of bytes the system stores and returns but never looks inside: a photo, a video,
a backup, a log file. A photo app takes 1 million uploads a day at 2 MB each: 2 TB a day, about 730 TB after a year.
The first version stores each photo in a BYTEA column next to its caption. It works at a few
thousand photos, and then four things go wrong at once.
- Cost. Database storage is provisioned SSD at about $0.08 per GB-month, held three times (a primary and two replicas), plus backups. 730 TB costs about $175,000 a month. The same bytes in S3 Standard cost about $17,000, with copies in three availability zones included.
- Size. No single database server holds 730 TB. Managed Postgres and MySQL top out at tens of TB per instance. So the photos would force sharding, on a table with no other reason for it.
- Operations. Backups, restores and replica rebuilds scale with bytes, from minutes to days.
- The serving path. Every view drags bytes through the buffer pool and an app server. And a 2 MB upload over a 1 Mbit/s mobile link holds an app server connection for 16 seconds.
The fix splits the data by shape. The metadata is small, structured and queried: owner, caption, size, and which key holds the bytes. So it stays in the database, at about 1 KB a photo, or 365 GB a year. The bytes go to an object store. It’s built for exactly one thing: put a blob under a name, get it back by that name, and never lose it.
How it works
Section titled “How it works”Step 1: Files on a server’s disk
Section titled “Step 1: Files on a server’s disk”The simplest version writes each upload to /var/data/photos/{id}.jpg, and stores the path in the
row. It fails at the second server, or the first dead disk, because the file lives on one machine.
A shared network filesystem moves the problem to one big server. Its POSIX semantics (locks,
rename, partial writes) are expensive to scale, and photos never needed them.
Step 2: An object store, and what an object is
Section titled “Step 2: An object store, and what an object is”An object store drops everything a filesystem promises except the parts blobs need. The model has three nouns:
- A bucket is a namespace with its own settings: region, access policy, versioning, lifecycle rules, encryption. An account has few buckets and each holds unlimited objects.
- A key is the object’s full name, such as
media/u42/2026/10/7f3a.jpg. The namespace is flat: the slashes are just characters. “Folders” in a console are a display trick, aLISTwith a prefix and a/delimiter. - An object is the bytes plus a little metadata (size, content type, an ETag, user-defined headers). It is immutable: you replace a whole object, you never edit byte 1,000 of it.
The API is about six verbs: PUT, GET (optionally a byte range), HEAD, DELETE, LIST and
COPY. The constraints that follow show up in every design:
| Constraint | What it means | Design consequence |
|---|---|---|
| No partial update | Changing one byte rewrites the object | Split big mutable things into chunks (file sync), or append as new objects (logs, segments) |
| No rename | A “rename” is a copy plus a delete; renaming a folder of 1 million objects is 2 million requests | Never encode mutable things (titles, owners, folder paths) in keys; the database holds the name |
LIST is slow and paged |
1,000 keys per call, in key order; listing 1 billion keys is 1 million sequential calls | The database is the index. For audits, use a daily inventory report, not LIST |
| No transactions across objects | Two PUTs are two independent writes | A multi-object change needs a manifest object or a database commit as the single switch (step 4) |
| Per-request cost and latency | About $0.005 per 1,000 PUTs, tens of ms to the first byte | Tiny objects are expensive per byte; pack them (step 8) |
| Throughput per key prefix | About 3,500 writes and 5,500 reads a second per partition of the key space, which S3 splits as load grows | Spread hot write paths across prefixes; never serve a viral object straight from the bucket |
Consistency. Since December 2020, S3 gives strong read-after-write consistency for every PUT,
overwrite and DELETE. And LIST reflects them immediately. Google Cloud Storage and Azure Blob
Storage are also strongly consistent for single-object operations.
Nothing is guaranteed across objects or copies. Two writers to one key race, and the last one wins.
(S3’s conditional writes, If-None-Match and If-Match, added in 2024, prevent that.)
Cross-region replication is asynchronous. And a CDN serves whatever it cached.
Step 3: Durability, from copies to erasure codes
Section titled “Step 3: Durability, from copies to erasure codes”S3 Standard is designed for 99.999999999% durability, eleven nines, per object per year. AWS’s own illustration: store 10 million objects, and expect to lose one every 10,000 years. Availability is a different, much lower number. S3 Standard is designed for 99.99%, which allows about 53 minutes of errors a year. Durability means “is it still there”. Availability means “can I read it right now”.
There are two ways to buy durability from disks that fail at 1 to 2% a year.
Replication stores whole copies on different disks, racks or zones. Three copies survive any two losses and cost 3 times the data. Reads are simple, and repairing a lost copy reads one other copy.
Erasure coding cuts an object into k data fragments, and computes m parity fragments. Any k of the k + m fragments rebuild the object. Reed-Solomon RS(10,4) stores 10 data and 4 parity fragments, on 14 different disks. It survives any 4 losses, at 1.4 times the data.
That is more loss tolerance than three copies, for less than half the disk. That’s why every large object store codes most of its bytes. The price is repair and read cost. Rebuilding one lost fragment reads 10 others. And a read during a failure must reconstruct.
- Color key
- Service
- Database
- Failure
| Scheme | Storage overhead | Losses survived | Repair of one fragment reads | Used by |
|---|---|---|---|---|
| 3 replicas | 3.0× | 2 | 1 copy | Hot data, small objects, HDFS default, most databases |
| RS(6,3) | 1.5× | 3 | 6 fragments | HDFS erasure coding policy |
| RS(10,4) | 1.4× | 4 | 10 fragments | Facebook’s f4 warm photo store, many object stores |
| LRC(12,2,2) | 1.33× | Any 3, most sets of 4 | 6 fragments | Azure Storage: local parities make the common single repair cheap |
The overhead numbers are why erasure coding matters in a design. At 1,000 PB, the difference between 3 copies and 1.4 is 1,600 PB of disk. The Instagram problem uses exactly that move for media older than 90 days.
Go deeperThe durability arithmetic, and why it is optimistic3 min
Data is lost when more fragments fail than the code tolerates, before repair finishes. Take independent disks with a 2% annual failure rate. The chance that one given disk fails in a one-day repair window is 0.02 / 365, about 5.5 × 10⁻⁵. Then, per group of fragments, per year:
| Scheme | Repair in 1 day | Repair in 7 days |
|---|---|---|
| 3 copies | about 2 × 10⁻¹⁰ (10 nines) | about 9 × 10⁻⁹ (8 nines) |
| RS(6,3) | about 2 × 10⁻¹² (12 nines) | about 6 × 10⁻¹⁰ (9 nines) |
| RS(10,4) | about 2 × 10⁻¹⁵ (15 nines) | about 4 × 10⁻¹² (11 nines) |
Two lessons follow.
First, repair speed matters as much as the code. The loss probability scales with the repair window, raised to the number of failures tolerated. So a store repairs in parallel from many disks, to keep the window in hours.
Second, these numbers assume independence, and real failures are not independent. A rack loses power. A firmware bug hits one disk model. A deploy corrupts a batch. An operator deletes the wrong thing. Correlated failures dominate real loss. So stores spread fragments across racks and availability zones. They scrub (re-read and checksum) every fragment all the time, to find silent corruption. And “eleven nines” is a design target, not a measurement. The biggest remaining risk to your data is your own code and credentials (“How it fails”), not disks.
Step 4: Metadata in a database, bytes in the store
Section titled “Step 4: Metadata in a database, bytes in the store”Every real design has two stores. One is a database row per blob, which the application queries. The other is the object store: a dumb, huge, durable map from key to bytes.
- Color key
- Service
- Database
The two stores share no transaction, so a crash can always land between them, leaving one of two inconsistencies:
- A row that points at nothing. Readers get a broken image, and nothing looks wrong from the row, so nothing finds it. This is the bad one.
- Bytes no row points at, an orphan. Invisible to users, costs money, and a background job can find it by comparing the bucket’s inventory with the table.
So the rule is bytes first, metadata last. The commit that makes a blob visible is the last write. So a crash before it leaves only an orphan.
The production version adds a pending row:
- Insert the row first, in state
uploading. Readers can’t see it. It records the key and the upload id. - Upload the bytes.
- Flip the row to
ready.
The row now says what was in flight. So a sweeper can finish or clean up a stuck upload, without listing the bucket.
Keys follow from this. Use a fresh, opaque key per upload. Never use the user’s filename, and never overwrite in place.
The other half of the rule is a reconciler. It joins the daily inventory with the table. It deletes
orphans older than a grace period. It rolls forward, or fails, rows stuck in uploading. It aborts
multipart uploads nobody tracks. And it refuses to run when the numbers look wrong, because a
truncated table makes every object look like an orphan.
import java.util.ArrayList;import java.util.HashMap;import java.util.HashSet;import java.util.List;import java.util.Map;import java.util.Set;
// One line of the daily inventory listing, not a LIST call per key.record StoredObject(String key, long size, int ageHours) {}
// The metadata database; only "ready" rows are visible to readers.class Row { final int id; final String key; String state; // "uploading", "ready" or "failed" final long size; // what the client said it would upload final String uploadId; final int ageHours;
Row(int id, String key, String state, long size, String uploadId, int ageHours) { this.id = id; this.key = key; this.state = state; this.size = size; this.uploadId = uploadId; this.ageHours = ageHours; }}
// An unfinished multipart upload: invisible, but its parts are billed.record Upload(String key, String uploadId, int ageHours) {}
class Reconciler { static final int GRACE_HOURS = 24; // younger objects may belong to an upload about to commit static final double MAX_DELETE_SHARE = 0.01; // over 1% of the bucket in one run means a bug
// Read the listing before the rows. Every upload writes a fresh key, so an object that is // old in the listing can only gain a row by an upload that is already old too. Content // addressed keys break this: a new commit can reuse an old object, and needs a touch guard. static List<String> reconcile(List<StoredObject> objects, List<Row> rows, List<Upload> uploads) { List<String> actions = new ArrayList<>(); Map<String, StoredObject> byKey = new HashMap<>(); for (StoredObject o : objects) byKey.put(o.key(), o); Set<String> referenced = new HashSet<>(); Set<String> tracked = new HashSet<>(); for (Row r : rows) { referenced.add(r.key); if (r.uploadId != null) tracked.add(r.uploadId); }
// 1. Orphans: bytes no row points at, typically a crash between the upload and the commit. List<StoredObject> orphans = new ArrayList<>(); for (StoredObject o : objects) { if (!referenced.contains(o.key()) && o.ageHours() > GRACE_HOURS) orphans.add(o); } if (orphans.size() > Math.max(10, MAX_DELETE_SHARE * objects.size())) { // A truncated table or a bad join would make every object look orphaned. Stop and page. throw new IllegalStateException(orphans.size() + " orphans is too many; refusing to delete"); } for (StoredObject o : orphans) actions.add("delete orphan " + o.key());
// 2. Stuck uploads: the row says uploading and nothing has happened for a day. for (Row r : rows) { if (!r.state.equals("uploading") || r.ageHours <= GRACE_HOURS) continue; StoredObject obj = byKey.get(r.key); if (obj != null && obj.size() == r.size) { // The bytes made it; the crash was before the flip. Rolling forward keeps the // user's upload. (Production also checks the checksum the client declared.) r.state = "ready"; actions.add("roll forward media " + r.id); } else { r.state = "failed"; actions.add("abort upload " + r.uploadId + ", fail media " + r.id); } }
// 3. Multipart uploads with no row at all. A lifecycle rule (AbortIncompleteMultipartUpload) // does the same after N days; this is the safety net for buckets without one. for (Upload u : uploads) { if (!tracked.contains(u.uploadId()) && u.ageHours() > GRACE_HOURS) { actions.add("abort upload " + u.uploadId() + " (no row)"); } } return actions; }}
class Main { public static void main(String[] args) { List<StoredObject> objects = List.of( new StoredObject("m/1", 2_000_000, 400), // a normal, committed photo new StoredObject("m/2", 3_000_000, 30), // orphan: the server crashed before the insert new StoredObject("m/3", 1_000_000, 2), // no row yet, but young: maybe committing now new StoredObject("m/4", 5_000_000, 26)); // complete, but the row never flipped to ready List<Row> rows = List.of( new Row(1, "m/1", "ready", 2_000_000, null, 400), new Row(4, "m/4", "uploading", 5_000_000, "up-4", 26), new Row(5, "m/5", "uploading", 9_000_000, "up-5", 30), // the phone gave up halfway new Row(6, "m/6", "uploading", 4_000_000, "up-6", 1)); // still in progress List<Upload> uploads = List.of( new Upload("m/5", "up-5", 30), new Upload("m/6", "up-6", 1), new Upload("m/9", "up-9", 50)); for (String action : Reconciler.reconcile(objects, rows, uploads)) System.out.println(action); // delete orphan m/2 // roll forward media 4 // abort upload up-5, fail media 5 // abort upload up-9 (no row) }}from dataclasses import dataclass
GRACE_HOURS = 24 # younger objects may belong to an upload that is about to commitMAX_DELETE_SHARE = 0.01 # deleting more than 1% of the bucket in one run means a bug, not garbage
@dataclassclass StoredObject: # one line of the daily inventory listing, not a LIST call per key key: str size: int age_hours: int
@dataclassclass Row: # the metadata database; only "ready" rows are visible to readers id: int key: str state: str # "uploading", "ready" or "failed" size: int # what the client said it would upload upload_id: str | None age_hours: int
@dataclassclass Upload: # an unfinished multipart upload: invisible, but its parts are billed key: str upload_id: str age_hours: int
def reconcile(objects: list[StoredObject], rows: list[Row], uploads: list[Upload]) -> list[str]: # Read the listing before the rows. Every upload writes a fresh key, so an object that is # old in the listing can only gain a row by an upload that is already old too. Content # addressed keys break this: a new commit can reuse an old object, and needs a touch guard. actions: list[str] = [] by_key = {o.key: o for o in objects} referenced = {r.key for r in rows}
# 1. Orphans: bytes no row points at, typically a crash between the upload and the commit. orphans = [o for o in objects if o.key not in referenced and o.age_hours > GRACE_HOURS] if len(orphans) > max(10, MAX_DELETE_SHARE * len(objects)): # A truncated table or a bad join would make every object look orphaned. Stop and page. raise RuntimeError(f"{len(orphans)} orphans is too many; refusing to delete") actions += [f"delete orphan {o.key}" for o in orphans]
# 2. Stuck uploads: the row says uploading and nothing has happened for a day. for r in rows: if r.state != "uploading" or r.age_hours <= GRACE_HOURS: continue obj = by_key.get(r.key) if obj is not None and obj.size == r.size: # The bytes made it; the crash was before the flip. Rolling forward keeps the user's # upload. (Production also checks the checksum the client declared.) r.state = "ready" actions.append(f"roll forward media {r.id}") else: r.state = "failed" actions.append(f"abort upload {r.upload_id}, fail media {r.id}")
# 3. Multipart uploads with no row at all. A lifecycle rule (AbortIncompleteMultipartUpload) # does the same after N days; this is the safety net for buckets without one. tracked = {r.upload_id for r in rows} for u in uploads: if u.upload_id not in tracked and u.age_hours > GRACE_HOURS: actions.append(f"abort upload {u.upload_id} (no row)") return actions
if __name__ == "__main__": objects = [ StoredObject("m/1", 2_000_000, 400), # a normal, committed photo StoredObject("m/2", 3_000_000, 30), # orphan: the server crashed before the insert StoredObject("m/3", 1_000_000, 2), # no row yet, but young: maybe committing now StoredObject("m/4", 5_000_000, 26), # complete, but the row never flipped to ready ] rows = [ Row(1, "m/1", "ready", 2_000_000, None, 400), Row(4, "m/4", "uploading", 5_000_000, "up-4", 26), Row(5, "m/5", "uploading", 9_000_000, "up-5", 30), # the phone gave up halfway Row(6, "m/6", "uploading", 4_000_000, "up-6", 1), # still in progress ] uploads = [Upload("m/5", "up-5", 30), Upload("m/6", "up-6", 1), Upload("m/9", "up-9", 50)] for action in reconcile(objects, rows, uploads): print(action) # delete orphan m/2 # roll forward media 4 # abort upload up-5, fail media 5 # abort upload up-9 (no row)using System;using System.Collections.Generic;using System.Linq;
// One line of the daily inventory listing, not a LIST call per key.public sealed record StoredObject(string Key, long Size, int AgeHours);
// The metadata database; only "ready" rows are visible to readers.public sealed class Row{ public Row(int id, string key, string state, long size, string? uploadId, int ageHours) { Id = id; Key = key; State = state; Size = size; UploadId = uploadId; AgeHours = ageHours; }
public int Id { get; } public string Key { get; } public string State { get; set; } // "uploading", "ready" or "failed" public long Size { get; } // what the client said it would upload public string? UploadId { get; } public int AgeHours { get; }}
// An unfinished multipart upload: invisible, but its parts are billed.public sealed record Upload(string Key, string UploadId, int AgeHours);
public static class Reconciler{ public const int GraceHours = 24; // younger objects may belong to an upload about to commit public const double MaxDeleteShare = 0.01; // over 1% of the bucket in one run means a bug
// Read the listing before the rows. Every upload writes a fresh key, so an object that is // old in the listing can only gain a row by an upload that is already old too. Content // addressed keys break this: a new commit can reuse an old object, and needs a touch guard. public static List<string> Reconcile(List<StoredObject> objects, List<Row> rows, List<Upload> uploads) { var actions = new List<string>(); var byKey = objects.ToDictionary(o => o.Key); var referenced = rows.Select(r => r.Key).ToHashSet(); var tracked = rows.Where(r => r.UploadId != null).Select(r => r.UploadId!).ToHashSet();
// 1. Orphans: bytes no row points at, typically a crash between the upload and the commit. var orphans = objects.Where(o => !referenced.Contains(o.Key) && o.AgeHours > GraceHours).ToList(); if (orphans.Count > Math.Max(10, MaxDeleteShare * objects.Count)) { // A truncated table or a bad join would make every object look orphaned. Stop and page. throw new InvalidOperationException($"{orphans.Count} orphans is too many; refusing to delete"); } actions.AddRange(orphans.Select(o => $"delete orphan {o.Key}"));
// 2. Stuck uploads: the row says uploading and nothing has happened for a day. foreach (var r in rows) { if (r.State != "uploading" || r.AgeHours <= GraceHours) continue; if (byKey.TryGetValue(r.Key, out var obj) && obj.Size == r.Size) { // The bytes made it; the crash was before the flip. Rolling forward keeps the // user's upload. (Production also checks the checksum the client declared.) r.State = "ready"; actions.Add($"roll forward media {r.Id}"); } else { r.State = "failed"; actions.Add($"abort upload {r.UploadId}, fail media {r.Id}"); } }
// 3. Multipart uploads with no row at all. A lifecycle rule (AbortIncompleteMultipartUpload) // does the same after N days; this is the safety net for buckets without one. foreach (var u in uploads) { if (!tracked.Contains(u.UploadId) && u.AgeHours > GraceHours) { actions.Add($"abort upload {u.UploadId} (no row)"); } } return actions; }}
public static class Program{ public static void Main() { var objects = new List<StoredObject> { new("m/1", 2_000_000, 400), // a normal, committed photo new("m/2", 3_000_000, 30), // orphan: the server crashed before the insert new("m/3", 1_000_000, 2), // no row yet, but young: maybe committing now new("m/4", 5_000_000, 26), // complete, but the row never flipped to ready }; var rows = new List<Row> { new(1, "m/1", "ready", 2_000_000, null, 400), new(4, "m/4", "uploading", 5_000_000, "up-4", 26), new(5, "m/5", "uploading", 9_000_000, "up-5", 30), // the phone gave up halfway new(6, "m/6", "uploading", 4_000_000, "up-6", 1), // still in progress }; var uploads = new List<Upload> { new("m/5", "up-5", 30), new("m/6", "up-6", 1), new("m/9", "up-9", 50) }; foreach (var action in Reconciler.Reconcile(objects, rows, uploads)) Console.WriteLine(action); // delete orphan m/2 // roll forward media 4 // abort upload up-5, fail media 5 // abort upload up-9 (no row) }}package main
import ( "fmt" "math")
const ( graceHours = 24 // younger objects may belong to an upload that is about to commit maxDeleteShare = 0.01 // deleting more than 1% of the bucket in one run means a bug, not garbage)
// StoredObject is one line of the daily inventory listing, not a LIST call per key.type StoredObject struct { Key string Size int64 AgeHours int}
// Row lives in the metadata database; only "ready" rows are visible to readers.type Row struct { ID int Key string State string // "uploading", "ready" or "failed" Size int64 // what the client said it would upload UploadID string AgeHours int}
// Upload is an unfinished multipart upload: invisible, but its parts are billed.type Upload struct { Key string UploadID string AgeHours int}
// Reconcile reads the listing before the rows. Every upload writes a fresh key, so an object// that is old in the listing can only gain a row by an upload that is already old too. Content// addressed keys break this: a new commit can reuse an old object, and needs a touch guard.func Reconcile(objects []StoredObject, rows []*Row, uploads []Upload) ([]string, error) { var actions []string byKey := map[string]StoredObject{} for _, o := range objects { byKey[o.Key] = o } referenced := map[string]bool{} tracked := map[string]bool{} for _, r := range rows { referenced[r.Key] = true tracked[r.UploadID] = true }
// 1. Orphans: bytes no row points at, typically a crash between the upload and the commit. var orphans []StoredObject for _, o := range objects { if !referenced[o.Key] && o.AgeHours > graceHours { orphans = append(orphans, o) } } if float64(len(orphans)) > math.Max(10, maxDeleteShare*float64(len(objects))) { // A truncated table or a bad join would make every object look orphaned. Stop and page. return nil, fmt.Errorf("%d orphans is too many; refusing to delete", len(orphans)) } for _, o := range orphans { actions = append(actions, "delete orphan "+o.Key) }
// 2. Stuck uploads: the row says uploading and nothing has happened for a day. for _, r := range rows { if r.State != "uploading" || r.AgeHours <= graceHours { continue } if obj, ok := byKey[r.Key]; ok && obj.Size == r.Size { // The bytes made it; the crash was before the flip. Rolling forward keeps the // user's upload. (Production also checks the checksum the client declared.) r.State = "ready" actions = append(actions, fmt.Sprintf("roll forward media %d", r.ID)) } else { r.State = "failed" actions = append(actions, fmt.Sprintf("abort upload %s, fail media %d", r.UploadID, r.ID)) } }
// 3. Multipart uploads with no row at all. A lifecycle rule (AbortIncompleteMultipartUpload) // does the same after N days; this is the safety net for buckets without one. for _, u := range uploads { if !tracked[u.UploadID] && u.AgeHours > graceHours { actions = append(actions, fmt.Sprintf("abort upload %s (no row)", u.UploadID)) } } return actions, nil}
func main() { objects := []StoredObject{ {"m/1", 2_000_000, 400}, // a normal, committed photo {"m/2", 3_000_000, 30}, // orphan: the server crashed before the insert {"m/3", 1_000_000, 2}, // no row yet, but young: maybe committing now {"m/4", 5_000_000, 26}, // complete, but the row never flipped to ready } rows := []*Row{ {1, "m/1", "ready", 2_000_000, "", 400}, {4, "m/4", "uploading", 5_000_000, "up-4", 26}, {5, "m/5", "uploading", 9_000_000, "up-5", 30}, // the phone gave up halfway {6, "m/6", "uploading", 4_000_000, "up-6", 1}, // still in progress } uploads := []Upload{{"m/5", "up-5", 30}, {"m/6", "up-6", 1}, {"m/9", "up-9", 50}} actions, err := Reconcile(objects, rows, uploads) if err != nil { panic(err) } for _, a := range actions { fmt.Println(a) } // delete orphan m/2 // roll forward media 4 // abort upload up-5, fail media 5 // abort upload up-9 (no row)}const GRACE_HOURS = 24; // younger objects may belong to an upload that is about to commitconst MAX_DELETE_SHARE = 0.01; // deleting more than 1% of the bucket in one run means a bug, not garbage
// One line of the daily inventory listing, not a LIST call per key.type StoredObject = { key: string; size: number; ageHours: number };
// The metadata database; only "ready" rows are visible to readers.type Row = { id: number; key: string; state: 'uploading' | 'ready' | 'failed'; size: number; // what the client said it would upload uploadId: string | null; ageHours: number;};
// An unfinished multipart upload: invisible, but its parts are billed.type Upload = { key: string; uploadId: string; ageHours: number };
// Read the listing before the rows. Every upload writes a fresh key, so an object that is old// in the listing can only gain a row by an upload that is already old too. Content addressed// keys break this: a new commit can reuse an old object, and needs a touch guard.function reconcile(objects: StoredObject[], rows: Row[], uploads: Upload[]): string[] { const actions: string[] = []; const byKey = new Map(objects.map((o) => [o.key, o])); const referenced = new Set(rows.map((r) => r.key)); const tracked = new Set(rows.map((r) => r.uploadId));
// 1. Orphans: bytes no row points at, typically a crash between the upload and the commit. const orphans = objects.filter((o) => !referenced.has(o.key) && o.ageHours > GRACE_HOURS); if (orphans.length > Math.max(10, MAX_DELETE_SHARE * objects.length)) { // A truncated table or a bad join would make every object look orphaned. Stop and page. throw new Error(`${orphans.length} orphans is too many; refusing to delete`); } for (const o of orphans) actions.push(`delete orphan ${o.key}`);
// 2. Stuck uploads: the row says uploading and nothing has happened for a day. for (const r of rows) { if (r.state !== 'uploading' || r.ageHours <= GRACE_HOURS) continue; const obj = byKey.get(r.key); if (obj && obj.size === r.size) { // The bytes made it; the crash was before the flip. Rolling forward keeps the user's // upload. (Production also checks the checksum the client declared.) r.state = 'ready'; actions.push(`roll forward media ${r.id}`); } else { r.state = 'failed'; actions.push(`abort upload ${r.uploadId}, fail media ${r.id}`); } }
// 3. Multipart uploads with no row at all. A lifecycle rule (AbortIncompleteMultipartUpload) // does the same after N days; this is the safety net for buckets without one. for (const u of uploads) { if (!tracked.has(u.uploadId) && u.ageHours > GRACE_HOURS) actions.push(`abort upload ${u.uploadId} (no row)`); } return actions;}
const objects: StoredObject[] = [ { key: 'm/1', size: 2_000_000, ageHours: 400 }, // a normal, committed photo { key: 'm/2', size: 3_000_000, ageHours: 30 }, // orphan: the server crashed before the insert { key: 'm/3', size: 1_000_000, ageHours: 2 }, // no row yet, but young: maybe committing now { key: 'm/4', size: 5_000_000, ageHours: 26 }, // complete, but the row never flipped to ready];const rows: Row[] = [ { id: 1, key: 'm/1', state: 'ready', size: 2_000_000, uploadId: null, ageHours: 400 }, { id: 4, key: 'm/4', state: 'uploading', size: 5_000_000, uploadId: 'up-4', ageHours: 26 }, { id: 5, key: 'm/5', state: 'uploading', size: 9_000_000, uploadId: 'up-5', ageHours: 30 }, // the phone gave up halfway { id: 6, key: 'm/6', state: 'uploading', size: 4_000_000, uploadId: 'up-6', ageHours: 1 }, // still in progress];const uploads: Upload[] = [ { key: 'm/5', uploadId: 'up-5', ageHours: 30 }, { key: 'm/6', uploadId: 'up-6', ageHours: 1 }, { key: 'm/9', uploadId: 'up-9', ageHours: 50 },];for (const action of reconcile(objects, rows, uploads)) console.log(action);// delete orphan m/2// roll forward media 4// abort upload up-5, fail media 5// abort upload up-9 (no row)
export {}; // a module, so these names do not clash with other snippetsStep 5: Bytes skip your servers: presigned URLs
Section titled “Step 5: Bytes skip your servers: presigned URLs”The bytes need not pass through your API at all. A presigned URL is a URL to one object, for one method, until one expiry time, signed with your
credentials. The API checks that the user may upload, creates the row, signs a URL and returns it.
The client then PUTs straight to the store, and the store checks the signature. App servers
handle a 1 KB request instead of a 2 MB stream, and upload bandwidth becomes the store’s problem.
- Color key
- Database
- Service
A signature binds exactly what was signed, and the holder chooses everything else, so what you sign is the security design:
- Method and key, always. A PUT URL cannot read, and cannot write anywhere else.
- Content type, so a URL for
image/jpegcannot uploadtext/htmlthat your domain would then serve to other users. - A checksum (S3 accepts
x-amz-checksum-sha256), so the bytes that arrive are the bytes the client declared. - Size. A presigned
PUTcannot express a maximum: it can at most pin one exactContent-Lengthif you sign that header. S3’s presigned POST policy can set a range (content-length-range). Either way, check the size with aHEADbefore marking the row ready, and delete what is too big. - Expiry, in minutes, not days. S3 caps presigned URLs at 7 days, and a URL signed with temporary credentials dies when they do. A leaked URL is a capability until it expires.
A presigned GET, valid for minutes, serves private downloads the same way. The code shows the idea
in simplified form. S3’s real scheme, SigV4, adds a canonical request format and derived keys.
import java.net.URI;import java.nio.charset.StandardCharsets;import java.security.MessageDigest;import java.util.HashMap;import java.util.HexFormat;import java.util.Map;import java.util.regex.Pattern;import javax.crypto.Mac;import javax.crypto.spec.SecretKeySpec;
class Presigner { // Never leaves the server. The client only ever sees signatures made with it. (S3's SigV4 // goes further and derives a key per day, region and service, so a leaked key has a short life.) private static final byte[] SECRET = "server-side-secret".getBytes(StandardCharsets.UTF_8); static final long MAX_TTL = 7 * 24 * 3600; // S3 refuses presigned URLs longer than 7 days private static final Pattern SAFE_KEY = Pattern.compile("[a-z0-9/._-]+"); // server-picked keys stay URL-safe
// Everything the server wants to enforce goes into the signed string. Whatever is left out, // the holder of the URL chooses freely. private static String canonical(String method, String path, long expires, String contentType) { return method + "\n" + path + "\n" + expires + "\n" + contentType; }
private static String signature(String method, String path, long expires, String contentType) { try { Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(SECRET, "HmacSHA256")); byte[] raw = mac.doFinal(canonical(method, path, expires, contentType).getBytes(StandardCharsets.UTF_8)); return HexFormat.of().formatHex(raw); } catch (Exception e) { throw new IllegalStateException(e); } }
static String sign(String method, String bucket, String key, long now, long ttl, String contentType) { if (ttl > MAX_TTL || !SAFE_KEY.matcher(key).matches()) { throw new IllegalArgumentException("refusing to sign"); } String path = "/" + bucket + "/" + key; long expires = now + ttl; return "https://blob.example.com" + path + "?expires=" + expires + "&sig=" + signature(method, path, expires, contentType); }
// Runs on every request at the storage side. It needs no database: the URL carries // everything, which is why one signer can hand out millions of URLs a second. static boolean verify(String method, String url, String contentType, long now) { URI uri = URI.create(url); Map<String, String> query = new HashMap<>(); if (uri.getRawQuery() != null) { for (String pair : uri.getRawQuery().split("&")) { String[] kv = pair.split("=", 2); if (kv.length == 2) query.put(kv[0], kv[1]); } } long expires; try { expires = Long.parseLong(query.getOrDefault("expires", "")); } catch (NumberFormatException e) { return false; } String sig = query.get("sig"); if (sig == null || now >= expires) { return false; // an expired URL is dead, whatever else it says } String want = signature(method, uri.getRawPath(), expires, contentType); // Constant-time compare, so response timing does not leak the signature byte by byte. return MessageDigest.isEqual(want.getBytes(StandardCharsets.UTF_8), sig.getBytes(StandardCharsets.UTF_8)); }}
class Main { public static void main(String[] args) { long now = 1_700_000_000L; String url = Presigner.sign("PUT", "media", "uploads/u42/7f3a.jpg", now, 900, "image/jpeg"); System.out.println(url); System.out.println(Presigner.verify("PUT", url, "image/jpeg", now + 60)); // true System.out.println(Presigner.verify("GET", url, "image/jpeg", now + 60)); // false: signed for PUT only System.out.println(Presigner.verify("PUT", url.replace("u42", "u43"), "image/jpeg", now + 60)); // false: key is signed System.out.println(Presigner.verify("PUT", url, "text/html", now + 60)); // false: no HTML served from our domain System.out.println(Presigner.verify("PUT", url, "image/jpeg", now + 901)); // false: expired after 15 minutes }}import hashlibimport hmacimport refrom urllib.parse import parse_qs, urlsplit
# Never leaves the server. The client only ever sees signatures made with it. (S3's SigV4 goes# further and derives a key per day, region and service, so a leaked key has a short life.)SECRET = b"server-side-secret"MAX_TTL = 7 * 24 * 3600 # S3 refuses presigned URLs longer than 7 days; minutes is betterSAFE_KEY = re.compile(r"[a-z0-9/._-]+") # the server picks keys, so it can keep them URL-safe
def canonical(method: str, path: str, expires: int, content_type: str) -> bytes: # Everything the server wants to enforce goes into the signed string. Whatever is left out, # the holder of the URL chooses freely. return f"{method}\n{path}\n{expires}\n{content_type}".encode()
def signature(method: str, path: str, expires: int, content_type: str) -> str: return hmac.new(SECRET, canonical(method, path, expires, content_type), hashlib.sha256).hexdigest()
def sign(method: str, bucket: str, key: str, now: int, ttl: int, content_type: str = "") -> str: if ttl > MAX_TTL or not SAFE_KEY.fullmatch(key): raise ValueError("refusing to sign") path = f"/{bucket}/{key}" expires = now + ttl sig = signature(method, path, expires, content_type) return f"https://blob.example.com{path}?expires={expires}&sig={sig}"
def verify(method: str, url: str, content_type: str, now: int) -> bool: # The storage side runs this on every request. It needs no database: the URL carries # everything, which is why one signer can hand out millions of URLs a second. parts = urlsplit(url) query = parse_qs(parts.query) try: expires = int(query["expires"][0]) sig = query["sig"][0] except (KeyError, ValueError): return False if now >= expires: return False # an expired URL is dead, whatever else it says want = signature(method, parts.path, expires, content_type) # Constant-time compare, so response timing does not leak the signature byte by byte. return hmac.compare_digest(want, sig)
if __name__ == "__main__": now = 1_700_000_000 url = sign("PUT", "media", "uploads/u42/7f3a.jpg", now, 900, "image/jpeg") print(url) print(verify("PUT", url, "image/jpeg", now + 60)) # True print(verify("GET", url, "image/jpeg", now + 60)) # False: signed for PUT only print(verify("PUT", url.replace("u42", "u43"), "image/jpeg", now + 60)) # False: key is signed print(verify("PUT", url, "text/html", now + 60)) # False: no HTML served from our domain print(verify("PUT", url, "image/jpeg", now + 901)) # False: expired after 15 minutesusing System;using System.Collections.Generic;using System.Security.Cryptography;using System.Text;using System.Text.RegularExpressions;
public static class Presigner{ // Never leaves the server. The client only ever sees signatures made with it. (S3's SigV4 // goes further and derives a key per day, region and service, so a leaked key has a short life.) private static readonly byte[] Secret = Encoding.UTF8.GetBytes("server-side-secret"); public const long MaxTtl = 7 * 24 * 3600; // S3 refuses presigned URLs longer than 7 days private static readonly Regex SafeKey = new("^[a-z0-9/._-]+$"); // server-picked keys stay URL-safe
// Everything the server wants to enforce goes into the signed string. Whatever is left out, // the holder of the URL chooses freely. private static string Canonical(string method, string path, long expires, string contentType) => $"{method}\n{path}\n{expires}\n{contentType}";
private static string Signature(string method, string path, long expires, string contentType) { var raw = HMACSHA256.HashData(Secret, Encoding.UTF8.GetBytes(Canonical(method, path, expires, contentType))); return Convert.ToHexString(raw).ToLowerInvariant(); }
public static string Sign(string method, string bucket, string key, long now, long ttl, string contentType = "") { if (ttl > MaxTtl || !SafeKey.IsMatch(key)) { throw new ArgumentException("refusing to sign"); } var path = $"/{bucket}/{key}"; var expires = now + ttl; return $"https://blob.example.com{path}?expires={expires}&sig={Signature(method, path, expires, contentType)}"; }
// Runs on every request at the storage side. It needs no database: the URL carries // everything, which is why one signer can hand out millions of URLs a second. public static bool Verify(string method, string url, string contentType, long now) { var uri = new Uri(url); var query = new Dictionary<string, string>(); foreach (var pair in uri.Query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries)) { var kv = pair.Split('=', 2); if (kv.Length == 2) query[kv[0]] = kv[1]; } if (!query.TryGetValue("expires", out var e) || !long.TryParse(e, out var expires) || !query.TryGetValue("sig", out var sig)) { return false; } if (now >= expires) { return false; // an expired URL is dead, whatever else it says } var want = Signature(method, uri.AbsolutePath, expires, contentType); // Constant-time compare, so response timing does not leak the signature byte by byte. return CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(want), Encoding.UTF8.GetBytes(sig)); }}
public static class Program{ public static void Main() { long now = 1_700_000_000; var url = Presigner.Sign("PUT", "media", "uploads/u42/7f3a.jpg", now, 900, "image/jpeg"); Console.WriteLine(url); Console.WriteLine(Presigner.Verify("PUT", url, "image/jpeg", now + 60)); // True Console.WriteLine(Presigner.Verify("GET", url, "image/jpeg", now + 60)); // False: signed for PUT only Console.WriteLine(Presigner.Verify("PUT", url.Replace("u42", "u43"), "image/jpeg", now + 60)); // False: key is signed Console.WriteLine(Presigner.Verify("PUT", url, "text/html", now + 60)); // False: no HTML served from our domain Console.WriteLine(Presigner.Verify("PUT", url, "image/jpeg", now + 901)); // False: expired after 15 minutes }}package main
import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "errors" "fmt" "net/url" "regexp" "strconv" "strings")
// Never leaves the server. The client only ever sees signatures made with it. (S3's SigV4 goes// further and derives a key per day, region and service, so a leaked key has a short life.)var secret = []byte("server-side-secret")
const maxTTL = 7 * 24 * 3600 // S3 refuses presigned URLs longer than 7 days; minutes is better
var safeKey = regexp.MustCompile(`^[a-z0-9/._-]+$`) // the server picks keys, so it can keep them URL-safe
// canonical holds everything the server wants to enforce. Whatever is left out, the holder of// the URL chooses freely.func canonical(method, path string, expires int64, contentType string) string { return fmt.Sprintf("%s\n%s\n%d\n%s", method, path, expires, contentType)}
func signature(method, path string, expires int64, contentType string) string { mac := hmac.New(sha256.New, secret) mac.Write([]byte(canonical(method, path, expires, contentType))) return hex.EncodeToString(mac.Sum(nil))}
func Sign(method, bucket, key string, now, ttl int64, contentType string) (string, error) { if ttl > maxTTL || !safeKey.MatchString(key) { return "", errors.New("refusing to sign") } path := "/" + bucket + "/" + key expires := now + ttl sig := signature(method, path, expires, contentType) return fmt.Sprintf("https://blob.example.com%s?expires=%d&sig=%s", path, expires, sig), nil}
// Verify runs on every request at the storage side. It needs no database: the URL carries// everything, which is why one signer can hand out millions of URLs a second.func Verify(method, rawURL, contentType string, now int64) bool { u, err := url.Parse(rawURL) if err != nil { return false } q := u.Query() expires, err := strconv.ParseInt(q.Get("expires"), 10, 64) if err != nil || q.Get("sig") == "" { return false } if now >= expires { return false // an expired URL is dead, whatever else it says } want := signature(method, u.Path, expires, contentType) // Constant-time compare, so response timing does not leak the signature byte by byte. return hmac.Equal([]byte(want), []byte(q.Get("sig")))}
func main() { var now int64 = 1_700_000_000 u, err := Sign("PUT", "media", "uploads/u42/7f3a.jpg", now, 900, "image/jpeg") if err != nil { panic(err) } fmt.Println(u) fmt.Println(Verify("PUT", u, "image/jpeg", now+60)) // true fmt.Println(Verify("GET", u, "image/jpeg", now+60)) // false: signed for PUT only fmt.Println(Verify("PUT", strings.Replace(u, "u42", "u43", 1), "image/jpeg", now+60)) // false: key is signed fmt.Println(Verify("PUT", u, "text/html", now+60)) // false: no HTML served from our domain fmt.Println(Verify("PUT", u, "image/jpeg", now+901)) // false: expired after 15 minutes}import { createHmac, timingSafeEqual } from 'node:crypto';
// Never leaves the server. The client only ever sees signatures made with it. (S3's SigV4 goes// further and derives a key per day, region and service, so a leaked key has a short life.)const SECRET = 'server-side-secret';const MAX_TTL = 7 * 24 * 3600; // S3 refuses presigned URLs longer than 7 days; minutes is betterconst SAFE_KEY = /^[a-z0-9/._-]+$/; // the server picks keys, so it can keep them URL-safe
// Everything the server wants to enforce goes into the signed string. Whatever is left out,// the holder of the URL chooses freely.function canonical(method: string, path: string, expires: number, contentType: string): string { return `${method}\n${path}\n${expires}\n${contentType}`;}
function signature(method: string, path: string, expires: number, contentType: string): string { return createHmac('sha256', SECRET).update(canonical(method, path, expires, contentType)).digest('hex');}
function sign(method: string, bucket: string, key: string, now: number, ttl: number, contentType = ''): string { if (ttl > MAX_TTL || !SAFE_KEY.test(key)) throw new Error('refusing to sign'); const path = `/${bucket}/${key}`; const expires = now + ttl; return `https://blob.example.com${path}?expires=${expires}&sig=${signature(method, path, expires, contentType)}`;}
// Runs on every request at the storage side. It needs no database: the URL carries// everything, which is why one signer can hand out millions of URLs a second.function verify(method: string, url: string, contentType: string, now: number): boolean { const u = new URL(url); const expires = Number(u.searchParams.get('expires')); const sig = u.searchParams.get('sig'); if (!Number.isInteger(expires) || !sig) return false; if (now >= expires) return false; // an expired URL is dead, whatever else it says const want = Buffer.from(signature(method, u.pathname, expires, contentType)); const got = Buffer.from(sig); // Constant-time compare, so response timing does not leak the signature byte by byte. return want.length === got.length && timingSafeEqual(want, got);}
const now = 1_700_000_000;const url = sign('PUT', 'media', 'uploads/u42/7f3a.jpg', now, 900, 'image/jpeg');console.log(url);console.log(verify('PUT', url, 'image/jpeg', now + 60)); // trueconsole.log(verify('GET', url, 'image/jpeg', now + 60)); // false: signed for PUT onlyconsole.log(verify('PUT', url.replace('u42', 'u43'), 'image/jpeg', now + 60)); // false: key is signedconsole.log(verify('PUT', url, 'text/html', now + 60)); // false: no HTML served from our domainconsole.log(verify('PUT', url, 'image/jpeg', now + 901)); // false: expired after 15 minutesStep 6: Big files, multipart and resumable uploads
Section titled “Step 6: Big files, multipart and resumable uploads”A single PUT is all or nothing. Say a phone uploading a 2 GB video loses signal at 1.9 GB. It
starts again from zero. And S3 refuses single PUTs over 5 GB anyway. Multipart upload splits
the object into parts:
CreateMultipartUploadreturns an upload id.- The client
PUTs the parts. Each is 5 MB to 5 GB (only the last may be smaller), with up to 10,000 parts. It sends several at a time, in any order. Each part gets its own presigned URL, and each returns an ETag. CompleteMultipartUpload, with the list of part numbers and ETags, stitches them into one object atomically. Before it,GETreturns 404. After it,GETreturns the whole object.- If the connection drops,
ListPartstells the client which parts the store already has, and it sends only the rest.
Part size is a real decision. Smaller parts lose less on a dropped connection. Bigger parts mean fewer requests. The 10,000 part limit sets a floor: a 100 GB file needs parts of at least 10 MB. Mobile clients commonly use 5 to 16 MB parts, and servers 64 to 128 MB. Parallel parts also lift throughput, because one TCP connection to S3 tops out around 50 to 100 MB/s.
Try it below. Start the upload, drop the connection after a few parts, then resume. Watch only the missing parts go. Switch to Row first to see what viewers get meanwhile. Turn on the crash to compare what each order leaves behind. Then skip ahead 7 days, to see what the lifecycle rule and the reconciler clean up, and what they cannot.
Phone
64 MB video, 8 parts of 8 MB, 2 at a time
On the wire: 0 MB
Object store
- Multipart upload
- none
- Object m/42.mp4
- does not exist
- Billed storage
- 0 MB
Metadata row
- media 42
- no row
- object_key
- -
- upload_id
- -
Pick a write order, then start the upload.
Two details matter in production. First, unfinished multipart uploads are invisible, and billed
forever. So every bucket that accepts them needs the lifecycle rule AbortIncompleteMultipartUpload
(7 days is common). Second, a multipart object’s ETag is not the MD5 of its bytes. It’s a hash of
the part hashes, with -N appended. So integrity checks need an explicit checksum.
Go deeperResumable sessions instead of multipart1 min
Multipart is resumable because the client keeps the state: the upload id, and the parts done. Other protocols put the state on the server. A Google Cloud Storage resumable upload is one session URL. It accepts chunks, and answers “how many bytes do you have” with a byte offset. tus is an open protocol for the same thing, over plain HTTP. Either way, the client can lose its connection, its app process or its battery, and continue from the last confirmed byte.
Step 7: Reads: byte ranges and a CDN
Section titled “Step 7: Reads: byte ranges and a CDN”An object store answers a GET in tens of milliseconds to the first byte. It supports
byte-range GETs (Range: bytes=0-8388607). So a downloader can fetch 8 to 16 MB ranges in
parallel. And a Parquet reader can fetch only the footer and the columns it needs.
For anything public or popular, put a CDN in front. It caches objects 10 to 40 ms from users. And S3 to CloudFront transfer is free, while S3’s internet egress starts around $0.09 per GB. Three practices make it work:
- Immutable keys, cached forever with
Cache-Control: public, max-age=31536000, immutable. A new version is a new key, so nothing ever needs purging. - The bucket stays private. Only the CDN’s own identity can read it (CloudFront’s origin access control, for example). Private content uses short-lived signed CDN URLs or cookies.
- An origin shield, a regional cache tier that turns a miss at 200 edges into one origin read.
Step 8: Content addressing and dedup
Section titled “Step 8: Content addressing and dedup”So far keys are random IDs. A different choice names each object by the hash of its content,
such as its SHA-256. Git objects, container image layers (sha256:... digests), backup tools and
the file sync problem all do this.
The properties fall out of the name. The same content always gets the same key, so a second upload of an identical file is a no-op: dedup for free. The key is a checksum, so a reader can verify what it got. And content at a key can never change, so it caches forever. File sync goes further and hashes chunks of about a megabyte, so editing one cell of a 200 MB spreadsheet uploads one chunk.
What it costs: deletion becomes reference counting, since two users can point at one object. So a mark-and-sweep collector must handle a race, where a new upload reuses an object it is about to delete.
And dedup leaks. Say “I already have this hash” skips the upload. Then a hash becomes proof that you have someone else’s file. Only skip uploads of blocks the same user already holds. And verify the hash of what arrives, instead of trusting the client’s.
Small objects need one more trick. A billion 1 KB thumbnails is 1 TB. It costs $23 a month to store, and $5,000 in PUT requests to write. Large systems pack small blobs into big segment files, and store (segment, offset, length) in the metadata. Facebook’s Haystack did this for photos, and web crawlers do it with 1 GB WARC files.
Options and tradeoffs
Section titled “Options and tradeoffs”Where the bytes live
Section titled “Where the bytes live”| Option | Wins when | Loses |
|---|---|---|
| In the database row | Blobs are small (under about 100 KB), few, and must commit atomically with the row | Cost per GB, backup and failover times, buffer pool pollution |
| Network filesystem (NFS, cloud files) | Legacy software that needs POSIX paths, shared scratch space | Scaling, cost, semantics nobody needed |
| Object store, one key per blob | Almost everything: media, documents, backups, logs | Two stores to keep consistent, per-request costs, tens of ms latency |
| Object store, packed segments | Billions of tiny objects, append-heavy data (crawls, log chunks) | Deletes need compaction, you build an index and a small storage engine |
An inline threshold is a reasonable refinement. A pastebin can keep bodies under a few KB in the row, and put the rest in the store. The price is two read and delete paths.
How uploads reach the store
Section titled “How uploads reach the store”| Upload path | Wins when | Costs |
|---|---|---|
| Through the API server | Bytes must be inspected or transformed in line, small files only | App servers carry every byte and every slow client |
Presigned single PUT |
Files up to tens of MB on decent networks: avatars, documents | Restart from zero on failure, no size cap on the URL |
| Presigned multipart | Large files, mobile clients, anything over about 100 MB | Part tracking, a complete call, an abort lifecycle rule |
| Resumable session (GCS, tus) | Very unreliable networks, clients that restart, browser uploads | Server-side session state, a protocol to implement on both ends |
Storage classes and lifecycle
Section titled “Storage classes and lifecycle”Reads skew heavily by age: a photo gets most of its views in its first days. Storage classes trade storage price for retrieval price and latency, and lifecycle rules move objects between them by age automatically. Approximate S3 list prices in us-east-1 (they change, and fall slightly at volume):
| Class | Storage, per TB-month | Retrieval | First byte | Minimum stay | Fits |
|---|---|---|---|---|---|
| Standard | $23 | Free | Tens of ms | None | New, hot and unknown data |
| Standard-IA | $12.50 | $10 per TB | Tens of ms | 30 days | Read about once a month: older media |
| One Zone-IA | $10 | $10 per TB | Tens of ms | 30 days | Re-creatable data (thumbnails you can rerender) |
| Glacier Instant Retrieval | $4 | $30 per TB | Tens of ms | 90 days | Read a few times a year, but instantly |
| Glacier Flexible Retrieval | $3.60 | Varies | Minutes to 12 hours | 90 days | Backups, compliance copies |
| Glacier Deep Archive | About $1 | Varies | Within 12 to 48 hours | 180 days | Legal holds, data you hope never to read |
The shape matters more than the exact prices. Down the ladder, storage falls from $23 to about $1 per TB-month. Each step down adds a retrieval fee and a longer minimum duration. At the bottom, it adds hours of latency.
Deleting early still bills the minimum. The infrequent access and Glacier Instant classes bill objects under 128 KB as 128 KB. And every transition is a billed request per object. So tiering a billion small thumbnails can cost more than it saves.
A worked example. 1 PB of photos, where 20% is younger than 30 days, 30% is 30 to 90 days old and 50% older:
- Color key
- Database
- Failure
| Layout | Monthly storage bill |
|---|---|
| All 1 PB in Standard | About $23,000 |
| 200 TB Standard, 300 TB IA, 500 TB Glacier IR | About $4,600 + $3,750 + $2,000 = $10,350 |
That halves the bill, as long as the old photos really are rarely read. Reading back 100 TB a month from Glacier Instant Retrieval costs $3,000 in retrieval fees. When nobody knows the access pattern, use Intelligent-Tiering. It moves each object between tiers by its own access history, for a small monitoring fee per object. (Objects under 128 KB are not tiered.)
Which object store
Section titled “Which object store”Rent one, unless the bill or the law says otherwise. At hundreds of PB, the provider’s margin outgrows the cost of a storage team. Dropbox moved most of its data off S3 to its own store in 2016, as the file sync problem discusses.
Go deeperManaged, zero-egress or self-hosted1 min
| Option | Wins when | Watch out for |
|---|---|---|
| S3, GCS, Azure Blob | The default: you run on that cloud, need the ecosystem (events, lifecycle, IAM) | Egress fees, per-request fees, lock-in to one API’s extras |
| Zero-egress stores (Cloudflare R2 and others) | Read-heavy public content served to the internet | Fewer storage classes and integrations; check the request pricing |
| Self-hosted (Ceph, MinIO and similar) | On-premises, data sovereignty, or exabyte scale where the margin is the bill | You now run durability, repair, scrubbing and capacity planning yourself |
How it fails
Section titled “How it fails”Orphans, dangling rows and partial uploads
Section titled “Orphans, dangling rows and partial uploads”The two-store seam from step 4 fails four ways:
- A dangling row: metadata committed first, or an object deleted before its row.
- An orphan object.
- A stuck pending row.
- An abandoned multipart upload.
Only the first hurts users. So prevent it with the write order, and delete in reverse: hide the row first, and delete the object later. The reconciler, a sweeper and the abort rule handle the other three.
Event-driven processing adds one more. S3 event notifications are delivered at least once. They usually arrive within seconds, but sometimes later, and not always in order. So a thumbnail worker must be idempotent. (The Instagram pipeline claims each media row with a state check.) And the reconciler is the backstop for an event that never arrives.
Hot objects and 503 Slow Down
Section titled “Hot objects and 503 Slow Down”S3 splits a busy prefix automatically, but the split takes minutes, and a sudden burst past about
3,500 writes a second gets 503 Slow Down meanwhile. Writes clustered under one prefix
(logs/2026-10-28/... from every server) hit this first, so put a high-cardinality part early in
the key. A single object cannot be split at all: a viral video read 50,000 times a second is
throttled and billed for egress. A CDN with an origin shield turns those reads into a handful, as
in the pastebin
problem’s viral paste.
Accidental public buckets
Section titled “Accidental public buckets”Misconfigured public buckets are a recurring cause of large data leaks. A typical case: a read-for-everyone policy, set once for a static site, on a bucket that later receives user documents. Three defenses:
- Turn on Block Public Access at the account level. It’s the default for new S3 buckets since April 2023, but not for older ones.
- Keep one purpose per bucket, so public assets never share a bucket with user data.
- Run configuration scanning that pages on any public grant.
Unguessable keys are not access control. A random key is found the first time its URL is shared or logged.
Deletes, overwrites and versioning
Section titled “Deletes, overwrites and versioning”A buggy cleanup job, a script run against the wrong bucket, or ransomware with stolen credentials erases data with full eleven-nines reliability. The defenses:
- Versioning: a
DELETEadds a delete marker and the old version can be restored. Old versions are billed, so expire them after 30 to 90 days with a lifecycle rule. - Object Lock (write once, read many) blocks deletion for a retention period, even by the account root in compliance mode. Use it for backups and audit logs.
- A copy in another account, with separate credentials, survives the first one’s compromise.
- Circuit breakers in anything that deletes in bulk, like the reconciler in step 4.
Go deeperEvery failure mode at a glance1 min
| Failure | What breaks | How you see it | Blast radius | Fix |
|---|---|---|---|---|
| Metadata committed first | Rows point at missing objects | Broken-image reports, inventory join | Every upload that failed midway | Bytes first or a pending row; reconciler |
| No abort lifecycle rule | Parts of abandoned uploads billed forever | Storage Lens or bill vs LIST total |
Cost only, grows quietly | AbortIncompleteMultipartUpload after 7 days |
| Event lost or duplicated | Thumbnails missing, or processed twice | Rows stuck in uploaded, duplicate work counts |
Individual uploads | Idempotent workers, sweeper for stuck rows |
| Burst on one prefix | 503 Slow Down on writes |
503 rate per prefix | Writes under that prefix | Spread keys, retry with backoff and jitter |
| Viral object | Throttled reads and a large egress bill | Per-key request rate, egress spike | One object, the bill | CDN with origin shield |
| Public bucket | User data readable by anyone | Config scanning, access logs from unknown IPs | Everything in the bucket | Block Public Access, one purpose per bucket |
| Mistaken or malicious delete | Data gone, correctly and durably | Delete rate alarms | Up to the whole bucket | Versioning, Object Lock, copy in another account |
| Billions of tiny objects | Request fees dwarf storage, lifecycle costs more than it saves | PUT and transition request line items | The bill | Pack small blobs into segments, keep them in Standard |
At scale
Section titled “At scale”| Number | Value |
|---|---|
| Time to first byte, same region | About 10 to 100 ms; single-digit ms on low-latency classes (S3 Express One Zone) |
| Throughput per connection | About 50 to 100 MB/s; parallel ranges or parts for more |
| Request rate per prefix partition | About 3,500 writes and 5,500 reads a second |
| Largest single PUT; multipart parts | 5 GB; 5 MB to 5 GB each, up to 10,000 |
| Requests | About $5 per million PUTs, $0.40 per million GETs |
| Internet egress from the bucket | About $90 per TB at low volume, less with a CDN and at scale |
At 10 times the traffic. The store scales without you, so the pressure moves to its edges. Uploads move fully to presigned multipart. Reads move behind a CDN, because egress becomes the largest line on the bill. Write-heavy keys get a high-cardinality prefix. The reconciler moves to daily inventory reports. And lifecycle rules start to matter, as storage grows by hundreds of TB a year.
At 100 times. Cost becomes the design. Small objects get packed into segments, tiering follows measured access, renditions of cold media are re-rendered on demand, and other regions get only the originals. At hundreds of PB a self-built, erasure-coded store (f4 at Facebook, Magic Pocket at Dropbox) beats the provider’s price, and the team now owns durability.
What to monitor. Bytes stored and object count per bucket and class, against the metadata
table’s totals (a growing gap means orphans or dangling rows). Bytes held in incomplete multipart
uploads. 4xx and 5xx rates per operation, with 503s per prefix. p99 time to first byte. CDN hit
ratio and origin egress. Rows stuck in uploading past an hour. Delete rates, with an alarm on any
spike. And any bucket policy change that grants public access.
In the interview
Section titled “In the interview”Interviewer asksUsers upload videos up to 2 GB from phones on bad networks. Walk me through the upload path.
The client asks the API to start an upload. The API checks quota, and inserts a row in state uploading with a fresh key. It calls CreateMultipartUpload, and returns the upload id with presigned URLs for, say, 8 MB parts. The phone PUTs parts directly to the object store, four at a time. If the connection drops, it asks which parts arrived (ListParts), and sends only the rest. When all parts are in, the API calls CompleteMultipartUpload, which makes the object appear atomically. It checks size and checksum, and flips the row to uploaded. That emits an event for transcoding. A lifecycle rule aborts abandoned uploads after 7 days. A sweeper fails rows stuck in uploading.
Interviewer asksThere is no transaction across your database and S3. How do you keep them consistent?
I order the writes so a crash can only leave a harmless inconsistency. First a hidden pending row, then the bytes, then the flip to ready. A crash leaves an orphan object, never a row pointing at nothing. Deletes run in reverse: hide the row, then delete the object later. A daily reconciler joins the bucket inventory with the table. It deletes orphans older than a day, and settles stuck rows. It has a circuit breaker, so a bad read of the table cannot delete the bucket.
Interviewer asksStorage is growing by 700 TB a month. How do you bring the bill down?
First, split the bill into storage, requests and egress. For storage, a lifecycle policy roughly halves the cost of media whose reads fall off with age: Standard to Infrequent Access at 30 days, and Glacier Instant Retrieval at 90. Check retrieval rates first, because retrieval fees punish a wrong guess. Then stop storing what can be re-created. Expire old versions, abort incomplete uploads, and dedup by content hash. If requests dominate, pack small objects. If egress does, raise the CDN hit ratio.
Seen in
Section titled “Seen in”- Pastebin: paste bodies in an object store under random keys. Body first, metadata second, with a reconciler that deletes orphans from the daily inventory.
- Instagram: presigned multipart uploads from phones, an object-created event that drives rendering, and erasure-coded warm storage for media older than 90 days.
- File sync: content-addressed blocks of about 1.25 MB, blocks first and metadata last, mark-and-sweep garbage collection and dedup that does not leak.
- Video streaming: resumable multipart uploads in 8 MB parts, and immutable segments served through a CDN and an origin shield.
- News feed: media uploaded straight to blob storage with a presigned URL before the post is created.
- Chat: attachments through presigned uploads, and older message history moved to object storage.
- Collaborative editor: document snapshots stored as immutable objects keyed by document and revision.
- Message queue: tiered storage, with closed log segments uploaded to object storage and brokers keeping only recent hours.
- Metrics and monitoring: two-hour TSDB blocks on object storage, compacted offline, with caches in front of slow reads.
- Web crawler: pages packed into 1 GB WARC files to avoid per-object request costs, and frontier checkpoints written to S3.
The job. Keep billions of immutable blobs cheaply and durably, by key. The database keeps the small metadata row. The object store keeps the bytes.
The model. Flat keys, immutable objects, no rename, slow paged LIST, no multi-object
transactions, fees per request. Strongly consistent per object in S3 since December 2020.
Durability. Eleven nines from erasure coding across zones: RS(10,4) survives 4 losses at 1.4 times the data, against 3 times for three copies. Fast repair matters as much as the code.
The seam. Bytes first, metadata last, or a hidden pending row flipped last. A reconciler with a circuit breaker removes orphans and settles stuck rows.
Uploads. Presigned URLs keep bytes off app servers. Sign the method, key, content type and checksum, with a short expiry. Use multipart with resume for big files, plus an abort lifecycle rule.
Reads and cost. Immutable keys behind a CDN, bucket private. Lifecycle tiers halve storage for aging data but charge for retrieval. Pack tiny objects.
Biggest risks. Rows pointing at missing bytes, invisible abandoned uploads, a hot object or prefix, a public bucket, and a mistaken delete executed perfectly.
Further reading
Section titled “Further reading”- On this site: CDNs for the read path, databases for the metadata side, and replication for copies and quorums.
- Amazon S3 performance guidelines: request rates per prefix, byte-range fetches and parallel uploads, from AWS.
- f4: Facebook’s Warm BLOB Storage System, OSDI 2014: Reed-Solomon (10,4) for warm photos and the effective replication factor.
- Finding a Needle in Haystack: Facebook’s Photo Storage, OSDI 2010: packing billions of small photos into large files.
The designbeyondcode digest, every Saturday
New deep dives, interview tips and site news in one short email. Free. Leave with one click.
We never sell or share your email. We only use it to send this newsletter.