Why Is Your ASP.NET Core API Using So Much Memory? 7 Causes and Fixes

 

Why Is Your ASP.NET Core API Using So Much Memory? 7 Causes and Fixes


If your ASP.NET Core API is consuming more and more memory, you might immediately suspect a memory leak.

Sometimes you're right.

But here's the problem:

High memory usage doesn't always mean you have a memory leak.

An ASP.NET Core API can consume a surprising amount of RAM because of large object allocations, inefficient database queries, unbounded caching, excessive logging, incorrect lifetime management, or simply because the .NET Garbage Collector hasn't returned memory to the operating system yet.

I've seen developers try to fix these problems by increasing server RAM.

That's usually treating the symptom instead of the cause.

In this article, we'll look at 7 common reasons ASP.NET Core APIs use too much memory — and what you can do about them.





1. Loading Too Much Data Into Memory


One of the easiest ways to make an API consume memory is to retrieve an enormous amount of data from the database.

Consider this:

var customers = await dbContext.Customers
    .ToListAsync();

If your Customers table contains 500,000 records, you're asking your application to load all of them into memory.

And Entity Framework Core isn't just storing the raw database rows.

It may also create objects, track entities, maintain relationships, and allocate additional internal data structures.

The fix: paginate your queries

Instead of returning everything:

var customers = await dbContext.Customers
    .ToListAsync();

Use pagination:

var customers = await dbContext.Customers
    .AsNoTracking()
    .Skip((page - 1) * pageSize)
    .Take(pageSize)
    .ToListAsync();

Even better, only select the columns you actually need:

var customers = await dbContext.Customers
    .AsNoTracking()
    .Select(x => new CustomerDto
    {
        Id = x.Id,
        Name = x.Name,
        Email = x.Email
    })
    .Skip((page - 1) * pageSize)
    .Take(pageSize)
    .ToListAsync();

Why this helps

You're reducing:

  • Database data transferred

  • Number of objects created

  • Entity Framework tracking overhead

  • Garbage collection pressure

  • Peak memory usage

Rule of thumb: Don't make your API load data just because the database has it.


2. Using ToList() Too Early

This one is related to the previous problem, but it's common enough to deserve its own section.

Consider:

var users = dbContext.Users
    .Where(x => x.IsActive)
    .ToList()
    .Where(x => x.Age > 30)
    .ToList();

The first ToList() executes the database query and loads all active users into memory.

The second filter then happens in memory.

That's unnecessary.

Instead:

var users = await dbContext.Users
    .Where(x => x.IsActive && x.Age > 30)
    .ToListAsync();

Now the filtering can happen at the database level.

Why this matters

Imagine you have:

1,000,000 users
        ↓
Database
        ↓
500,000 active users
        ↓
ASP.NET Core memory
        ↓
Filter another 400,000 users

You're moving hundreds of thousands of objects into your application just to throw most of them away.

Let the database do the filtering whenever possible.


3. Your In-Memory Cache Has No Limit

Caching can dramatically improve API performance.

But caching everything can also dramatically increase memory usage.

For example:

private static readonly Dictionary<string, object> Cache = new();

And then:

Cache[key] = hugeObject;

If new entries are continuously added, your dictionary continues growing.

Nothing removes old entries.

Eventually:

Request
   ↓
Create object
   ↓
Add to cache
   ↓
Create another object
   ↓
Add to cache
   ↓
Repeat...
   ↓
Memory keeps increasing

This can look exactly like a memory leak.

The fix: use a bounded cache

ASP.NET Core provides IMemoryCache:

builder.Services.AddMemoryCache();

Then:

public class ProductService
{
    private readonly IMemoryCache _cache;

    public ProductService(IMemoryCache cache)
    {
        _cache = cache;
    }

    public Product GetProduct(int id)
    {
        return _cache.GetOrCreate(
            $"product:{id}",
            entry =>
            {
                entry.AbsoluteExpirationRelativeToNow =
                    TimeSpan.FromMinutes(10);

                return LoadProduct(id);
            });
    }
}

Now cached objects can expire.

For larger distributed applications, consider a distributed cache such as Redis instead of keeping everything inside each API process.


4. Creating Large Temporary Objects

Another common source of memory pressure is large temporary allocations.

For example:

var json = JsonSerializer.Serialize(hugeObject);

or:

var bytes = await File.ReadAllBytesAsync(filePath);

If you're processing a 500 MB file, this:

File.ReadAllBytesAsync(...)

can result in a very large allocation.

And if multiple requests do this simultaneously:

Request 1 → 500 MB
Request 2 → 500 MB
Request 3 → 500 MB
Request 4 → 500 MB

You've potentially created gigabytes of memory pressure.

The fix: stream large data

Instead of:

var bytes = await File.ReadAllBytesAsync(path);

use a stream:

await using var stream = File.OpenRead(path);

await stream.CopyToAsync(Response.Body);

Streaming allows your application to process data in smaller chunks instead of loading the entire payload into memory.

This is particularly important for:

  • Large file uploads

  • Large file downloads

  • Images

  • Videos

  • Reports

  • CSV exports

  • Large JSON payloads

If your API handles large data, think streams — not giant byte arrays.


5. Incorrect Dependency Injection Lifetimes

Dependency Injection lifetimes can also contribute to memory problems.

ASP.NET Core has three common service lifetimes:

Transient
Scoped
Singleton

A common mistake is registering something as a singleton when it shouldn't be.

For example:

builder.Services.AddSingleton<MyDataService>();

If MyDataService keeps references to large objects, those objects can remain alive for the entire lifetime of the application.

A singleton lives until the application shuts down.

Be careful with singleton state

This is risky:

public class MyDataService
{
    private readonly List<Customer> _customers = new();

    public void Add(Customer customer)
    {
        _customers.Add(customer);
    }
}

If the service is registered as a singleton:

builder.Services.AddSingleton<MyDataService>();

that list can grow indefinitely.

The fix

Choose the lifetime based on the actual behavior of the service.

For request-specific data, a scoped service is often more appropriate:

builder.Services.AddScoped<MyDataService>();

And avoid keeping large mutable collections inside long-lived services unless there is a deliberate reason to do so.


6. Excessive Logging

Logging is essential for production systems.

But logging enormous objects on every request isn't.

Imagine:

_logger.LogInformation(
    "Request payload: {@Request}",
    request);

If request contains thousands of properties, nested objects, or large collections, serialization and logging can generate significant allocations.

It can also increase:

  • CPU usage

  • Memory allocations

  • Log storage

  • Network traffic

Log what you actually need

Instead of:

_logger.LogInformation(
    "Request: {@Request}",
    request);

consider:

_logger.LogInformation(
    "Processing order {OrderId} for customer {CustomerId}",
    request.OrderId,
    request.CustomerId);

Structured logging gives you useful information without dumping the entire object graph.

Also be careful with:

LogLevel.Trace
LogLevel.Debug

in high-volume production APIs.

Your logging configuration should match your production requirements.


7. Assuming High Memory Means a Memory Leak

This is probably the most important point.

You start your API:

Memory: 150 MB

After some traffic:

Memory: 400 MB

Later:

Memory: 700 MB

You think:

"My application has a memory leak."

Not necessarily.

.NET uses a managed heap and Garbage Collection.

The runtime may reserve or retain memory for future allocations even after objects become eligible for collection.

So this:

Process memory
    ↑
    │       ┌────────────
    │      /
    │     /
    │____/
    └──────────────────→ Time

doesn't automatically prove that objects are continuously leaking.

The important question is:

Is the amount of live memory continuously increasing?

That's very different from:

Is the process working set increasing?


How Do You Actually Find the Problem?

Don't guess.

Measure it.

.NET provides several useful diagnostic tools.

dotnet-counters

You can monitor a running application using:

dotnet-counters monitor --process-id <PID>

Pay attention to metrics related to:

  • GC heap size

  • Allocation rate

  • Gen 0 collections

  • Gen 1 collections

  • Gen 2 collections

  • CPU usage

  • Exception rate

For example, if allocation rate is extremely high, your application may simply be creating too many temporary objects.


Take a Memory Dump

When you suspect an actual memory leak, a memory dump can be much more useful than staring at Task Manager.

You can collect a dump using:

dotnet-dump collect --process-id <PID>

Then analyze it:

dotnet-dump analyze <dump-file>

You can investigate which objects are consuming memory and whether unexpected references are keeping them alive.


A Simple Troubleshooting Checklist

When an ASP.NET Core API starts consuming too much memory, ask these questions:

Database

  • Are we loading too many rows?

  • Are we calling ToList() too early?

  • Are we using AsNoTracking() for read-only queries?

  • Are we selecting only the columns we need?

  • Do our endpoints support pagination?

Application

  • Are we creating large temporary objects?

  • Are large files being loaded entirely into memory?

  • Are there static collections growing indefinitely?

  • Are singleton services holding application state?

Caching

  • Does the cache have an expiration policy?

  • Is the cache bounded?

  • Are we caching unnecessarily large objects?

Logging

  • Are we logging entire request/response objects?

  • Are verbose log levels enabled in production?

Runtime

  • Is the managed heap actually growing?

  • Is GC working normally?

  • What do dotnet-counters and memory dumps show?


The Bigger Lesson

When an ASP.NET Core API uses a lot of memory, don't immediately throw more RAM at it.

First find out what is consuming the memory and why.

The most common causes are surprisingly ordinary:

  1. Loading too much database data

  2. Calling ToList() too early

  3. Unbounded in-memory caching

  4. Large temporary allocations

  5. Incorrect dependency injection lifetimes

  6. Excessive logging

  7. Misinterpreting normal .NET memory behavior as a memory leak

The good news is that most of these problems can be found with measurement rather than guesswork.

Your first step should be simple:

dotnet-counters monitor --process-id <PID>

Don't optimize what you haven't measured.

And don't call it a memory leak until you've proved that objects that should have died are actually staying alive.


Final Thought

A high-memory ASP.NET Core API isn't necessarily a broken API.

But a steadily growing heap, frequent Gen 2 collections, high allocation rates, and unexpected object retention are signals worth investigating.

The goal isn't to make your API use as little memory as possible.

The goal is to make it use memory predictably and efficiently.

That's a much more useful target for a production .NET application.


Want to learn more?

If you're interested in .NET performance, memory management, and backend engineering, follow me for more practical articles on C#, ASP.NET Core, .NET performance, APIs, and system design.


Home


Comments

Popular posts from this blog

How to Install .NET 10 SDK on Windows, Linux, and macOS

Microsoft Agent Framework Explained: A .NET Developer’s Architecture Guide