An ASP.NET Core application can have plenty of available CPU, a healthy database, and fast network connections yet still become slow under load because of memory. Every request creates objects, strings, arrays, JSON buffers, collections, and temporary data. Most disappear quickly, which is exactly what .NET’s garbage collector is designed to handle. Problems begin when an application allocates too much, creates large temporary objects, keeps data alive longer than necessary, or uses pooling without understanding the trade-offs.
In this guide, we’ll look inside .NET memory management, understand generations 0, 1, and 2, explore the Large Object Heap (LOH), identify allocation-heavy ASP.NET Core patterns, and use ArrayPool<T> and object pooling to reduce pressure where it matters.
Memory Problems Rarely Begin as “Out of Memory”
Imagine an ASP.NET Core API processing thousands of requests every second.
Each request allocates:
Request objects
Strings
JSON models
Collections
Temporary arrays
Database results
Response buffersNone seems particularly dangerous.
Suppose each request creates 50 KB of short-lived managed allocations.
At 5,000 requests per second:
50 KB × 5,000
≈ 250 MB allocated every secondThat does not necessarily mean the process permanently grows by 250 MB every second.
Most of those objects quickly become unreachable and can be reclaimed.
But somebody still has to clean them up.
That somebody is the garbage collector.
This is the first important distinction:
Memory usage and allocation rate are not the same thing.
An application might stabilize at a reasonable heap size while allocating enormous quantities of temporary memory.
The symptom may therefore be latency and CPU consumption rather than an obvious memory leak.
What the .NET Garbage Collector Actually Does
.NET uses automatic memory management.
When managed objects are created, the runtime allocates space for them on the managed heap. When objects are no longer reachable from application roots, the garbage collector can reclaim their memory. Microsoft describes the GC as the CLR’s automatic memory manager, responsible for allocation and reclamation of managed memory.
Conceptually:
Allocate object
↓
Use object
↓
References disappear
↓
Object becomes unreachable
↓
GC eventually reclaims memoryThat means we usually do not write:
free(customer);
free(order);
free(response);as we would with manual memory-management models.
This dramatically reduces an entire category of bugs.
But automatic does not mean free.
The garbage collector still consumes resources to determine which objects remain alive and reclaim memory from those that do not.
Allocation Is Designed to Be Fast
The managed heap is optimized for quick allocation.
At a simplified level, the runtime can often allocate a new object by advancing a pointer in available heap space. Microsoft notes that this makes managed-heap allocation extremely efficient.
Imagine:
| object | object | object | free space........ |
↑
allocation pointerCreate another object:
| object | object | object | NEW | free space... |
↑
allocation pointerThe problem is not usually:
Creating one object is slow.
The problem is:
Creating millions of unnecessary objects forces the memory-management system to work harder.
This distinction matters when optimizing ASP.NET Core.
Why the GC Uses Generations
Most objects in server applications have short lives.
A request arrives.
Objects are created.
The response is returned.
Those request-specific objects become unreachable.
.NET exploits this pattern through a generational garbage collector.
The managed heap has three logical generations:
Generation 0
Generation 1
Generation 2Generation 0 contains the youngest objects.
Generation 1 acts as an intermediate stage.
Generation 2 contains longer-lived objects.
Microsoft explains that this arrangement is based partly on the observation that newer objects generally have shorter lifetimes and that collecting only part of the heap is cheaper than repeatedly examining everything.
Generation 0: Where Most Temporary Objects Begin
Imagine an API request:
app.MapGet("/products", async (ProductService service) =>
{
var products = await service.GetProductsAsync();
var response = new ProductResponse
{
Items = products
};
return response;
});Several objects may exist only for that request.
Those short-lived allocations are exactly what generation 0 is designed to handle.
Conceptually:
Request arrives
↓
Temporary objects created
↓
Request completes
↓
Objects become unreachable
↓
Gen 0 collection
↓
Memory reclaimedGeneration 0 collections happen relatively frequently and are intended to be inexpensive compared with collecting older generations.
The best outcome for many temporary request objects is simple:
Die young.
Generation 1: The Waiting Room
What if an object survives a generation 0 collection?
It may be promoted to generation 1.
Generation 1 acts as a buffer between very short-lived objects and genuinely long-lived objects. If those objects survive further collection, they can eventually move to generation 2.
Think of it as:
Gen 0
"Probably temporary"
↓ survives
Gen 1
"Maybe longer-lived"
↓ survives
Gen 2
"Apparently this object is sticking around"Promotion itself is not automatically bad.
Long-lived objects are normal.
Configuration objects, singleton state, application caches, and other data may legitimately live for a long time.
Problems appear when objects survive longer accidentally.
Generation 2: Expensive Territory
Generation 2 contains long-lived managed objects.
A generation 2 collection is effectively a full managed-heap collection because younger generations are collected as well.
This matters for server applications.
Frequent Gen 0 collections may be perfectly normal.
Frequent Gen 2 collections deserve closer investigation.
They can indicate:
High allocation pressure
Too many surviving objects
Large object allocations
Oversized caches
Long-lived references
Memory pressureThe goal is not:
Never run Gen 2 GC.
That would be unrealistic.
The goal is to avoid application behavior that causes unnecessarily frequent expensive collections.
Garbage Collection Can Affect Latency
During parts of garbage collection, managed application threads may need to be suspended. Microsoft documents thread suspension as part of GC processing.
For an interactive web application, this makes memory a latency issue.
Imagine typical response latency:
p50: 45 ms
p95: 90 ms
p99: 130 msThen under high allocation pressure:
p50: 48 ms
p95: 160 ms
p99: 900 msAverage performance may still appear reasonable.
Users in the tail experience pauses.
That is why memory optimization is often about predictability, not merely reducing the number displayed in Task Manager.
Allocation Rate Is One of the Most Useful Signals
Suppose two services both use 1 GB of managed memory.
Service A allocates:
20 MB/secService B allocates:
1.5 GB/secThose applications have radically different memory behavior.
Heap size alone cannot explain it.
For performance investigations, useful signals include:
Allocation rate
Managed heap size
Gen 0 collections
Gen 1 collections
Gen 2 collections
LOH size
GC pause time
Time spent in GC
Process working setTools such as dotnet-counters, dotnet-trace, dotnet-gcdump, Visual Studio diagnostics, and profiling products can help identify where memory is being allocated and retained.
Measure first.
Do not begin by randomly converting code to pools and spans.
Temporary Allocations Hide in Innocent Code
Consider:
var message =
"Customer " + customer.Id +
" placed order " + order.Id +
" at " + DateTime.UtcNow;One execution is irrelevant.
A hot path executing millions of times may be different.
The same applies to:
LINQ projections
Repeated ToList()
String transformations
Serialization
Large arrays
Repeated buffer creation
Regex work
Collection copying
Intermediate DTOsNone of these APIs is inherently bad.
Context determines whether allocation matters.
Hot Paths Deserve Different Rules
Suppose this endpoint receives 20 requests per day:
POST /admin/rebuild-indexShaving three small allocations from it probably has no practical value.
Now consider:
GET /telemetrycalled 30,000 times per second.
A small per-request improvement can become significant.
This gives us a useful optimization rule:
Allocation cost = allocation size × execution frequency.
Do not optimize code because an allocation exists.
Optimize because measurements show the allocation occurs often enough to matter.
The Large Object Heap Changes the Picture
.NET treats large managed objects differently.
Objects at or above the LOH threshold, traditionally 85,000 bytes, are placed on the Large Object Heap. Microsoft documents that large objects are collected with generation 2 and are normally treated differently because moving large blocks of memory is expensive.
Typical examples include large arrays:
var buffer = new byte[100_000];or sufficiently large arrays of other types.
Conceptually:
Small allocations
↓
Small Object Heap
Large allocation
↓
Large Object HeapThe threshold is about object size in bytes, not whether the code looks complicated.
Why Large Objects Cost More
Imagine repeatedly doing:
var buffer = new byte[1_000_000];Process something.
Discard it.
Then repeat.
The object is temporary, but each allocation is roughly one megabyte.
At 1,000 operations per second:
~1 GB of large allocations every secondThis creates very different pressure from allocating tiny request objects.
Microsoft’s ASP.NET Core performance guidance specifically recommends minimizing large-object allocations in hot paths and notes that frequent large allocations can lead to expensive generation 2 collections and inconsistent performance.
The LOH Is Usually Not Compacted Like the Small Heap
Normal GC compaction can move surviving objects together to remove gaps.
Large objects are expensive to copy.
For that reason, the LOH is ordinarily swept rather than routinely compacted. Freed regions can later be reused for other allocations. .NET does provide mechanisms for requesting LOH compaction in specific scenarios, but routine manual compaction is not a substitute for fixing an unhealthy allocation pattern.
Imagine:
AAAA BBBBBBB CCCC DDDDDDSome large objects die:
AAAA ........ CCCC ......Those gaps can be reused.
But repeated differently sized allocations can make memory behavior less efficient.
This is one reason LOH fragmentation may become relevant in allocation-heavy applications.
A Common ASP.NET Core Example: Large Buffers
Suppose an application receives large files.
A naive implementation might repeatedly create large arrays:
var buffer = new byte[1024 * 1024];
while (true)
{
var read = await stream.ReadAsync(
buffer,
cancellationToken);
if (read == 0)
break;
await ProcessAsync(
buffer.AsMemory(0, read),
cancellationToken);
}The buffer itself is one megabyte.
If this allocation happens once for an occasional operation, no crisis exists.
If thousands of concurrent requests repeatedly create similar buffers, allocation pressure becomes significant.
This is where pooling becomes interesting.
ArrayPool<T>: Rent Instead of Repeatedly Allocate
.NET provides ArrayPool<T> for scenarios where arrays can be reused.
Instead of:
var buffer = new byte[1024 * 1024];we can rent:
using System.Buffers;
var pool = ArrayPool<byte>.Shared;
var buffer = pool.Rent(1024 * 1024);
try
{
// Use the buffer.
}
finally
{
pool.Return(buffer);
}The important change is conceptual.
Without pooling:
Allocate
Use
Discard
Allocate
Use
Discard
Allocate
Use
DiscardWith pooling:
Rent
Use
Return
↓
Rent againMicrosoft’s ASP.NET Core performance guidance specifically recommends ArrayPool<T> as one option for reducing repeated large-array allocations.
Pooling Does Not Mean “Free Memory”
This is a crucial distinction.
When an array is returned to a pool, we are not necessarily returning its memory to the operating system.
We are making that array available for reuse.
That means pooling trades:
Fewer repeated allocationsfor:
Reusable memory retained by the poolThat can be a very good trade in a hot path.
But pooling everything can increase retained memory and complexity without improving meaningful performance.
Always Return What You Rent
This is wrong:
var buffer =
ArrayPool<byte>.Shared.Rent(1024 * 1024);
await ProcessAsync(buffer);
// Forgot Return()The pool loses the opportunity to reuse that buffer efficiently.
Use try/finally:
var buffer =
ArrayPool<byte>.Shared.Rent(1024 * 1024);
try
{
await ProcessAsync(buffer);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}The ownership rule should be obvious:
The code that rents the buffer must ensure it is returned exactly once after nobody needs it.
Never Use a Buffer After Returning It
Consider:
ArrayPool<byte>.Shared.Return(buffer);
await SendLaterAsync(buffer);That is dangerous.
After Return, the array no longer belongs exclusively to you.
Another operation may rent the same array and modify it.
This can create bugs that appear only under concurrency.
The safe mental model is:
Rent
↓
You own temporary access
↓
Finish all usage
↓
Return
↓
Do not touch againPooling makes lifetime discipline more important.
Sensitive Data Requires Extra Thought
Suppose a pooled array temporarily contains:
Authentication tokens
Personal information
Payment-related data
SecretsReturning the array does not inherently mean every byte has been erased.
ArrayPool<T>.Return supports clearing an array when appropriate:
ArrayPool<byte>.Shared.Return(
buffer,
clearArray: true);Clearing has a cost.
Whether it is necessary depends on the data, security boundary, and pool usage.
Performance optimization should never quietly create a data-exposure problem.
ObjectPool<T> Extends the Same Idea
Arrays are not the only reusable objects.
ASP.NET Core provides object-pooling infrastructure for objects that are expensive to create and can safely be reset and reused.
Conceptually:
Create expensive object
Use
Discard
Create expensive object
Use
Discardbecomes:
Get from pool
Use
Reset
Return
Get from pool
Use
Reset
ReturnA simplified example might involve a reusable StringBuilder:
public sealed class StringBuilderPolicy
: PooledObjectPolicy<StringBuilder>
{
public override StringBuilder Create()
=> new(capacity: 1024);
public override bool Return(
StringBuilder builder)
{
if (builder.Capacity > 64 * 1024)
return false;
builder.Clear();
return true;
}
}Registration could look like:
builder.Services.AddSingleton<ObjectPool<StringBuilder>>(
serviceProvider =>
{
var provider =
new DefaultObjectPoolProvider();
return provider.Create(
new StringBuilderPolicy());
});Then:
var builder = pool.Get();
try
{
builder.Append("Order ");
builder.Append(order.Id);
return builder.ToString();
}
finally
{
pool.Return(builder);
}The policy decides whether an object is healthy enough to reuse.
Why Reject an Oversized Pooled Object?
Look at this line:
if (builder.Capacity > 64 * 1024)
return false;Imagine one unusual request forces the builder to grow enormously.
If we blindly return it to the pool, the application may retain that oversized buffer for future tiny requests.
Pooling can therefore accidentally turn a temporary memory spike into longer-lived memory.
A good pool policy asks:
Is this object worth retaining?
Sometimes the correct answer is no.
Do Not Pool Cheap Objects
Suppose we have:
public sealed class Coordinate
{
public int X { get; set; }
public int Y { get; set; }
}Creating one is cheap.
Adding pooling introduces:
Pool management
Reset logic
Ownership rules
Concurrency considerations
More complicated code
Potential stale statePooling is not automatically an optimization.
It is most useful when objects are:
Expensive to allocate or initialize
Created very frequently
Reusable safely
Easy to reset
Measured as a meaningful allocation costThe last condition is the most important.
Reuse Can Create State Bugs
Suppose:
public sealed class WorkBuffer
{
public string? CustomerId { get; set; }
public List<string> Items { get; } = new();
}We return it to a pool without resetting it.
The next request rents it.
Now:
Previous CustomerId remains
Previous Items remainThat is both a correctness problem and potentially a privacy problem.
Every pooled type needs a clear reset contract.
If resetting it correctly is complicated, pooling may not be worth the risk.
IDisposable Solves a Different Problem
Memory management discussions often confuse GC with IDisposable.
Suppose:
using var stream = File.OpenRead(path);Why dispose the stream if .NET has garbage collection?
Because the GC manages managed memory.
Some objects wrap resources such as:
File handles
Sockets
Native memory
Operating-system handlesThose resources often need deterministic release.
Microsoft recommends explicit cleanup for unmanaged resources, commonly through Dispose, even though the managed wrapper itself remains subject to garbage collection.
So:
GC
→ managed memory lifetime
Dispose
→ deterministic resource cleanupThey solve related but different problems.
A Managed Memory Leak Is Still Possible
Garbage collection does not mean memory leaks are impossible.
The GC reclaims objects that are unreachable.
If your application accidentally keeps references to objects, the GC correctly assumes they are still needed.
For example:
private static readonly List<byte[]> _history = new();
public void Store(byte[] data)
{
_history.Add(data);
}If nothing removes entries:
Request
↓
Array added
↓
Static list keeps reference
↓
Object remains reachable
↓
GC cannot reclaim itThat is a managed memory leak.
The GC is doing exactly what it should.
The application is telling it the objects remain alive.
Common Sources of Retention
Look carefully at:
Static collections
Unbounded caches
Event subscriptions
Long-lived delegates
Queues
Background work
Singletons holding request data
Large object graphs
Improperly managed timersAgain, the important question is not:
Who allocated this object?
It is often:
Who is still referencing it?
Memory profilers are particularly useful here because they can show paths from GC roots to objects that should have disappeared.
Caches Are Deliberate Memory Retention
Caching improves performance by keeping data available.
That means a cache is intentionally preventing objects from being collected.
This can be useful:
Database query
↓
Expensive result
↓
Cache
↓
ReuseBut an unbounded cache can become a memory problem.
ASP.NET Core’s IMemoryCache supports mechanisms for setting entry sizes and configuring a size limit when the application needs explicit cache-size accounting. Microsoft also warns that applications should not depend on cached data for correctness.
A cache needs a policy.
Ask:
How much can it hold?
When do entries expire?
What happens under memory pressure?
Are entries bounded by count or size?
Can unusually large entries enter the cache?“It’s cached” is not a memory-management strategy.
Streaming Can Beat Buffering
Suppose an endpoint generates a 200 MB export.
One approach:
Load everything
↓
Build giant in-memory representation
↓
Serialize everything
↓
Return responseMemory consumption may scale with the entire response.
A streaming design can instead process smaller pieces:
Read chunk
↓
Write chunk
↓
Release/reuse buffer
↓
Read next chunkPeak memory can remain far lower.
This is one reason our next article on High-Performance I/O in ASP.NET Core naturally follows this one.
Memory and I/O design are deeply connected.
Avoid Unnecessary Copies
Consider a large payload passing through several layers:
Network buffer
↓ copy
byte[]
↓ copy
MemoryStream
↓ copy
serializer buffer
↓ copy
responseEvery copy consumes memory bandwidth and may create additional allocations.
Modern .NET APIs increasingly use abstractions such as:
Span<T>
Memory<T>
ReadOnlySpan<T>
ReadOnlyMemory<T>to work with existing memory regions without always creating new arrays.
These tools are powerful, but they should solve a measured problem.
Turning ordinary application code into complex span-based code without evidence rarely improves maintainability.
Avoid Materializing Data Too Early
Suppose:
var records = await query.ToListAsync();loads 500,000 records.
If we only need to process them sequentially, holding the entire result set may be unnecessary.
A streaming or batched approach may provide:
Lower peak memory
Shorter object lifetimes
Less GC pressure
More predictable behaviorThe same principle applies to:
File processing
Database exports
JSON processing
Message batches
Analytics pipelinesAsk whether the entire dataset genuinely needs to exist in memory at once.
String Work Can Be Surprisingly Expensive
Web applications process strings constantly:
URLs
Headers
JSON
Logs
Identifiers
HTML
Database valuesRepeated transformations can generate temporary objects.
For example:
var normalized =
input.Trim()
.ToLowerInvariant()
.Replace(" ", "-");That is perfectly acceptable in most ordinary code.
But if profiling identifies it as a dominant allocation source in a very hot path, it may deserve attention.
Do not prematurely optimize every string operation.
Use profiling to find the few that matter.
Logging Can Create Allocation Pressure
Logging is essential, but high-volume logging deserves design attention.
Imagine:
_logger.LogInformation(
$"Processing order {order.Id} for {customer.Name}");String interpolation may perform work before the logger decides whether the message will actually be emitted.
Prefer structured logging:
_logger.LogInformation(
"Processing order {OrderId} for {CustomerName}",
order.Id,
customer.Name);For extremely hot logging paths, .NET logging also supports high-performance patterns such as source-generated logging.
The broader lesson is familiar:
Frequently executed infrastructure code matters because tiny costs multiply.
Do Not Call GC.Collect() as a Routine Fix
A developer sees memory usage increase and writes:
GC.Collect();The graph drops.
Problem solved?
Usually not.
Microsoft’s GC documentation says explicit GC.Collect calls are unnecessary in almost all normal cases because the collector determines when collections should occur.
Forced collection can:
Trigger expensive work prematurely
Promote surviving objects
Disturb the collector's adaptive behavior
Create latency spikes
Hide the actual retention problemThere are specialized cases where manual GC control is justified.
A normal ASP.NET Core request path is rarely one of them.
High Memory Usage Is Not Automatically a Leak
Suppose process memory rises from:
300 MBto:
1.2 GBand stays there.
That alone does not prove a leak.
The runtime may retain memory for efficient future allocations.
Pools may retain reusable buffers.
Caches may legitimately hold data.
The operating system and GC may manage committed and reserved memory differently from what a simple process-memory graph suggests.
A stronger leak pattern looks like:
Load
↓
Memory rises
↓
Load falls
↓
Live managed data never stabilizes
↓
Repeat
↓
Baseline continues risingYou need heap and retention evidence, not merely a frightening graph.
Containers Make Memory Limits More Important
ASP.NET Core applications increasingly run inside containers.
A container may have a strict memory limit even if the physical machine has far more memory.
The .NET GC has runtime settings for memory constraints, including hard heap limits and percentage-based limits. Current .NET documentation also describes LOH behavior in memory-constrained environments.
The practical lesson is:
Test the application inside realistic memory limits.
A service that behaves beautifully on a developer workstation with 64 GB of RAM may behave very differently inside a tightly constrained production container.
Server GC and Workstation GC
.NET supports different GC modes designed for different workload characteristics.
ASP.NET Core server workloads commonly operate with server-oriented GC behavior, which is designed for throughput and multiprocessor server scenarios.
But configuration is not something to change casually.
GC mode affects:
Heap organization
Concurrency
Throughput
Memory consumption
Pause behaviorBefore tuning runtime GC configuration, first fix obvious application-level problems:
Unbounded retention
Huge temporary buffers
Excessive allocation rates
Poor cache limits
Needless materialization
Missing pooling in proven hot pathsChanging GC switches cannot compensate for fundamentally wasteful code.
Measure Before and After Pooling
Suppose profiling shows:
Allocation rate: 900 MB/secand 500 MB/sec comes from temporary 256 KB buffers.
That is an excellent pooling candidate.
After introducing ArrayPool<byte>:
Allocation rate: 420 MB/secNow we have evidence.
Compare that with:
Allocation rate: 25 MB/secand a developer spends two days pooling tiny DTOs to reduce it to:
24.7 MB/secThe optimization increased complexity for almost no practical gain.
Performance engineering needs economics.
A Practical Memory-Safe Processing Pipeline
Imagine an ASP.NET Core endpoint that processes large uploaded datasets.
A naive design:
Upload
↓
Read entire file into byte[]
↓
Convert entire file to string
↓
Parse complete document
↓
Build giant List<Record>
↓
Transform everything
↓
Serialize complete result
↓
ReturnPeak memory can become several times larger than the original file.
A more disciplined design:
Upload stream
↓
Rent bounded buffer
↓
Read chunk
↓
Parse incrementally
↓
Process records
↓
Write result incrementally
↓
Reuse buffer
↓
Return bufferNow memory usage is related more closely to working-set size than total input size.
That is a major architectural improvement.
Memory Management Connects to Backpressure
Two articles ago, we explored Designing Backpressure in ASP.NET Core.
The connection is direct.
Suppose each active request needs 5 MB of working memory.
At:
20 concurrent requestswe may need roughly:
100 MBfor that working data.
At:
2,000 concurrent requeststhe same design theoretically demands:
10 GBbefore considering the rest of the application.
Reducing allocations helps.
But sometimes the correct solution is not to make each request 10% cheaper.
It is to limit concurrency.
Memory is another finite resource that backpressure protects.
Memory Management Connects to Graceful Shutdown
Our previous article covered Graceful Shutdown and Application Lifecycle in ASP.NET Core.
Memory behavior affects lifecycle too.
Suppose an instance is under extreme memory pressure when deployment begins.
Thousands of queued operations hold large buffers.
Background workers are processing oversized batches.
The application now needs to drain before termination.
A bounded, memory-conscious architecture:
Has fewer outstanding operations
Uses controlled buffer sizes
Limits concurrent work
Keeps queues boundedand therefore shuts down more predictably.
Performance, resilience, and lifecycle management are not separate concerns.
They reinforce one another.
A Practical Investigation Workflow
Suppose production shows periodic latency spikes and high memory usage.
Do not immediately rewrite the application.
Start with questions.
1. Is memory actually growing continuously?
Observe the baseline over time.
2. What is the allocation rate?
High churn may matter even if heap size is stable.
3. Which generations are collecting?
Frequent Gen 2 activity deserves investigation.
4. Is the LOH growing or churning?
Look for large arrays, buffers, and serialized payloads.
5. Which types dominate allocations?
Profile them.
6. Which objects dominate retained memory?
Allocation and retention are different problems.
7. Why are retained objects still reachable?
Follow references back to GC roots.
8. Are caches and queues bounded?
Unbounded structures are frequent offenders.
9. Can large workloads stream instead of buffer?
Often, this creates bigger gains than micro-optimizations.
10. Would pooling materially reduce a measured hot allocation?
Only then introduce the additional lifetime complexity.
This process turns “our app uses too much memory” into an engineering problem we can actually solve.
Common Memory Management Mistakes
Several mistakes appear repeatedly in ASP.NET Core applications.
Optimizing every allocation. Most allocations are harmless. Focus on hot paths.
Assuming GC means leaks cannot happen. Reachable objects cannot be reclaimed.
Repeatedly allocating large arrays. LOH churn can create expensive GC behavior.
Using GC.Collect() as routine maintenance. It usually treats the symptom, not the cause.
Pooling everything. Pools add ownership, reset, security, and retention complexity.
Forgetting to return rented buffers. Pooling only works when lifetimes are controlled.
Using pooled memory after returning it. Another caller may already own it.
Keeping giant pooled objects forever. Reuse can accidentally increase retained memory.
Creating unbounded caches. A cache is deliberate memory retention and needs limits.
Loading entire datasets unnecessarily. Streaming and batching often reduce peak memory dramatically.
Watching only process memory. Allocation rate, live heap, generations, LOH, and GC pauses tell a richer story.
What Good Memory Behavior Looks Like
A healthy ASP.NET Core service does not necessarily use very little memory.
A service with 4 GB available may deliberately use a meaningful portion of it for caches and runtime efficiency.
Healthy behavior looks more like:
Predictable working set
Bounded caches
Bounded queues
Stable live heap
Controlled LOH usage
Reasonable allocation rate
Infrequent expensive collections
Low GC pause impact
No continuous unexplained growthMemory exists to be used.
The objective is not:
Make the graph as low as possible.
It is:
Use memory deliberately enough that performance remains predictable under realistic load.
Coming Next
In the next article, we’ll explore High-Performance I/O in ASP.NET Core: Streams, Pipelines, and Efficient Data Processing.
Memory management taught us why repeatedly allocating and copying large buffers can become expensive.
The natural next question is:
How can we move large amounts of data without constantly creating those buffers in the first place?
We’ll look at streaming, Stream, PipeReader, PipeWriter, buffering, backpressure within I/O pipelines, large request and response bodies, and the patterns that help ASP.NET Core process data efficiently without loading everything into memory.
Closing Thoughts
.NET’s garbage collector is extremely good at managing memory, but it cannot decide whether your application should have created an object in the first place.
That distinction sits at the heart of ASP.NET Core memory performance.
Generation 0 makes short-lived allocations inexpensive.
Generations 1 and 2 allow the runtime to treat surviving objects differently.
The Large Object Heap handles allocations that are expensive to move.
ArrayPool<T> and object pools allow selected expensive objects to be reused.
Streaming can prevent giant intermediate representations.
Bounded caches and queues prevent intentional retention from becoming uncontrolled growth.
None of these tools means that allocation itself is bad.
In fact, ordinary managed allocation is one of .NET’s strengths.
The problem is unnecessary allocation at scale.
A few temporary objects in an endpoint called once per minute do not deserve heroic optimization. The same pattern executed tens of thousands of times per second may deserve serious attention.
So when an ASP.NET Core application’s memory graph starts looking uncomfortable, do not begin with:
How do we force the GC to clean this up?Begin with:
What are we allocating?
How often are we allocating it?
How long does it stay alive?
Why does it stay alive?
Does the whole object need to exist?
Could the data be streamed?
Could an expensive buffer be safely reused?
Is concurrency creating more live memory than the process can support?Those questions lead to durable improvements.
The best memory optimization is often not making the garbage collector work faster.
It is giving the garbage collector less unnecessary work to do.
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 garbage collection, memory diagnostics, application performance, and the engineering decisions behind fast, reliable ASP.NET Core systems.


