DEV Community

Cover image for I Made Our Attendance API 75% Faster By Fixing One Stupid Mistake
Abdul Imran Faridh
Abdul Imran Faridh

Posted on

I Made Our Attendance API 75% Faster By Fixing One Stupid Mistake

Originally published at imrantech.hashnode.dev

It Started With Complaints

I work on an HRMS platform and our HR team kept telling me the attendance page was too slow. I ignored it the first couple of times — I thought maybe it was their internet connection or a slow PC. Then my manager asked me about it.

Okay fine. Time to actually look at this.

The attendance API was taking over 1 minute to return data for 6500 employees. One full minute. You could make a coffee and come back and it still wouldn't be done.

I Looked at the Wrong Place First

My first instinct was to look at the main SQL query. I had JOINs, GROUP BY, filters — surely something there was wrong.
But honestly? The query looked fine. I checked it three times.

I was confused. The SQL was clean. The database had indexes. So why was it so slow?

I almost opened a Stack Overflow tab. But then I added a simple Stopwatch log

`var sw = System.Diagnostics.Stopwatch.StartNew();

foreach (var item in data) {

item.EmpCategoryName = AppConfig.GetAppConfigDescription(item.EmpCategoryID);

}

sw.Stop(); LogError.WriteErrorLog("foreach time: " + sw.ElapsedMilliseconds + "ms");`
Enter fullscreen mode Exit fullscreen mode

The log showed 45+ seconds just for the foreach loop.

Wait. What.

The Real Culprit

Here's the original code I had written:

This looked innocent. It was not.

foreach (var item in data) {

item.EmpCategoryName = AppConfig.GetAppConfigDescription(item.EmpCategoryID);

}

Enter fullscreen mode Exit fullscreen mode

See the problem?

GetAppConfigDescription() was hitting the database on every single loop iteration.

If grouped data returned 150 rows — that's 150 separate database calls. Just to get category names. I was genuinely embarrassed when I realized this.

This is the N+1 problem. And it was hiding in a tiny helper method that looked completely harmless.

Here's what my full original code looked like:

public IList GetEmpAttenDanceDetails(GetNewAttenListDto obj)

{ try

{

using (var ctx = new hrms2Context())

{ var locationIds = obj.LocIds; var departmentIds = obj.DepartIds;

        var selectMonths = AppconfigtableList
            .Where(a => a.Source == "AttendanceDisplayMonth")
            .Select(o => o.Value)
            .FirstOrDefault();

        var today = DateTime.Now.Date;
        var fromMonth = today.AddMonths(-(Convert.ToInt32(selectMonths) - 1));
        var startDate = new DateTime(fromMonth.Year, fromMonth.Month, 1);
        var endDate = today;

        var data = ctx.tEmpAttendance
            .Join(ctx.tEmployees,
                a => a.EmpIoyeeID,
                b => b.ID,
                (a, b) => new { a, b })
            .Join(ctx.tLocations,
                ab => ab.b.EmpLocationID,
                d => d.ID,
                (ab, d) => new { ab, d })
            .Where(w =>
                w.d.IsActive &&
                w.ab.b.IsActive &&
                locationIds.Contains((int)w.ab.b.EmpLocationID) &&
                departmentIds.Contains((int)w.ab.b.DepartmentID) &&
                w.ab.a.AttendDate >= startDate &&
                w.ab.a.AttendDate < endDate.AddDays(1))
            .GroupBy(g => new
            {
                Year = g.ab.a.AttendDate.Year,
                Month = g.ab.a.AttendDate.Month,
                Category = g.ab.b.EmpCategoryID,
                Location = g.ab.b.EmpLocationID,
                LocationName = g.d.LocationName
            })
            .Select(s => new GetAllAttendanceDetailsDTO
            {
                AttendanceYear = s.Key.Year.ToString(),
                AttendanceMonth = new DateTime(s.Key.Year, s.Key.Month, 1)
                                    .ToString("MMMM"),
                AttendanceMonthInt = s.Key.Month,
                EmpCategoryID = s.Key.Category,
                LocationID = s.Key.Location,
                LocationName = s.Key.LocationName
            })
            .OrderByDescending(o => o.AttendanceYear)
            .ThenByDescending(o => o.AttendanceMonthInt)
            .ToList();

        // THE PROBLEM WAS HERE ALL ALONG
        foreach (var item in data)
        {
            item.EmpCategoryName = 
                AppConfig.GetAppConfigDescription(item.EmpCategoryID);
        }

        return data;
    }
}
catch (Exception ex)
{
    LogError.WriteErrorLog(
        "AttendanceNewService- GetEmpAttenDanceDetails : " + ex.Message);
    throw ex;
}

}
Enter fullscreen mode Exit fullscreen mode

The main query? Perfect. The foreach at the bottom? Disaster.

The Fix Was Actually Simple

Once I understood the problem, the fix was straightforward.

Instead of calling the database on every loop iteration, I pre-fetch all category names once into a dictionary. Then inside the loop I just do a simple dictionary lookup — no database involved at all.

// Pre-fetch all category names in ONE call

var categoryIds = data .Select(d => d.EmpCategoryID) .Distinct() .ToList();

var categoryDict = AppconfigtableList .Where(a => categoryIds.Contains(a.ID)) .ToDictionary(a => a.ID, a => a.Description);

// Dictionary lookup — zero database calls

foreach (var item in data) { item.EmpCategoryName = categoryDict .GetValueOrDefault(item.EmpCategoryID ?? 0); }
Enter fullscreen mode Exit fullscreen mode

That's it. Two small changes. No fancy libraries. No major refactoring.

Here's the full fixed version:

public IList GetEmpAttenDanceDetails(GetNewAttenListDto obj)

{

try

{

using (var ctx = new hrms2Context())

{

var locationIds = obj.LocIds; var departmentIds = obj.DepartIds;

        var selectMonths = AppconfigtableList
            .Where(a => a.Source == "AttendanceDisplayMonth")
            .Select(o => o.Value)
            .FirstOrDefault();

        var today = DateTime.Now.Date;
        var fromMonth = today.AddMonths(-(Convert.ToInt32(selectMonths) - 1));
        var startDate = new DateTime(fromMonth.Year, fromMonth.Month, 1);
        var endDate = today;

        var data = ctx.tEmpAttendance
            .Join(ctx.tEmployees,
                a => a.EmpIoyeeID,
                b => b.ID,
                (a, b) => new { a, b })
            .Join(ctx.tLocations,
                ab => ab.b.EmpLocationID,
                d => d.ID,
                (ab, d) => new { ab, d })
            .Where(w =>
                w.d.IsActive &&
                w.ab.b.IsActive &&
                locationIds.Contains((int)w.ab.b.EmpLocationID) &&
                departmentIds.Contains((int)w.ab.b.DepartmentID) &&
                w.ab.a.AttendDate >= startDate &&
                w.ab.a.AttendDate < endDate.AddDays(1))
            .GroupBy(g => new
            {
                Year = g.ab.a.AttendDate.Year,
                Month = g.ab.a.AttendDate.Month,
                Category = g.ab.b.EmpCategoryID,
                Location = g.ab.b.EmpLocationID,
                LocationName = g.d.LocationName
            })
            .Select(s => new GetAllAttendanceDetailsDTO
            {
                AttendanceYear = s.Key.Year.ToString(),
                AttendanceMonth = new DateTime(s.Key.Year, s.Key.Month, 1)
                                    .ToString("MMMM"),
                AttendanceMonthInt = s.Key.Month,
                EmpCategoryID = s.Key.Category,
                LocationID = s.Key.Location,
                LocationName = s.Key.LocationName
            })
            .OrderByDescending(o => o.AttendanceYear)
            .ThenByDescending(o => o.AttendanceMonthInt)
            .ToList();

        // Pre-fetch once
        var categoryIds = data
            .Select(d => d.EmpCategoryID)
            .Distinct()
            .ToList();

        var categoryDict = AppconfigtableList
            .Where(a => categoryIds.Contains(a.ID))
            .ToDictionary(a => a.ID, a => a.Description);

        // In-memory lookup
        foreach (var item in data)
        {
            item.EmpCategoryName = categoryDict
                .GetValueOrDefault(item.EmpCategoryID ?? 0);
        }

        return data;
    }
}
catch (Exception ex)
{
    LogError.WriteErrorLog(
        "AttendanceNewService- GetEmpAttenDanceDetails : " + ex.Message);
    throw ex;
}

}
Enter fullscreen mode Exit fullscreen mode

Before vs After

Before After
Employees 6500 6500
Response Time ~1 minute ~15 seconds
DB calls in loop 150+ 0
Improvement 75% faster

The HR team didn't even say thank you. They just stopped complaining. That felt like a win. 😄

What I Learned From This A few things I'll never forget after this:

The main query is not always the problem. I wasted time staring at perfectly fine SQL while the real issue was a tiny method call at the bottom.

Never trust what looks innocent. That one-liner GetAppConfigDescription() looked completely harmless. It wasn't.

Stopwatch logs are your best friend. Adding timing around different sections of code immediately told me where the problem was. I should have done that first.

Pre-fetch, then lookup. Any time you need external data inside a loop — fetch it all before the loop, store in a dictionary, and use .GetValueOrDefault() inside.

Wrapping Up

The N+1 problem doesn't always look like a classic SELECT inside a loop. Sometimes it hides inside a helper method that calls the database quietly in the background.

If your API is slow and you can't figure out why — add Stopwatch logs around each section. The numbers will tell you exactly where to look.

Hope this saves someone a few hours of frustration! If it did, consider subscribing — I write 3 practical .NET articles every week based on real things I've actually fixed in production.

Top comments (0)