Skip to content
Back to blog

Blog / ·6 min read

The deadlock that lived in a constructor

Part 4 of the Solo-Build series: a .NET deep dive into how one blocking call in a singleton constructor deadlocked a whole Blazor Server app, why it happens, and the fix.

In the last post I opened with an afternoon when every page on the site went blank, and I blamed one line. This post is that one line, in full, because if I could hand another .NET developer a single thing from this whole project, it would be this. It is the rule I am most sure of, and it is the one an AI assistant will break for you over and over unless you stop it.

The platform is AlgoForgeX, a Blazor Server app. The bug was a deadlock. Here is exactly how a deadlock hides in a constructor and takes a whole app down with it.

What a circuit actually is

Blazor Server does something that feels strange the first time you sit with it. Your interface code runs on the server, and the browser is mostly a thin terminal connected over a websocket. Each connected user gets a circuit, and all of that user's component work, every render, every click, every lifecycle method, runs through a single synchronization context.

The important word is single. Only one callback runs on that context at a time. Everything else waits its turn. Most days this is a gift: you almost never have to worry about two event handlers touching the same component state at once, because they cannot. The framework lines them up for you and runs them one at a time.

It turns into a trap the moment you block.

Why blocking is fatal here

Here is the shape of the problem. When you write await, the compiler splits your method in two. The part before the await runs now. The part after it, the continuation, is scheduled to run once the awaited work finishes. On a circuit, that continuation is handed back to the same synchronization context, to run when the context is free.

Now suppose that instead of awaiting, you block. You call task.GetAwaiter().GetResult(), or .Result, or .Wait(). You are now holding the context and refusing to let go until the task finishes. But the task's continuation needs the context to run, and the context is busy, held by you, waiting for the task. The task is waiting for the context. The context is waiting for the task. Neither one moves again.

That is the deadlock. And because that same context is what renders the page, the symptom is not a crash and not an error. It is silence. Every panel renders empty, because the one lane that draws them is stuck waiting for a car parked behind it.

Where it hid

None of this would survive long if the blocking call sat in a button handler. You would catch it the first time you clicked. The reason this one reached production is where it was hiding: in the constructor of a singleton.

A singleton in a dependency injection container is created once, the first time something asks for it. Not at startup. The first time. So you can write a service, register it as a singleton, test it, and never notice that its constructor does something dangerous, because in your tests it gets built on an ordinary thread. Then one day in production the first thing to ask for that singleton is a component rendering on a circuit. The constructor runs there. And the constructor did this:

public sealed class ResearchGate
{
    private readonly int _maxConcurrent;

    public ResearchGate(IAppSettings settings)
    {
        // Runs on the first resolve. If that first resolve happens on a
        // Blazor circuit, this blocking read deadlocks the whole circuit.
        _maxConcurrent = settings.GetIntAsync("Research:MaxConcurrent")
                                 .GetAwaiter().GetResult();
    }
}

That GetIntAsync reads a setting, and reading a setting is I/O. The constructor blocked the circuit waiting on it, the continuation could never run, and the site went blank for everyone whose request happened to be the one that first built the service.

The seductive part is the reasoning that lets it through. It only runs once. That is true, and it does not matter. Once is plenty when the once is on a circuit.

The fix

The fix is not a clever trick. It is to stop blocking. The constructor should do no I/O at all. It should only set up a way to read the setting later, asynchronously, the first time someone actually needs it:

public sealed class ResearchGate
{
    private readonly Lazy<Task<int>> _maxConcurrent;

    public ResearchGate(IAppSettings settings)
    {
        // No I/O here. Just describe how to read it later.
        _maxConcurrent = new Lazy<Task<int>>(
            () => settings.GetIntAsync("Research:MaxConcurrent"));
    }

    public async Task<bool> TryEnterAsync()
    {
        var max = await _maxConcurrent.Value; // read once, awaited properly
        // ... use max ...
    }
}

The constructor now just builds a Lazy<Task<int>> and touches nothing. The first real async call awaits it. Lazy makes sure the read happens once and is shared by everyone after. await makes sure the continuation is free to run on the context once the read finishes. The deadlock cannot form, because nothing ever holds the context hostage.

The general version of the rule is the one worth memorizing: async all the way. If something down the stack is asynchronous, every caller above it stays asynchronous too, right up to the framework. You never bridge from async back to synchronous by blocking, not anywhere a circuit can reach.

The rule, and the fact that it came back

This went into the conventions file the assistant reads at the start of every session, written as a hard rule with the reason attached: never block on async on any path that can run on a circuit, and never read config or do I/O in a constructor a circuit might trigger.

And then, months later, it happened again. A different service, a notification helper, blocked on a settings read the same way. This time the symptom was narrower. An action that was supposed to send notifications would log that it had done its work, while no notification ever went out and the screen that triggered it hung. Same root cause, new costume.

Here is the part that matters, and the reason the rule earns its keep even though it got broken twice. The second time, I knew the shape instantly. A hung interaction plus work that claims to have happened but did not is the fingerprint of exactly this deadlock. What would have been an afternoon of confusion the first time was a few minutes the second, because the rule had given the failure a name. A good rule does not only prevent the mistake. It makes the mistake legible when it slips through anyway.

What to take from it

Two closing thoughts, one for each kind of reader.

If you write .NET: blocking on async is a footgun everywhere, but on Blazor Server it is a loaded one pointed at your whole app, and it loves to hide in constructors and singletons where it looks harmless. Async all the way is not a style preference there. It is the difference between a site that stays up and a site that goes dark.

If you build with an AI: this is exactly the kind of mistake the tool will make confidently, because the blocking version looks simpler, compiles fine, and passes every test that does not happen to run on a circuit. The assistant has no memory of the outage. So the rule lives in a file it reads every session, and one of the audit passes goes looking specifically for blocking calls on circuit paths. The tool supplies the speed. The rule supplies the judgment it does not have.

That is the pattern this whole series keeps circling back to, and it is a good place to leave it for now.