Filter concepts by levelShowing all levels.

System Design · Section 53

File and Large Data Processing

Level
intermediate
Read
16 min
Concepts
3

Handling a large file safely starts with not loading it fully into memory — streaming processes it in small, fixed-size chunks instead, so peak memory use stays roughly constant regardless of file size, avoiding the out-of-memory kill that a multi-gigabyte request can otherwise cause on an otherwise-healthy worker. On top of that foundation, multipart upload, chunking, asynchronous processing and progress tracking are the mechanisms that make large-file handling work in production: large transfers are split into independently-retriable parts, the actual processing is handed off to a background job so the request does not block on work that can take minutes, and progress is explicitly tracked and reported since neither a caller nor a UI can otherwise tell a job that is nearly done from one that has silently stalled. Finally, metadata (a file's name, size, owner, content type — small and structured) belongs in a database row, while the blob itself (large and opaque) belongs in object storage referenced by key — storing the blob directly in the database makes every backup, replica, and query pay for its full size for no benefit.

System Design overview

What is true here

  1. Streaming keeps peak memory bounded by chunk size instead of file size — an oversized request loaded fully into memory can OOM-kill a whole worker process.
  2. Multipart upload, chunking, asynchronous processing and progress tracking together turn a large-file operation from one blocking request into a bounded, observable background job.
  3. Object storage's own data model and multipart mechanics are covered by system-design.object-storage — this section covers the surrounding pattern (chunk, go async, track progress), not a duplicate of that API.
  4. Metadata (small, structured) belongs in a database row referencing the blob by key; the blob itself (large, opaque bytes) belongs in object storage, never in a database column.

What you will be able to do

  • Explain why loading an entire file into memory before processing it can OOM-kill a worker process, and how streaming avoids that
  • Design an upload flow that chunks a large transfer, processes it asynchronously, and exposes real progress to the caller
  • Distinguish what this section's mechanisms add on top of object storage's own multipart upload API, rather than re-deriving it
  • Decide to store a file's metadata in a database row and its bytes in object storage, instead of storing the blob directly in the database

Why streaming, not loading fully into memory

The OOM failure mode this whole section exists to prevent, and why streaming avoids it by keeping peak memory bounded by chunk size.

Stream large files instead of loading them fully into memory

coreintermediate

Reading an entire file into a variable before processing it means the file's full size sits in RAM at once, even if you only ever look at it one line or chunk at a time. Streaming reads and processes a file in small, fixed-size pieces instead, so memory use stays roughly constant no matter how large the file is.

Think of it as

Loading a whole file into memory is like trying to drink a lake by scooping it into one cup that has to hold the entire lake before you take a sip — the cup has to be exactly as big as the lake or it overflows. Streaming is drinking through a straw: water flows through a narrow, constant-size path, and you can drink a lake or a puddle with the same straw because the straw was never sized to the source.

text
// Loading fully -- memory use = file size
data = read_entire_file("upload.csv")   // 2 GB in RAM
process(data)

// Streaming -- memory use = chunk size, constant
for chunk in stream_file("upload.csv", chunk_size=64_KB):
    process(chunk)

What we're doing: Compare what happens to a worker process's memory when it loads a large upload fully vs streams it.

upload-handler.txttext
1  # Handler for POST /uploads, worker has 2 GB free RAM
2
3  # Loading fully:
4  def handle_upload_bad(request):
5      body = request.read_all()      # buffers entire body
6      rows = parse_csv(body)         # 2nd full copy in memory
7      save_rows(rows)
8      # A 3 GB upload needs 3+ GB free -- this worker does not have it
9
10 # Streaming:
11 def handle_upload_good(request):
12     for line in request.stream_lines():   # one line at a time
13         row = parse_csv_line(line)
14         save_row(row)
15     # Memory use is bounded by one line's size, regardless
16     # of whether the upload is 3 MB or 3 TB
5
request.read_all() forces the entire request body to exist in memory before line 6 can even start — nothing downstream can run until this line finishes.
6
parse_csv(body) commonly allocates a second, parsed copy alongside the raw body, so peak memory can be a multiple of the file size, not just equal to it.
12
request.stream_lines() yields one line at a time from the underlying connection — the next line is not read until this one has been processed and can be released.

Why this works: Streaming keeps memory use bounded by the size of one chunk, not the size of the whole file, because the process never needs more than one chunk resident at a time — the file's total size stops being a limiting factor for that worker's memory budget.

Reading an entire upload into a variable before validating or processing it

Wrong

text
def handle_upload(request):
    body = request.read_all()  # blocks until all bytes arrive
    if len(body) > MAX_SIZE:
        return error("too large")
    process(body)

Better

text
def handle_upload(request):
    total = 0
    for chunk in request.stream(chunk_size=64_KB):
        total += len(chunk)
        if total > MAX_SIZE:
            return error("too large")  # abort early, no full read
        process_chunk(chunk)

What you see: A handful of unusually large uploads (or a deliberately oversized one from an attacker) cause a worker process to be OOM-killed, taking down every other in-flight request on that worker at the same moment — with no error message pointing at the actual cause unless the process's own memory graph is checked.

Why: Checking body size after read_all() has already forced the full body into memory defeats the size check's own purpose — the memory spike that check exists to prevent has already happened by the time the length is known. Streaming lets the size limit abort mid-transfer, before the oversized remainder is ever read.

Processing a 5 GB upload: load fully vs stream

Load fully into memory

  • +Whole 5 GB must fit in RAM before processing starts
  • +Peak memory use = file size
  • +A worker with 2 GB free gets OOM-killed

Stream in chunks

  • Each chunk (e.g. 64 KB) is processed and released
  • Peak memory use = chunk size, not file size
  • Same worker handles a 5 GB or a 5 MB file identically
  • Load fully into memory
    • Whole 5 GB must fit in RAM before processing starts
    • Peak memory use = file size
    • A worker with 2 GB free gets OOM-killed
  • Stream in chunks
    • Each chunk (e.g. 64 KB) is processed and released
    • Peak memory use = chunk size, not file size
    • Same worker handles a 5 GB or a 5 MB file identically

Loading fully into memory vs streaming, for the same file

Loading fully into memory vs streaming, for the same file
PropertyLoad fully into memoryStream in chunks
Peak memory useScales with file sizeRoughly constant, bounded by chunk size
Behavior on a 10 GB fileLikely OOM kill on a typical workerSame memory footprint as a 10 MB file
Time to first byte processedWaits for the entire file to load firstStarts processing the first chunk immediately
Random access / seekingTrivial — the whole file is already in memoryLimited — usually front-to-back only

Remember: Loading a file fully into memory makes peak memory scale with file size; streaming keeps peak memory bounded by chunk size instead. An oversized request that gets loaded fully can OOM-kill the whole worker process, not just fail that one request.

See also: multipart chunking async and progress tracking · object storage vs application servers

Advertisement

Chunking, asynchronous processing and progress tracking

The mechanisms that make large-file handling work in practice, cross-referencing object storage's own multipart upload mechanics rather than repeating them.

Multipart upload, chunking, asynchronous processing and progress tracking

coreintermediate

Multipart upload splits a large file into independently-uploaded parts (system-design.object-storage.multipart-upload-and-signed-urls covers the S3-style mechanics in full — parts, upload IDs, completion). Chunking is the same idea applied more generally: any large payload, upload or download, gets processed in fixed-size pieces rather than as one unit. Asynchronous processing means the request that receives a file returns immediately, handing the actual work (transcoding, virus scanning, indexing) to a background job instead of making the caller wait for it. Progress tracking reports how much of a chunked, asynchronous operation has completed so far, since neither the caller nor a human watching a UI can tell from a single request/response pair how a multi-minute file operation is going.

Think of it as

Multipart upload and chunking are the same idea as a moving crew carrying a house's contents in numbered boxes rather than as one impossible single load — chunking is just that idea applied to any large transfer, not only the upload API itself. Asynchronous processing is the crew leaving a receipt at the door the moment they take your boxes, rather than making you stand on the porch until every box is unpacked at the destination. Progress tracking is the tracking number on that receipt — without it, "did my stuff arrive yet?" has no answer except waiting and hoping.

text
POST /uploads              -> 202 Accepted, { job_id: "j-9f2a" }
                               (upload handed to queue, not processed yet)

GET  /uploads/j-9f2a/status -> { status: "processing",
                                  chunks_done: 42, chunks_total: 120 }

GET  /uploads/j-9f2a/status -> { status: "complete",
                                  chunks_done: 120, chunks_total: 120 }

What we're doing: Trace a large video upload through chunked transfer, an asynchronous transcode job, and progress polling.

chunked-async-upload.txttext
1  POST /videos  (multipart/chunked, 800 MB file, 50 chunks)
2    -> server acknowledges each chunk as it arrives
3    -> once all 50 chunks are received, server responds:
4       202 Accepted  { job_id: "vid-7731", status: "queued" }
5
6  # The HTTP request is now DONE. The client is not
7  # blocked waiting for transcoding, which has not
8  # started yet.
9
10 # A background worker picks up job vid-7731:
11 for i, segment in enumerate(split_into_segments(video)):
12     transcode(segment)
13     update_progress(job_id="vid-7731", done=i+1, total=50)
14
15 # Client polls (or subscribes via WebSocket):
16 GET /videos/vid-7731/status
17   -> { status: "processing", done: 31, total: 50 }
18   ... later ...
19   -> { status: "complete", done: 50, total: 50, url: "..." }
4
The 202 response happens the moment the upload finishes, not when transcoding finishes — the caller's HTTP connection is released long before the video is actually usable.
13
update_progress is an explicit call the worker makes after each segment — progress is not automatically visible, something has to record it as work completes.
17
The status endpoint reads the same job record the worker is writing to — polling it is how the client learns progress without holding a connection open for the whole transcode.

Why this works: Chunked transfer bounds the size of each upload unit, the 202 response decouples "received" from "processed" so the request does not block on transcoding, and progress tracking gives the caller a way to observe a job that, from the outside, would otherwise look identical whether it is 5% done or about to fail.

Processing an upload synchronously inside the same request that received it

Wrong

text
def handle_video_upload(request):
    video = request.read_all()
    transcode(video)          # blocks for ~90s
    save_to_storage(video)
    return {"status": "done"}  # client waited 90+ seconds

Better

text
def handle_video_upload(request):
    video = save_upload_to_storage(request)  # fast
    job_id = enqueue_transcode_job(video)
    return {"status": "queued", "job_id": job_id}, 202
    # transcoding happens in a background worker

What you see: Upload requests routinely time out or appear to hang on anything but the smallest files, because the client's connection (and any load balancer or proxy's own timeout) has to stay open for the entire transcode, not just the upload — a 90-second transcode on a 30-second proxy timeout fails every time, even though the upload itself succeeded.

Why: A synchronous handler conflates two operations with very different durations — receiving bytes (seconds) and transforming them (minutes) — into one request/response cycle bounded by the shorter operation's expected timeout. Decoupling them with a queue lets each have its own, appropriate time budget.

A large upload, end to end: chunked, asynchronous, tracked
chunk 1..Nhand off, donot blockprogressupdatespoll or push

Client

uploads in chunks

Upload endpoint

returns 202 + job_id immediately

Background job

processes chunks, updates progress

Status endpoint

chunks_done / chunks_total

  • Client — uploads in chunks
    • leads to Upload endpoint (chunk 1..N)
  • Upload endpoint — returns 202 + job_id immediately
    • leads to Background job (hand off, do not block)
  • Background job — processes chunks, updates progress
    • leads to Status endpoint (progress updates)
  • Status endpoint — chunks_done / chunks_total
    • leads to Client (poll or push)

The four mechanisms and what problem each one solves

The four mechanisms and what problem each one solves
MechanismProblem it solvesWhere it lives
Multipart uploadOne huge upload as a single unit is slow to retry and hard to parallelizeObject storage API — see system-design.object-storage.multipart-upload-and-signed-urls
Chunking (general)Any large transfer or transformation needs a bounded unit of work, not just uploadsUpload/download handlers, batch jobs, transcoding pipelines
Asynchronous processingProcessing a file can take far longer than a caller should wait on one requestA queue/worker that picks up a job after the request returns
Progress trackingA caller has no way to know how a multi-minute operation is goingA job record updated as chunks/steps complete, polled or pushed to the client

Remember: Multipart upload (system-design.object-storage.multipart-upload-and-signed-urls) is one specific application of the broader pattern: chunk large transfers, process them asynchronously so the request does not block on the work, and track progress explicitly by writing state as chunks complete — none of this is automatic.

See also: streaming vs loading into memory · separating metadata from blobs · multipart upload and signed urls

Advertisement

Keeping metadata and blobs apart

Why a database row should reference a blob by key instead of storing the blob's bytes directly.

Separate metadata from blobs

standardintermediate

Metadata (a file's name, size, owner, content type, upload date) is small and structured — it belongs in a database row. A blob (the actual file bytes) is large and opaque to the database — it belongs in object storage. Storing the blob itself as a database column instead of a reference to it makes routine database operations slow and expensive for no benefit, because the database ends up carrying weight it was never built to carry.

Think of it as

A library catalog card and the book itself are kept apart on purpose — the card (title, author, shelf location) lives in a compact, fast-to-search drawer, while the book sits on a shelf built for bulky objects. Nobody proposes gluing every book to its own catalog card and filing the combined object in the card drawer — the drawer would stop being fast to search, and its size would explode for no benefit, since nobody searches a catalog by reading full book text anyway.

sql
-- Metadata row: small, fixed-shape, fast to query
CREATE TABLE uploads (
  id           UUID PRIMARY KEY,
  owner_id     UUID NOT NULL,
  object_key   TEXT NOT NULL,   -- reference into object storage
  content_type TEXT NOT NULL,
  size_bytes   BIGINT NOT NULL,
  uploaded_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- The file's actual bytes live in object storage at object_key,
-- never in this table.

What we're doing: Compare a schema that stores the blob directly against one that stores only a reference to it.

metadata-vs-blob.sqlsql
1  -- BEFORE: blob stored directly in the database
2  CREATE TABLE documents_bad (
3    id UUID PRIMARY KEY,
4    filename TEXT,
5    file_data BYTEA  -- the actual PDF/image bytes, up to 100s of MB
6  );
7
8  -- AFTER: metadata only, blob lives in object storage
9  CREATE TABLE documents_good (
10   id UUID PRIMARY KEY,
11   filename TEXT,
12   object_key TEXT,     -- e.g. "documents/2026/inv-4471.pdf"
13   size_bytes BIGINT,
14   content_type TEXT
15 );
16 -- object_key points into the object store; the row itself
17 -- stays under 1 KB no matter how large the referenced file is
5
file_data BYTEA makes this row's size equal to the file's size — a table of 10,000 documents averaging 20 MB each is a 200 GB table before counting any other column.
12
object_key is a small string — the row is now a fixed, tiny size regardless of whether the referenced file is 10 KB or 10 GB.
17
This is what makes routine database work (backup, replication, index rebuilds) cheap and predictable again — none of it has to move blob bytes anymore.

Why this works: A database is built to make small, structured rows fast to query, index, and replicate — none of that design point applies to a multi-megabyte blob sitting in one column, so keeping the blob out of the database preserves the performance the database was chosen for in the first place.

The row stays the same size no matter how large the file is

The row holds a key, not the bytes. That is what keeps backups, replicas and index rebuilds cheap.

  • On the left, a database row for an uploads table listing id, owner_id, object_key, content_type and size_bytes — under 1 KB, and fixed regardless of the file.
  • An arrow labelled object_key points from that row to the right-hand box, object storage.
  • On the right, object storage holds the actual file, inv-4471.pdf, whose bytes may be 10 KB or 10 GB. It is served by a signed URL and never read back through the database.
  • Below: store the blob in the row instead and every backup, replica and index rebuild has to move it too.

Blob in the database vs blob in object storage, metadata row referencing it

Blob in the database vs blob in object storage, metadata row referencing it
AspectBlob stored in a DB columnBlob in object storage + metadata row
Row sizeGrows with file size — a 500 MB video is a 500 MB rowFixed and small — typically under 1 KB regardless of file size
Backup/replication costEvery backup and replica copies the full blobBackup copies only the small reference row
Query performanceScanning or indexing the table competes with blob I/OMetadata queries stay fast — no blob bytes in the table
Serving the fileApp server reads the blob out of the database firstClient can be handed a direct link (e.g. a signed URL) to the object store

Remember: Metadata (small, structured — name, size, owner, a reference key) belongs in a database row; the blob itself (large, opaque bytes) belongs in object storage. Storing the blob directly in the database makes every backup, replica, and query pay for its full size.

See also: multipart chunking async and progress tracking · the object storage model · object storage vs application servers

Advertisement