If you’ve ever developed for the web, you know the feeling: a user reports an error you can’t reproduce. “Something went wrong when I tried to save,” they say. No further details. If the problem happened on the server, you likely have logs to investigate. But what if the error occurred entirely in the user’s browser? The console logs, if they ever existed, were lost forever the moment the tab was closed.

This is where the challenge of logging in Blazor WebAssembly (WASM) comes into play. Our .NET applications now run in a sandbox inside the client’s browser, far from the controlled environment of our server. How can we reliably capture the events, warnings, and critical errors of a client-side application?

The answer is by building a custom logging system.

In this comprehensive guide, we’ll do exactly that. We will go far beyond a simple code recipe. You will learn:

  • The fundamentals of Dependency Injection (DI), the pillar that supports modern .NET applications.
  • Why popular logging solutions like NLog might not work as expected in the Blazor WASM environment, especially with network calls.
  • The step-by-step process to build a robust HTTP log provider, capable of sending logs to a central API safely and efficiently.
  • How to diagnose and solve advanced DI problems that may arise along the way.

By the end, you will have not just the code, but the deep knowledge to make your Blazor applications truly observable.

Fundamentals: What is Dependency Injection (DI) and Why Is It Essential?

Before we write our logger, we need to understand the magic that allows .NET to connect everything so flexibly: Dependency Injection (DI). If you’ve used ASP.NET Core or Blazor, you’ve already benefited from it, perhaps without realizing it.

In simple terms, DI is a design pattern that follows the Inversion of Control (IoC) Principle. Instead of a class creating its own dependencies, it receives them “injected” from an outside source.

Think of a chef. Instead of going to the pantry to get each ingredient themselves (creating their dependencies), they simply state what they need, and an assistant (the DI container) delivers everything prepared to their station. The chef doesn’t need to know where the ingredients came from, only that they are available.

In the .NET universe, this “magic” is orchestrated by two main actors in the Program.cs file:

  1. IServiceCollection (builder.Services): This is the “menu” of our application. It’s where we register all services and their implementations, telling .NET, “When someone asks for X, provide an instance of Y.”
  2. IServiceProvider: This is the “assistant” or “waiter.” After the menu is set, it’s the one that actually creates and delivers service instances when they are requested.

When registering a service, we also define its lifetime:

  • AddTransient: A new object is created every time it’s requested. It’s like ordering a new drink each time.
  • AddScoped: A single object is created per request (in the context of a web request) or per connection (in Blazor Server).
  • AddSingleton: A single object is created for the entire lifetime of the application. It’s the same object for everyone who requests it.

So, what does this have to do with logging?

The ILogger<T> system in .NET is one of the most classic examples of DI in action. When you add ILogger<MyClass> to your class’s constructor, the IServiceProvider sees your request, creates (or reuses) a logger instance configured for that class, and hands it to you. Our goal is precisely to teach the DI container to use our custom log provider to fulfill these requests.

The Common Hurdle: Why NLog (and others) Fail with HTTP Calls in Blazor WASM?

A common reaction from developers experienced with backend development is to try using a familiar logging library like NLog or Serilog. They are extremely powerful on the server. The problem is that the Blazor WASM environment imposes unique constraints that break the traditional approach.

The core problem lies in the collision of two natures:

  1. The ILogger.Log() interface is synchronous. It returns void and was not designed for long-running or I/O-bound operations, like network calls.
  2. Network calls (HttpClient) are, by nature, asynchronous. The best practice is to always use await to wait for the response.

You can’t just call an async method inside a synchronous one without await (a practice known as “fire-and-forget”) and expect it to work. In Blazor WASM, this is a recipe for data loss.

Imagine the most critical scenario: an unhandled error occurs, and your application is about to crash. The logging system tries to send one last error message to your API. If that call is “fire-and-forget,” the WASM application might finish its lifecycle and be unloaded by the browser before the HTTP request is completed. The result: the most important error log never reaches its destination.

This is why we need an architecture that decouples the act of logging a message (fast and synchronous) from the act of sending it over the network (slow and asynchronous).

Step-by-Step: Building a Robust HTTP Log Provider

Let’s build a resilient solution using the “Producer/Consumer” pattern. Our loggers will be the “producers” of messages, placing them in a queue. A background service will be the “consumer,” which drains the queue and sends the data.

Step 1: The Central Log Queue

First, we create a singleton service that will serve as our centralized message buffer.

using System.Collections.Concurrent;

// A singleton service to hold the log queue.
public class LogQueueService
{
    public ConcurrentQueue<string> Queue { get; } = new();
}

Step 2: The Provider and the Logger

Now, we create the ILogger that adds messages to this queue and the ILoggerProvider that creates it.

// Our Logger, which produces messages for the queue.
public class HttpBatchLogger : ILogger
{
    private readonly string _categoryName;
    private readonly LogQueueService _logQueue;

    public HttpBatchLogger(string categoryName, LogQueueService logQueue)
    {
        _categoryName = categoryName;
        _logQueue = logQueue;
    }

    public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
    {
        if (!IsEnabled(logLevel))
        {
            return;
        }

        // Formats the message and adds it to the queue. A fast and synchronous operation.
        var message = $"[{logLevel}] {_categoryName}: {formatter(state, exception)}";
        _logQueue.Queue.Enqueue(message);
    }

    public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None;

    public IDisposable BeginScope<TState>(TState state) => default!;
}

// Our Provider, which creates instances of the Logger.
public class HttpBatchLoggerProvider : ILoggerProvider
{
    private readonly LogQueueService _logQueue;

    public HttpBatchLoggerProvider(LogQueueService logQueue)
    {
        _logQueue = logQueue;
    }

    public ILogger CreateLogger(string categoryName)
    {
        return new HttpBatchLogger(categoryName, _logQueue);
    }

    public void Dispose() { }
}

Step 3: The Background Sending Service

This is the “consumer.” An IHostedService that runs in the background, periodically sending logs from the queue to our API.

// Service that consumes the queue and sends logs in batches.
public class HttpLogSenderService : IHostedService, IAsyncDisposable
{
    private readonly LogQueueService _logQueue;
    private readonly IHttpClientFactory _httpClientFactory;
    private readonly IConfiguration _configuration;
    private Timer? _timer;

    public HttpLogSenderService(LogQueueService logQueue, IHttpClientFactory httpClientFactory, IConfiguration configuration)
    {
        _logQueue = logQueue;
        _httpClientFactory = httpClientFactory;
        _configuration = configuration;
    }

    public Task StartAsync(CancellationToken cancellationToken)
    {
        // Starts a timer to check the queue every 5 seconds.
        _timer = new Timer(SendLogs, null, TimeSpan.Zero, TimeSpan.FromSeconds(5));
        return Task.CompletedTask;
    }

    private void SendLogs(object? state)
    {
        // We use Task.Run so we don't block the timer and can use async/await.
        _ = Task.Run(async () =>
        {
            if (_logQueue.Queue.IsEmpty)
            {
                return;
            }

            var logsToSend = new List<string>();
            while (_logQueue.Queue.TryDequeue(out var logMessage))
            {
                logsToSend.Add(logMessage);
            }

            if (logsToSend.Any())
            {
                try
                {
                    var client = _httpClientFactory.CreateClient("LoggingApi");
                    // The actual endpoint would come from configuration.
                    var endpoint = _configuration["LoggingApi:Url"]; 
                    // In a real scenario, the body would be a well-structured JSON.
                    await client.PostAsJsonAsync(endpoint, logsToSend);
                }
                catch (Exception ex)
                {
                    // If sending fails, we can try to re-queue or log to the console.
                    Console.WriteLine($"Error sending logs: {ex.Message}");
                }
            }
        });
    }

    public Task StopAsync(CancellationToken cancellationToken)
    {
        _timer?.Change(Timeout.Infinite, 0);
        // Try to send any remaining logs before stopping.
        SendLogs(null);
        return Task.CompletedTask;
    }

    public async ValueTask DisposeAsync()
    {
        _timer?.Dispose();
        // Ensure a final send when the service is disposed.
        await Task.Run(() => SendLogs(null));
    }
}

Step 4: Tying It All Together in Program.cs

Finally, we register all our new services in the DI container.

// In Program.cs
builder.Logging.ClearProviders();

// 1. Register the queue as a Singleton to be shared.
builder.Services.AddSingleton<LogQueueService>();

// 2. Add our custom log provider.
builder.Services.AddSingleton<ILoggerProvider, HttpBatchLoggerProvider>();

// 3. Register a named HttpClient for the logging API.
builder.Services.AddHttpClient("LoggingApi");

// 4. Add our background service.
builder.Services.AddHostedService<HttpLogSenderService>();

// ... rest of the configuration

With this architecture, our logging system is resilient, efficient, and respects the constraints of the Blazor WASM environment.

Advanced DI Challenges: The BuildServiceProvider Trap

Now that we have a robust solution, let’s explore a more subtle problem that can make everything stop working. In one of our projects, even with the setup above, the logs weren’t arriving. The cause was a seemingly harmless line of code in Program.cs:

// A dangerous anti-pattern in the middle of service configuration!
var tempServiceProvider = builder.Services.BuildServiceProvider(); 

Calling BuildServiceProvider() before builder.Build() creates a “split DI container.” This generates a temporary service provider and then discards it, which can corrupt the resolution of complex dependencies, like ILogger<T>, for services registered after this call.

If you ever run into a situation where injecting ILogger<T> fails, but injecting ILoggerFactory works, you may have fallen into this trap. For scenarios like this, or when an external library requires the non-generic ILogger interface, the Adapter Pattern is an elegant solution. You can create an ILoggerAdapter<T> that inherits from ILogger and injects ILoggerFactory to create the real logger internally, serving as a “bridge” to bypass the broken resolution.

Conclusion: Toward Observable Applications

Building an effective logging system in Blazor WASM is more than just sending an HTTP request. It requires a clear understanding of Dependency Injection, the application lifecycle, and the constraints of the browser environment.

The lessons we’ve learned are:

  • DI is your foundation: Understanding it is crucial for building maintainable applications.
  • Respect synchronicity: The ILogger interface is synchronous. Force network operations into it at your own risk.
  • Decouple with the Producer/Consumer pattern: Using a queue and a background service is the most robust approach for I/O in loggers.
  • Beware of DI anti-patterns: A premature BuildServiceProvider() is a source of hard-to-trace bugs.
  • Design Patterns save the day: The Adapter can be the right tool to solve complex dependency conflicts.

With these techniques, you are well-equipped to transform your Blazor applications from “black boxes” into transparent, observable systems.