An ASP.NET Core application spends much of its life moving data. Request bodies arrive from clients, database results flow into services, files move through APIs, JSON is parsed and generated, and responses travel back across the network.
When that data is small, almost any reasonable approach works. At scale, however, unnecessary buffering, synchronous I/O, repeated copying, and oversized in-memory payloads can consume memory, block threads, increase latency, and limit throughput. High-performance I/O is therefore not about making the network magically faster. It is about designing the application so data can move through the system efficiently, asynchronously, and with as little unnecessary work as possible.
The Real Problem Is Often How We Move the Data
In our previous article on Memory Management in ASP.NET Core, we looked at garbage collection, allocations, the Large Object Heap, and object pooling.
Consider an API that receives a 500 MB file.
A straightforward implementation might do this:
Receive 500 MB
↓
Load 500 MB into memory
↓
Convert or deserialize it
↓
Process it
↓
Build another large result
↓
Send the responseThe file may only be 500 MB, but the application could temporarily consume much more than 500 MB because multiple representations exist at the same time.
Now imagine ten users performing that operation concurrently.
The problem is no longer merely file processing.
It becomes a problem involving:
Memory
Garbage collection
Thread usage
Network throughput
Disk throughput
Concurrency
Backpressure
LatencyA better architecture tries to move data progressively:
Receive chunk
↓
Process chunk
↓
Write result
↓
Receive next chunkInstead of thinking:
“How quickly can I load this data?”
think:
“How little of this data needs to exist in memory at any one moment?”
That shift is fundamental to high-performance I/O.
I/O Is Different from CPU Work
Imagine two operations.
The first calculates millions of mathematical values:
CPU
████████████████████████The processor is actively working.
The second waits for data from a remote storage service:
Send request
↓
Wait...
↓
Wait...
↓
Data arrivesDuring much of that time, the CPU has nothing useful to do for that operation.
This distinction explains why asynchronous programming is so important for server I/O.
Microsoft’s ASP.NET Core performance guidance recommends asynchronous APIs for data access and I/O because blocking calls can lead to Thread Pool starvation and degraded response times.
Consider:
var data = stream.Read(buffer, 0, buffer.Length);This synchronous operation may occupy a thread while waiting for data.
The asynchronous equivalent is:
var bytesRead = await stream.ReadAsync(
buffer,
cancellationToken);While the operation is waiting, the thread can be used for other work.
That matters enormously in a web server handling many concurrent requests.
Async Does Not Make the Storage Device Faster
This is an important distinction.
Suppose a storage operation takes 200 milliseconds.
Changing:
stream.Read(...)to:
await stream.ReadAsync(...)does not magically turn the disk or network into a faster device.
The advantage is scalability.
With synchronous I/O:
Request A → Thread waits
Request B → Thread waits
Request C → Thread waits
Request D → Thread waitsWith asynchronous I/O:
Request A → waiting for I/O
Request B → waiting for I/O
Request C → waiting for I/O
Threads remain available
for other runnable workASP.NET Core can therefore handle large numbers of concurrent I/O-bound operations without requiring one blocked thread for every waiting request.
This is why Microsoft specifically advises against synchronous reads and writes on HttpRequest and HttpResponse bodies.
Streams Give Us a Better Mental Model
A Stream represents a sequence of bytes.
It does not necessarily represent a complete object already loaded into memory.
Streams can represent:
Files
Network connections
HTTP request bodies
HTTP responses
Memory
Compression layers
Cryptographic transformationsFor example:
await using var stream = File.OpenRead("report.csv");
var buffer = new byte[8192];
while (true)
{
var bytesRead = await stream.ReadAsync(buffer);
if (bytesRead == 0)
break;
Process(
buffer.AsSpan(0, bytesRead));
}The application does not need the entire file in memory.
It works on a small region at a time.
For a 10 GB file, our working buffer could still be only a few kilobytes.
That is the power of streaming.
Buffering and Streaming Are Different Strategies
Suppose an application receives:
1 GB requestA buffered approach looks like:
Network
↓
████████████████████
Entire request in memory
↓
ProcessingA streaming approach looks more like:
Network
↓
[chunk]
↓
Process
↓
[chunk]
↓
Process
↓
[chunk]Buffering is not inherently bad.
Small payloads are often easier and perfectly reasonable to buffer.
The problem is unbounded buffering.
Microsoft’s ASP.NET Core guidance warns against reading large request or response bodies into a single byte[] or string, partly because sufficiently large allocations enter the Large Object Heap and can contribute to expensive full garbage collections.
This connects directly to our previous article.
Memory-efficient I/O often means avoiding the giant allocation entirely.
A Common Mistake: ReadToEnd
Imagine middleware that needs to inspect a request.
This is tempting:
using var reader =
new StreamReader(context.Request.Body);
var body = await reader.ReadToEndAsync();For a small JSON request, this may be acceptable.
For a large upload, body may represent the entire request in memory.
If many requests arrive concurrently, memory consumption scales accordingly.
Instead, ask:
Do I actually need the entire body simultaneously?
If not, process the stream incrementally.
Let the Serializer Stream When Possible
Suppose an endpoint accepts JSON.
Instead of:
using var reader =
new StreamReader(Request.Body);
var json =
await reader.ReadToEndAsync();
var order =
JsonSerializer.Deserialize<Order>(json);we can deserialize directly from the stream:
var order =
await JsonSerializer.DeserializeAsync<Order>(
Request.Body,
cancellationToken:
HttpContext.RequestAborted);We remove an unnecessary intermediate string.
Microsoft’s performance guidance specifically demonstrates asynchronous JSON deserialization directly from the request body as a preferable pattern.
The general rule is simple:
Avoid creating an intermediate representation when the next API can consume the stream directly.
Cancellation Belongs in I/O Code
Suppose a user begins downloading a large report and then closes the browser.
Should the server continue spending 30 seconds:
Reading database rows
Generating data
Writing output
Calling storagefor a client that no longer exists?
Usually not.
ASP.NET Core exposes request cancellation through:
HttpContext.RequestAbortedor by binding a CancellationToken directly in a Minimal API endpoint:
app.MapGet(
"/export",
async (CancellationToken cancellationToken) =>
{
// Use cancellationToken throughout.
});That token should travel through the I/O path:
await source.ReadAsync(
buffer,
cancellationToken);
await destination.WriteAsync(
buffer,
cancellationToken);Cancellation prevents abandoned requests from continuing to consume scarce resources.
This becomes particularly important for large or slow transfers.
Stream Copying Can Be Surprisingly Simple
Suppose an application retrieves a file from storage and sends it to the client.
A wasteful architecture might be:
Storage
↓
byte[]
↓
MemoryStream
↓
another byte[]
↓
HTTP responseOften we simply need:
Storage stream
↓
HTTP response streamFor example:
await source.CopyToAsync(
Response.Body,
cancellationToken);Now the application acts more like a conduit.
Data moves through it rather than accumulating inside it.
ASP.NET Core also provides result types designed to return stream-based content. FileStreamResult, for example, represents an action result that writes a file from a stream to the HTTP response.
But Streams Still Require Buffering Somewhere
Streaming does not mean:
No buffers exist.Network and file I/O require buffers.
The real question is:
Who manages those buffers, how large are they, and how often are they copied?
A conventional stream parser may need to manage:
Partial messages
Buffer boundaries
Unused bytes
Resizing
Copying
Multiple buffersImagine receiving newline-separated records:
Alice\nBob\nCharlie\nA read might end here:
Alice\nBob\nChaThe next read contains:
rlie\nOur parser must remember:
Chaand combine it with:
rlieThis sounds simple.
At high throughput, correctly managing partial buffers becomes surprisingly complicated.
That is one of the problems System.IO.Pipelines was designed to solve.
Enter System.IO.Pipelines
Microsoft describes System.IO.Pipelines as a library designed to make high-performance I/O in .NET easier. It addresses much of the complicated buffer-management code required by streaming parsers.
The core abstractions are:
PipeReader
PipeWriterConceptually:
Producer
↓
PipeWriter
↓
[managed buffers]
↓
PipeReader
↓
ConsumerThe writer produces bytes.
The reader consumes bytes.
The pipeline manages the memory between them.
This allows the producer and consumer to operate somewhat independently while sharing an efficient buffering system.
ASP.NET Core Already Exposes Pipelines
ASP.NET Core gives us both stream and pipeline APIs.
For requests:
HttpRequest.Bodyis a Stream.
And:
HttpRequest.BodyReaderis a PipeReader.
For responses:
HttpResponse.Bodyis a Stream.
And:
HttpResponse.BodyWriteris a PipeWriter.
Microsoft’s current request and response documentation explicitly exposes both abstractions and notes that pipelines can provide performance advantages for advanced scenarios.
This does not mean every controller should immediately replace streams with pipelines.
Streams remain extremely useful.
Pipelines become particularly valuable when we need efficient parsing or writing on hot I/O paths.
Reading with PipeReader
A simplified reader loop looks like this:
PipeReader reader =
HttpContext.Request.BodyReader;
while (true)
{
ReadResult result =
await reader.ReadAsync(
HttpContext.RequestAborted);
ReadOnlySequence<byte> buffer =
result.Buffer;
// Inspect and process available bytes.
reader.AdvanceTo(
buffer.Start,
buffer.End);
if (result.IsCompleted)
break;
}The exact parsing logic depends on the protocol.
The important point is that the PipeReader provides access to buffered data without forcing us to continually allocate our own arrays.
Microsoft notes that HttpRequest.BodyReader directly accesses the request body and manages memory for the caller, avoiding an additional request-data copy into a caller-managed buffer.
ReadOnlySequence<byte> Matters
Pipeline data may not exist in one contiguous block of memory.
Instead, we may receive:
Segment 1
[ABCDEFG]
Segment 2
[HIJKLMN]
Segment 3
[OPQRST]Together they form a logical sequence.
ReadOnlySequence<byte> lets us treat those segments as one sequence without first combining them into another giant array.
That is important because this:
Segment 1
Segment 2
Segment 3
↓
Allocate giant array
↓
Copy everythingcreates work that may not be necessary.
High-performance I/O frequently comes down to one principle:
Move references to data whenever possible instead of repeatedly moving the data itself.
AdvanceTo Is Not Optional Housekeeping
PipeReader requires the consumer to tell it how much data has been processed.
That is the purpose of:
reader.AdvanceTo(
consumed,
examined);These positions mean different things.
consumed tells the pipeline:
We no longer need data before this point.
examined tells it:
We have inspected data up to this point.
Getting these positions wrong can cause serious problems.
If we fail to consume data properly, buffers may be retained longer than necessary.
If we report examined data incorrectly, the reader may behave unexpectedly or wait for data when it should continue processing.
Microsoft’s pipeline documentation specifically warns that incorrect PipeReader usage can lead to excessive memory use, hangs, infinite loops, or corrupted processing logic.
Pipelines remove a lot of buffer-management complexity.
They do not remove the need to understand ownership.
Parsing Delimited Data Efficiently
Imagine receiving:
1001,temperature,28.3\n
1002,temperature,28.4\n
1003,temperature,28.7\nInstead of converting the entire request into one huge string, we can search the available byte sequence for newline delimiters.
Conceptually:
PipeReader
↓
Find \n
↓
Process complete record
↓
Advance consumed position
↓
Keep incomplete record buffered
↓
Read more dataThis is exactly the kind of scenario pipelines handle well.
The parser can work with whatever data has arrived without demanding that the entire payload be available first.
Writing with PipeWriter
The same idea applies to responses.
ASP.NET Core exposes:
HttpResponse.BodyWriteras a PipeWriter.
We can request writable memory:
var writer =
HttpContext.Response.BodyWriter;
Memory<byte> memory =
writer.GetMemory(4096);Write data into it, tell the writer how many bytes were written:
writer.Advance(bytesWritten);and eventually flush:
await writer.FlushAsync(
HttpContext.RequestAborted);Microsoft notes that BodyWriter provides direct access to the response body while managing memory on behalf of the caller.
This can avoid some intermediate buffering and copying in performance-sensitive code.
Flush Does Not Mean “Flush Constantly”
Suppose we generate a million small records.
We could do:
Write record
Flush
Write record
Flush
Write record
FlushThat may create a large number of tiny writes.
At the opposite extreme:
Write everything
Write everything
Write everything
Never flush until the endmay delay delivery and accumulate too much buffered data.
The correct strategy lies between them.
Microsoft’s ASP.NET Core documentation explains that HttpResponse.BodyWriter buffers data and that the developer must choose when to call FlushAsync, balancing buffer size, network overhead, and how quickly chunks should reach the client.
So flushing is another form of batching.
Batching Is One of the Hidden Keys to I/O Performance
Imagine a database import containing 100,000 records.
Approach A:
Read one record
Write one record
Flush
Repeat 100,000 timesApproach B:
Read manageable batch
Process batch
Write batch
Flush when appropriate
RepeatThe second approach can reduce:
System calls
Network round trips
Database round trips
Flush operations
Protocol overheadBut gigantic batches can increase memory usage and latency.
Once again, the goal is balance.
High-performance systems often operate with bounded batches.
Buffer Size Is a Trade-Off
Should every buffer be 4 KB?
64 KB?
1 MB?
There is no universal answer.
Larger buffers can reduce the number of I/O operations.
But they also:
Consume more memory per operation
Increase memory pressure under concurrency
May cross LOH thresholds
Can retain unnecessary memoryImagine:
1 MB buffer
×
5,000 concurrent operationsThat theoretically represents:
~5 GBof buffer capacity alone.
A buffer that looks harmless in isolation can become expensive at scale.
This is why buffer sizing must consider concurrency, not just single-request throughput.
Reuse Buffers When Profiling Justifies It
Our previous article introduced:
ArrayPool<byte>It is highly relevant to stream processing.
Instead of repeatedly doing:
var buffer =
new byte[64 * 1024];a hot processing path can rent:
var buffer =
ArrayPool<byte>.Shared.Rent(
64 * 1024);
try
{
// Read and process.
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}This can reduce repeated allocations.
But remember the same rules:
Return exactly once
Do not use after return
Clear sensitive data when necessary
Do not pool without evidencePipelines can reduce the need to manage these buffers manually because buffer management is one of the responsibilities the abstraction was designed to simplify.
Backpressure Exists Inside I/O Too
Our earlier article on Designing Backpressure in ASP.NET Core focused on overload at the application level.
The same concept exists within pipelines.
Imagine:
Producer
████████████████████████
↓
Consumer
██████The producer generates data much faster than the consumer processes it.
Without limits:
Buffer
Buffer
Buffer
Buffer
Buffer
Buffer
...Memory keeps growing.
A healthy system needs a mechanism that eventually tells the producer:
Slow down.
System.IO.Pipelines includes pause and resume thresholds that can apply backpressure between the writer and reader. When buffered data grows beyond configured thresholds, flushing can pause until the reader consumes enough data.
This is a beautiful connection between two parts of our series.
Backpressure is not only an API gateway concept.
It exists all the way down to how bytes move through a process.
Slow Consumers Are a Real Production Problem
Suppose our server can generate a 2 GB export very quickly.
But the client is connected through a slow mobile network.
If we generate the entire export into memory first:
Fast server
↓
2 GB result in memory
↓
Slow networkthe server carries the cost of that speed mismatch.
Streaming changes the relationship:
Generate
↓
Write
↓
Network consumes
↓
Generate moreThe output path itself can influence how quickly data is produced.
This keeps resource consumption more closely tied to actual delivery.
Streaming Database Results
Suppose an endpoint exports five million records.
A naive implementation:
var rows =
await db.Orders
.AsNoTracking()
.ToListAsync(cancellationToken);Now millions of records may exist simultaneously in memory.
For workloads where records can be processed independently, asynchronous enumeration can allow progressive processing:
await foreach (
var order in db.Orders
.AsNoTracking()
.AsAsyncEnumerable()
.WithCancellation(cancellationToken))
{
await WriteOrderAsync(
order,
cancellationToken);
}Now the architecture becomes:
Database
↓
Record
↓
Serialize
↓
Responserather than:
Database
↓
Millions of objects
↓
Huge in-memory collection
↓
SerializeThe exact behavior still depends on the database provider, query, and surrounding code, so profiling remains essential.
But architecturally, streaming opens the door to bounded-memory processing.
Pagination Is Still Valuable
Streaming is not automatically the right answer for every large query.
For normal user-facing APIs, pagination is often better.
Instead of returning:
500,000 productsreturn:
100 productswith a mechanism to retrieve the next set.
Microsoft’s ASP.NET Core performance guidance recommends returning large collections across multiple smaller pages rather than retrieving and returning unnecessarily large datasets.
Streaming is powerful when the consumer genuinely needs a long sequence.
Pagination is better when the consumer only needs part of the dataset.
File Uploads Need Limits
Streaming a request body solves one problem.
It does not mean:
Accept unlimited data.
Imagine an attacker sends:
500 TB uploadvery slowly.
Even if we never buffer the whole file, the request can still consume:
Connections
Bandwidth
Disk
CPU
Application timefor an unreasonable duration.
Production upload design needs:
Request size limits
Authentication
Timeouts
Cancellation
Storage quotas
Content validation
Concurrency controls
Rate limitsStreaming is a memory-management strategy.
It is not a complete resource-governance strategy.
Beware of Re-Reading Request Bodies
HTTP request bodies normally behave like forward-moving streams.
Once consumed, they are not automatically available for another component to read again.
Sometimes middleware genuinely needs to inspect a body and allow later components to read it too.
ASP.NET Core provides:
Request.EnableBuffering();which enables multiple reads by buffering the request body. Microsoft’s HttpContext guidance demonstrates this pattern.
For example:
context.Request.EnableBuffering();
await InspectAsync(
context.Request.Body,
context.RequestAborted);
context.Request.Body.Position = 0;
await next(context);But notice what we just did.
We deliberately introduced buffering.
That may be fine for small controlled payloads.
For very large requests, casually enabling buffering can undermine the memory and I/O benefits we are trying to achieve.
Compression Changes the Equation
Suppose an API returns:
10 MB JSONCompression might reduce the bytes transmitted significantly, depending on the content.
That can improve network performance.
But compression consumes CPU.
So the system trades:
CPU workfor:
Fewer network bytesWhether that is beneficial depends on:
Payload type
Payload size
Network speed
CPU capacity
Compression level
Client supportThis illustrates an important performance principle.
There is rarely one resource called “performance.”
We trade among:
CPU
Memory
Network
Disk
Latency
ThroughputHigh-performance I/O is about making those trades deliberately.
Avoid Sync-Over-Async
One particularly harmful pattern is:
var result =
ReadDataAsync().Result;or:
ReadDataAsync().Wait();The asynchronous operation may be waiting for I/O while the calling thread is blocked waiting for the asynchronous operation.
At scale, this can contribute to Thread Pool starvation.
Prefer:
var result =
await ReadDataAsync();and keep the call chain asynchronous.
Microsoft explicitly recommends avoiding Task.Wait and .Result in ASP.NET Core hot paths and making the complete I/O call stack asynchronous when asynchronous APIs are available.
Do Not Use Task.Run to Hide Blocking I/O
Suppose a library exposes only:
ReadSynchronously();It may be tempting to write:
await Task.Run(
() => ReadSynchronously());That does not transform synchronous I/O into true asynchronous I/O.
A Thread Pool thread still performs the blocking call.
Microsoft specifically advises against using Task.Run merely to make synchronous APIs appear asynchronous in ASP.NET Core request processing.
Whenever possible, choose libraries that expose real asynchronous I/O APIs.
.NET 10 Pushes Pipelines Deeper into ASP.NET Core
There is an important current development worth noting.
Starting with .NET 10, several ASP.NET Core JSON request-processing paths use JsonSerializer.DeserializeAsync overloads based on PipeReader instead of Stream, including Minimal API parameter binding, MVC input formatting, and JSON request extensions. Microsoft says most applications benefit from the transition without needing application changes.
This tells us something important about the direction of the platform.
Pipelines are not merely an exotic API for specialized network servers.
They increasingly underpin ordinary ASP.NET Core infrastructure.
Most developers do not need to interact with that machinery directly.
But understanding it helps explain where ASP.NET Core gets much of its I/O efficiency.
Streams or Pipelines?
This is probably the most practical question in this article.
Use streams when:
The operation is straightforward
The existing API expects Stream
You are copying files
You are using compression
The workload is not a measured bottleneck
The simpler abstraction is sufficientConsider pipelines when:
You are building high-throughput middleware
You parse streaming protocols
You process large continuous byte sequences
Buffer management is becoming complicated
Copies and allocations appear in profiling
You need tighter control over producer/consumer flowMicrosoft’s request and response documentation makes the same broad distinction: streams remain useful and widely supported, while pipelines offer advantages for more performance-sensitive processing.
Do not choose pipelines because they sound more advanced.
Choose them because the problem justifies them.
Efficient I/O Is Mostly About Avoiding Work
When developers hear “high performance,” they often imagine clever algorithms.
For I/O systems, the biggest gains are frequently less glamorous:
Do not load the whole file.
Do not copy bytes unnecessarily.
Do not create a giant string.
Do not block a thread while waiting.
Do not fetch records nobody needs.
Do not flush after every tiny write.
Do not allocate giant buffers per request.
Do not continue work after the client disconnects.Each avoided operation seems small.
At scale, avoided work becomes throughput.
Observing I/O Performance
Optimization requires measurement.
Useful signals include:
Request duration
Request rate
Response size
Active requests
Thread Pool queue length
CPU utilization
Allocation rate
GC activity
Network throughput
Disk throughput
Database latency
Cancellation rate
Timeout rateFor custom pipelines, also consider:
Buffered bytes
Producer rate
Consumer rate
Flush frequency
Batch size
Time waiting on backpressureDo not optimize only one metric.
A change that improves throughput but doubles memory consumption may create a different production problem.
Load Test with Slow Clients Too
Performance tests often use powerful load generators on fast networks.
That can hide an important class of problems.
Real clients may:
Upload slowly
Download slowly
Disconnect halfway
Pause unexpectedly
Operate on unreliable mobile networksA robust I/O architecture needs to behave well when the consumer or producer is slow.
Test:
Large payloads
Slow uploads
Slow downloads
Cancellation
Concurrent transfers
Memory-constrained containers
Sudden traffic spikes
Downstream latencyThe goal is not merely impressive benchmark throughput.
The goal is predictable production behavior.
A Practical Architecture for Large Data Processing
Imagine an ASP.NET Core service that receives large telemetry files, validates records, transforms them, stores selected data, and streams a report back to the caller.
A poor architecture might be:
Upload
↓
Entire file in memory
↓
Entire file converted to string
↓
Entire file parsed
↓
List of every record
↓
Transform every record
↓
Build complete report
↓
Send responseA more scalable design could be:
Request stream
↓
Bounded reader
↓
Parse records incrementally
↓
Bounded processing stage
↓
Persist manageable batches
↓
Generate report progressively
↓
Response streamNow add:
Cancellation
Timeouts
Backpressure
Bounded buffers
ObservabilityThe application no longer treats data as one enormous object.
It treats data as a flow.
That is the deeper architectural idea behind high-performance I/O.
How This Connects to the Last Three Articles
The last few articles form a progression.
Backpressure taught us that producers cannot be allowed to overwhelm consumers indefinitely.
Graceful shutdown taught us that outstanding work needs clear ownership, cancellation, and completion rules.
Memory management taught us that allocations, large buffers, and retained objects have real runtime costs.
High-performance I/O brings those ideas together.
A production data pipeline should be able to:
Process data incrementally
Bound its memory usage
Slow producers when necessary
Cancel abandoned work
Drain safely during shutdown
Avoid unnecessary allocation
Remain observable under loadThat is much more valuable than simply calling the fastest API available.
Common High-Performance I/O Mistakes
Several mistakes appear repeatedly.
Reading entire large payloads into memory. This increases peak memory and can create LOH pressure.
Using synchronous I/O inside request handling. Waiting threads reduce scalability.
Using .Result or .Wait(). Sync-over-async can contribute to Thread Pool starvation.
Wrapping blocking work in Task.Run. It moves the blocking operation rather than eliminating it.
Using enormous per-request buffers. Buffer cost multiplies with concurrency.
Flushing after every tiny write. Too many small operations can reduce throughput.
Never flushing. Excessive buffering can increase latency and memory usage.
Ignoring cancellation. Work continues after the caller no longer needs the result.
Using pipelines without understanding AdvanceTo. Incorrect buffer ownership can create memory growth or hangs.
Streaming without resource limits. Streaming protects memory, not every other finite resource.
Using pipelines everywhere. Complexity is not a performance feature.
A Better Optimization Order
If an ASP.NET Core service has I/O performance problems, optimize in this order:
1. Measure the bottleneck.
2. Remove synchronous blocking.
3. Stop retrieving unnecessary data.
4. Avoid buffering large payloads.
5. Stream where the workload allows it.
6. Remove unnecessary copies.
7. Bound buffers and concurrency.
8. Propagate cancellation.
9. Batch appropriately.
10. Profile again.
11. Introduce pipelines where measurements
justify the added complexity.
12. Tune buffer sizes only with evidence.Notice where PipeReader appears.
Not first.
Architecture usually produces larger gains than micro-optimization.
What Good I/O Architecture Looks Like
A healthy ASP.NET Core I/O path has predictable resource usage.
It does not matter whether the application receives:
10 MBor:
10 GBif the workload can be processed incrementally and the amount held in memory remains bounded.
The ideal shape is:
Source
↓
Small bounded working set
↓
Processing
↓
Small bounded working set
↓
Destinationrather than:
Source
↓
EVERYTHING IN MEMORY
↓
Processing
↓
ANOTHER HUGE OBJECT
↓
DestinationThat difference often matters more than any low-level optimization.
Coming Next
In the next article, we’ll explore Native AOT in ASP.NET Core: Faster Startup, Smaller Containers, and the Trade-Offs.
So far, we have optimized what happens while an ASP.NET Core application is running.
Native AOT changes a different part of the performance story.
What if the application could start faster, use less memory, and ship without relying on JIT compilation at runtime?
That sounds ideal.
But Native AOT also changes assumptions around reflection, dynamic code generation, libraries, serialization, and deployment.
We’ll examine where Native AOT delivers real advantages, where compatibility becomes harder, and how to decide whether those trade-offs make sense for a production ASP.NET Core application.
Closing Thoughts
High-performance I/O is not really about streams or pipelines.
Those are tools.
The deeper problem is controlling how data moves through the application.
A scalable ASP.NET Core system should not need to load a giant request into memory just to process it. It should not block a Thread Pool thread while waiting for a network response. It should not repeatedly copy the same bytes between unnecessary intermediate buffers. And it should not continue processing expensive data after the caller has disappeared.
Streams give us a simple way to process data incrementally.
Asynchronous I/O prevents waiting operations from unnecessarily occupying threads.
PipeReader and PipeWriter provide more advanced buffer management for performance-sensitive workloads.
Bounded buffers prevent memory from growing without control.
Backpressure coordinates fast producers with slower consumers.
Cancellation stops work that no longer has value.
Together, these patterns change the way we think about large data.
Instead of asking:
How do we load this efficiently?ask:
Do we need to load it at all?Instead of asking:
How large should our buffer be?ask:
How many of these buffers can exist concurrently?Instead of asking:
Should we replace Stream with PipeReader?ask:
Where does profiling show that
buffer management or copying
is actually limiting us?That is the difference between performance optimization and performance theater.
The fastest byte is often the byte you never copy.
The cheapest buffer is often the buffer you never allocate.
And the most scalable large-data architecture is often the one that lets data flow through the application instead of forcing the application to hold all of it at once.
Subscribe Now
Enjoying the series? Subscribe to ASP Today for practical ASP.NET Core tutorials, advanced architecture deep dives, and production-ready .NET performance strategies. Join our Substack Chat to discuss streaming, pipelines, memory efficiency, performance tuning, and the engineering decisions behind fast, scalable ASP.NET Core systems.


