DEV Community

Ivan Rossouw
Ivan Rossouw

Posted on AI-assisted

Streaming an Upload Is an End-to-End Contract

An upload handler can accept a Stream and still sit at the end of an eager, allocation-heavy path. The signature tells us how one method receives data. It does not tell us how the bytes reached that method.

That distinction becomes important when a feature grows from small images to larger media. A transport shape that feels harmless at a few megabytes can become uncomfortable when files are an order of magnitude larger. The useful question is not “Do we use streams?” It is “Where can the whole file exist at once?”

The convenient path can hide several copies

Consider a server-interactive UI that receives a browser file, reads it through the UI circuit, copies it into an in-memory buffer, produces a byte array, converts the bytes to Base64, and places the resulting string in a JSON request.

Conceptually, the route looks like this:

browser file
  -> interactive UI circuit
  -> memory buffer
  -> byte array
  -> Base64 string
  -> JSON request
  -> application endpoint
Enter fullscreen mode Exit fullscreen mode

Each step can be locally understandable. The buffer makes an API easy to call. The array works with familiar serializers. The string fits a text-based request model. The trouble appears when several full-file representations overlap in one process.

Base64 also expands binary data by roughly one third before accounting for the managed string, JSON, and transport envelope. That is not automatically a problem for a tiny avatar. It is a warning sign for larger files or concurrent uploads.

Draw the payload path before changing code

I find it useful to make the payload path explicit before choosing an optimization. For every boundary, write down:

  • the data representation;
  • whether the entire file can be resident;
  • which process owns that memory or disk;
  • the maximum accepted length;
  • how cancellation travels;
  • what cleans up after a later failure.

This exercise often finds a hidden eager hop. Perhaps the browser API streams, but the UI layer calls ToArray(). Perhaps the controller exposes a file abstraction, but an application service turns it into a data URI. Perhaps storage accepts a stream, but a validation library first reads everything into memory.

One eager hop is enough to restore the allocation spike. Downstream streaming cannot undo memory already allocated upstream.

Move the boundary, not only the method

A stronger shape for a browser upload is often:

browser
  -- multipart HTTP --> bounded upload endpoint
  -- stream + length --> application boundary
  -- stream ---------> storage seam
Enter fullscreen mode Exit fullscreen mode

The important change is architectural. The bytes no longer need to travel through the interactive UI circuit or a JSON/data-URI model. The browser posts the original file to the endpoint that owns the upload contract. The application keeps the declared length and cancellation token alongside the stream, and the storage seam consumes that stream directly.

Removing the redundant typed client route matters too. Two upload paths tend to drift: one gains a new limit, validation rule, or error message while the other keeps the old behaviour. A single path is easier to reason about, test, and operate.

Bounded multipart is not zero-copy

It is tempting to describe the revised route as “zero-copy” or “constant-memory.” Those claims are usually too strong.

With an IFormFile boundary, ASP.NET Core uses buffered model binding: smaller uploads remain in memory and larger uploads move to a temporary file after the configured threshold. The application then opens that buffered file as a stream for storage. Proxies and storage clients may add their own buffering. The narrower, evidence-backed improvement is that the application no longer creates the memory buffer, second byte array, and Base64 string itself.

That nuance changes the operational checklist. Temporary storage needs space, permissions, cleanup, and monitoring. Request timeouts still matter. Concurrency can move pressure from the managed heap to disk throughput. Measure the deployed path before making latency or throughput claims.

Limits belong at more than one boundary

An ingress ceiling protects the HTTP host from an unbounded request. It does not replace a narrower domain rule.

The transport might allow enough headroom for multipart overhead or companion data while a particular use case accepts a smaller payload. Storage may have its own ceiling. These limits answer different questions:

  • Can the host safely receive this request?
  • Is this file valid for the requested operation?
  • Can the storage backend safely retain it?

Keep each rule explicit and return an actionable error from the boundary that owns it. A very tight framework limit can reject the request with a generic response before the application can explain what was wrong. A very loose limit without downstream validation can quietly become permission.

The trade-off moves complexity

Direct multipart upload is not free. Browser-side orchestration must build the form, call the endpoint, map network failures, reset failed selections, and potentially expose progress and cancellation. The endpoint becomes a separately testable HTTP surface instead of a convenient typed UI service. Temporary-disk pressure replaces some managed-memory pressure.

Those costs are often worth paying for large media, but they should be owned deliberately. Focused tests should cover the accepted boundary, one byte over the limit, unsupported types, cancellation, empty input, and cleanup when bytes are stored but later persistence fails. In this review, committed tests gave evidence for several domain-boundary and cleanup cases, but not a fresh run or proof of every HTTP and cancellation path.

A practical review rule

When reviewing a large-file path, ignore the final Stream parameter for a moment. Follow one representative file from the browser to durable storage and circle every full buffer, array, encoded string, and duplicated request model.

Then ask whether each circle is necessary.

Streaming is not a keyword sprinkled onto an endpoint. It is a contract every hop must preserve. One hidden full-file buffer breaks that end-to-end contract, even when downstream streams still prevent further copies.

Top comments (0)