Meetune is my event discovery app, and its backend is C# on .NET 8 with EF Core over PostgreSQL. The event feed felt fine with a handful of test rows and then got visibly sluggish the moment the database had realistic data in it. The culprit was the most common performance bug in any ORM: the N+1 query.

What N+1 looks like

You fetch a list of events. Then, for each event, your code touches a related property, its venue, its attendees, its tags, and EF quietly fires a separate query to load it. One query for the list, then N more, one per row. With ten events it is invisible. With a few hundred it is a wall of round trips to the database, and your response time scales with your data instead of staying flat.

The tell is in the logs. Turn on EF Core's query logging and you will see the same parameterized SELECT repeated over and over with a different id each time. Once you have seen that pattern you never un-see it.

N+1: 1 + N round trips API PostgreSQL ...N projection: 1 round trip API PostgreSQL SELECT + JOIN + COUNT
same endpoint, same data: the difference is how many times you cross the network

The fix that people reach for first

The instinct is to add eager loading:

var events = await db.Events
    .Include(e => e.Venue)
    .Include(e => e.Attendees)
    .ToListAsync();

This collapses the N+1 into fewer queries, which is a real improvement. But it also drags every column of every included entity across the wire, including a lot of data the feed never displays. For a list endpoint that only needs a name, a date, and an attendee count, you are paying to hydrate full object graphs you will throw away.

The fix I actually shipped: projection

Instead of loading entities and then shaping them, I project straight into the shape the endpoint returns, and let the database do the counting:

var events = await db.Events
    .Select(e => new EventFeedItem {
        Id = e.Id,
        Title = e.Title,
        StartsAt = e.StartsAt,
        VenueName = e.Venue.Name,
        AttendeeCount = e.Attendees.Count
    })
    .ToListAsync();

Now EF generates a single SQL statement with a join and an aggregate, and it selects only the five fields the feed needs. No entity tracking, no wasted columns, no N+1. The response time stopped scaling with the row count and went flat, which is the whole point.

The lesson

Eager loading fixes the query count. Projection fixes the query count and the payload. When an endpoint returns a read-only view, project into a purpose-built shape rather than loading full entities you will only partially use. And whatever you do, watch the generated SQL. The ORM is convenient precisely because it hides the database from you, which is also exactly why it will happily hide a performance problem until real data shows up.