DEV Community

Cover image for How I Fixed a 20-Second API Response Time Using Redis
RAGU N
RAGU N

Posted on

How I Fixed a 20-Second API Response Time Using Redis

A real performance engineering story from production — with code, metrics, and lessons learned.


The Problem Nobody Wanted to Debug

It started with a Teams message from the testing team: "The dashboard is taking forever to load. Users are complaining."

When I pulled up the API trace, I saw it: a single endpoint taking 18–22 seconds on high-traffic workflows. This was a critical dashboard API that aggregated data from multiple downstream services — and it was being called on every page load.

Users weren't just frustrated. Some were timing out entirely and seeing error screens. This needed to be fixed, and fast.

🔴 The Situation: A .NET Core API endpoint was taking 20+ seconds on high-traffic workflows. Each request made sequential downstream calls and re-fetched the same data on every hit — no caching, no parallelism.


Diagnosing the Root Cause

Before jumping to solutions, I traced exactly what was happening inside the slow endpoint. Here's what I found:

1. Sequential downstream calls

The API was calling 4 downstream microservices one after another, even though the results were completely independent. Total sequential wait time: ~14–16 seconds.

2. Zero caching — same queries, over and over

For data that barely changed (config values, user permission sets), the API was hitting the database and downstream services on every single request. No cache whatsoever.

3. Unoptimised PostgreSQL queries

Some queries lacked proper indexes and were doing full table scans on large datasets — adding another 3–5 seconds to the total.

💡 Lesson: Most performance problems aren't caused by one thing. It was a combination — sequential calls + no caching + slow queries. Fix all three, not just the obvious one.


The Fix: Redis Caching + Parallel Async Execution

The solution had two parts working together: parallelise the downstream calls, and cache the results with Redis so repeat requests skip the expensive work entirely.


Part 1 — Parallel Async Execution

The biggest win came from running independent downstream calls in parallel instead of sequentially. In .NET Core, this is clean with Task.WhenAll:

❌ Before — Sequential (slow)

// Each line waits for the previous one to finish
var userData     = await _userService.GetUserDataAsync(userId);
var permissions  = await _authService.GetPermissionsAsync(userId);
var metrics      = await _metricsService.GetMetricsAsync(userId);
var config       = await _configService.GetConfigAsync();
// Total: ~14-16s (sum of all calls)
Enter fullscreen mode Exit fullscreen mode

✅ After — Parallel (fast)

// All fire simultaneously — total time = slowest single call
var userTask        = _userService.GetUserDataAsync(userId);
var permissionsTask = _authService.GetPermissionsAsync(userId);
var metricsTask     = _metricsService.GetMetricsAsync(userId);
var configTask      = _configService.GetConfigAsync();

await Task.WhenAll(userTask, permissionsTask, metricsTask, configTask);

var userData    = await userTask;
var permissions = await permissionsTask;
var metrics     = await metricsTask;
var config      = await configTask;
// Total: ~3-4s instead of 14-16s
Enter fullscreen mode Exit fullscreen mode

This alone cut response time dramatically — from the sum of all calls to just the slowest individual call.


Part 2 — Redis Caching Layer with TTL Strategy

Parallelism helped with the first request. But for repeat requests — config lookups and permissions that rarely change — we were still doing unnecessary work every time.

I added a generic Redis cache-aside helper:

public async Task<T> GetOrSetAsync<T>(
    string key,
    Func<Task<T>> factory,
    TimeSpan ttl)
{
    // 1. Try Redis first
    var cached = await _redis.StringGetAsync(key);
    if (cached.HasValue)
        return JsonSerializer.Deserialize<T>(cached);

    // 2. Cache miss — fetch from source
    var result = await factory();

    // 3. Store in Redis with expiry
    await _redis.StringSetAsync(
        key,
        JsonSerializer.Serialize(result),
        ttl
    );

    return result;
}
Enter fullscreen mode Exit fullscreen mode

Then used it in the dashboard service with a TTL per data type:

var configTask = _cache.GetOrSetAsync(
    $"config:{userId}",
    () => _configService.GetConfigAsync(),
    TimeSpan.FromMinutes(30)  // config rarely changes
);

var permissionsTask = _cache.GetOrSetAsync(
    $"perms:{userId}",
    () => _authService.GetPermissionsAsync(userId),
    TimeSpan.FromMinutes(10)  // permissions change occasionally
);

var metricsTask = _cache.GetOrSetAsync(
    $"metrics:{userId}",
    () => _metricsService.GetMetricsAsync(userId),
    TimeSpan.FromMinutes(2)   // metrics need to be fresher
);

await Task.WhenAll(configTask, permissionsTask, metricsTask);
Enter fullscreen mode Exit fullscreen mode

📌 TTL Strategy: Not all data should be cached for the same duration. Match the TTL to how often the data actually changes. Getting this wrong leads to either stale data or a cache that's never warm enough to help.


The Results

After deploying both changes together, here's what the numbers looked like:

Metric Before After Change
API Response Time (p95) ~20s <7s ↓ 65%
Cache-hit Response Time N/A <200ms ↓ 99%+
Downstream Service Load 4 calls/request 0–1 (cached) ↓ ~80%
Timeout Errors Frequent Near-zero ↓ ~100%
DB Query Throughput Baseline +25% ↑ 25%

65% improvement on the critical path. Near-zero timeouts. Users happy.


Key Takeaways

1. Parallelise independent async calls — always

If your downstream calls don't depend on each other, there's no reason to await them sequentially. Task.WhenAll is one of the highest-ROI performance changes you can make in .NET Core.

2. Redis is most powerful when data is predictably stale

The cache-aside pattern works beautifully for data that doesn't change on every request. The key is identifying what changes at what frequency — and matching your TTL to that rhythm.

3. Profile before you optimise

It was tempting to jump straight to Redis. But tracing the endpoint revealed that parallelism was actually the bigger win. Without profiling first, I might have added caching and been confused why it only partially helped.

4. Think about cache invalidation from day one

Caching introduces a new complexity: stale data. We handled this with conservative TTLs and explicit cache invalidation on write operations. Don't add Redis without a clear invalidation strategy.


TL;DR: A 20-second .NET Core API was fixed with two changes: Task.WhenAll to parallelise independent downstream calls, and a Redis cache-aside layer with per-data TTL strategy. Result: 65% response time reduction, near-zero timeouts, and dramatically reduced downstream load.


What's Next?

Since this fix, we've extended the Redis pattern across several other high-traffic endpoints in our microservices. We've also added cache warming for the most critical data and are exploring distributed cache invalidation patterns for data that needs to stay even fresher.

If you're dealing with slow APIs in .NET Core, start by tracing your endpoint and looking for two things:

  • Sequential awaits that could be parallel
  • Repeated fetches of data that barely changes

Those two patterns together are responsible for a huge proportion of avoidable API latency.

Happy to discuss the implementation details or the Redis setup in the comments — drop your questions below. 👇


Senior Software Engineer @ Accenture, Chennai | React · .NET Core · Redis · Azure AD

LinkedIn · Portfolio

Top comments (0)