CodeOath
← All posts
Architecture & Patterns50 min total · 13 parts

Middleware Pipelines Compared: ASP.NET Core, Express.js, and Django

Part 11 of 13 · ~2 min

Async Middleware and the Cost of Getting It Wrong

Whatever a middleware spends time waiting on, every single request funneled through it inherits that same wait — there's no way around a slow network call once it's wired directly into the chain. Counter's requireManager check originally called out to ShiftDesk, Fernwood's internal roles service, on every approval attempt, just to confirm the current shift lead's certification hadn't lapsed:

# BAD — a network round trip to ShiftDesk on every approval attempt, even
# repeat attempts from the same shift lead moments apart
class RequireManagerMiddleware:
    def process_view(self, request, view_func, view_args, view_kwargs):
        if getattr(view_func, "requires_manager", False):
            is_certified = shiftdesk_client.check_certification(request.user.id)  # slow!
            if not is_certified:
                return HttpResponseForbidden()
# BETTER — a short-lived local cache, refreshed on a timer, hit for
# certification checks that don't need to be perfectly real-time to the second
class RequireManagerMiddleware:
    def process_view(self, request, view_func, view_args, view_kwargs):
        if getattr(view_func, "requires_manager", False):
            is_certified = certification_cache.get(request.user.id)  # local, fast
            if is_certified is None:
                is_certified = shiftdesk_client.check_certification(request.user.id)
                certification_cache.set(request.user.id, is_certified, timeout=300)
            if not is_certified:
                return HttpResponseForbidden()

None of that reasoning is specific to Django. Whatever costly work ends up inside globally-registered middleware needs one of two justifications: it's genuinely required for every request that flows through, or it's gated behind a cheap check so only the requests that actually need it pay for it. And anything that only ever needs to happen once — opening a connection, priming a cache — belongs in startup code, never repeated inside the path that runs per request.

ASP.NET Core Specifically: await Isn't Optional

public async Task InvokeAsync(HttpContext context, HttpClient shiftDeskClient)
{
    var isCertified = await shiftDeskClient.GetFromJsonAsync<bool>(
        $"/certifications/{context.User.Identity.Name}");  // awaited — doesn't block a thread

    if (!isCertified)
    {
        context.Response.StatusCode = 403;
        return;
    }

    await _next(context);
}

Reaching for await instead of a synchronous .Result/.Wait() call isn't a style preference here — it's load-bearing. ASP.NET Core draws from a finite pool of worker threads, and calling .Result on a pending task parks whichever thread happens to be running, doing nothing at all, for however long ShiftDesk takes to reply. await hands that thread straight back to the pool the moment the wait begins, leaving it free to pick up a completely unrelated request in the meantime. Let enough middleware block synchronously on I/O under real traffic and the pool simply runs dry — a well-documented way to stall requests that never went anywhere near ShiftDesk at all.