Blazor

Service Lifetimes in Blazor

If you've had previous experience with ASP.NET Core apps you may have used the built-in dependency injection system. For those who haven't you can check out the Microsoft Docs site for more info.

When registering services with the service container you must specify the lifetime of the service instance. You can specify one of 3 options singleton, scoped or transient.

Singleton scoped services are created once and the same instance is used to fulfil every request for the lifetime of the application.

Scoped scoped services are created once per request. Within a request you will always get the same instance of the service across the application.

Transient scoped services provide a new instance of the service whenever they are requested. Given a single request, if two classes needed an instance of a transient service they would each receive a different instance.

In this post, I want to show some slight differences in behaviour with dependency injection lifetimes in Blazor and Razor Components, aka server-side Blazor. I'm going to create a Blazor app and a Razor Components app. In each one I'm going to create the following 3 interfaces and classes, one scoped to each of the 3 lifetimes above.

public interface ISingletonService 
{
    Guid ServiceId { get; set; }
}

public interface IScopedService 
{
    Guid ServiceId { get; set; }
}

public interface ITransientService 
{
    Guid ServiceId { get; set; }
}
public class SingletonService : ISingletonService
{
    public Guid ServiceId { get; set; }

    public SingletonService()
    {
        ServiceId = Guid.NewGuid();
    }
}

public class ScopedService : IScopedService
{
    public Guid ServiceId { get; set; }

    public ScopedService()
    {
        ServiceId = Guid.NewGuid();
    }
}

public class TransientService : ITransientService
{
    public Guid ServiceId { get; set; }

    public TransientService()
    {
        ServiceId = Guid.NewGuid();
    }
}

As you can see, they all expose a ServiceId property which is set in the constructor. I'm going to display it for each service on two different pages in each app, the home page and counter page. I'm then going to do 4 tests and record the values for each one.

  1. Load the home page.
  2. Navigate to the counter page.
  3. Perform a full page refresh.
  4. Open the application in a new tab.

By doing this we should be able to get a clear understanding of how each of the lifetimes behave.

Let's get started.

Blazor

I've created a new Blazor app and added each of the services to the services container with the appropriate lifetime scopes. I've then changed the Index and Counter components to display the ServiceId from each instance.

@page "/"
@inject ISingletonService singletonService
@inject IScopedService scopedService
@inject ITransientService transientService

<h1>Blazor Service Lifetimes</h1>

<SurveyPrompt Title="How is Blazor working for you?" />

<div class="row">
    <div class="col-md-12">
        <ul>
            <li>Singleton Service - @singletonService.ServiceId</li>
            <li>Scoped Service - @scopedService.ServiceId</li>
            <li>Transient Service - @transientService.ServiceId</li>
        </ul>
    </div>
</div>
@page "/counter"
@inject ISingletonService singletonService
@inject IScopedService scopedService
@inject ITransientService transientService

<h1>Counter</h1>

<p>Current count: @currentCount</p>

<button class="btn btn-primary" onclick="@IncrementCount">Click me</button>

<hr />

<div class="row">
    <div class="col-md-12">
        <ul>
            <li>Singleton Service - @singletonService.ServiceId</li>
            <li>Scoped Service - @scopedService.ServiceId</li>
            <li>Transient Service - @transientService.ServiceId</li>
        </ul>
    </div>
</div>

@functions {
    int currentCount = 0;

    void IncrementCount()
    {
        currentCount++;
    }
}

Let's see what happens when we start up the app.

Load home page

  • Singleton Service - e353550e-28c1-42bd-9c85-cb5098dcdc89
  • Scoped Service - bed089e5-0fc6-4918-97c6-8bb629c89a3d
  • Transient Service - d748aa15-9824-45f0-821f-d60eacf46ba1
  • Singleton Service - e353550e-28c1-42bd-9c85-cb5098dcdc89
  • Scoped Service - bed089e5-0fc6-4918-97c6-8bb629c89a3d
  • Transient Service - 48fc9930-42b9-4558-8ce5-885bd9b49e2e

Reload the page

  • Singleton Service - 9d1dd585-6de6-4f82-9bbf-735dc05aa307
  • Scoped Service - 273eb2da-fb03-424d-b6c5-ad7e0ee5e2a5
  • Transient Service - 5ebd1782-5745-4712-b5db-6f8424444940

Open app in new incognito tab

  • Singleton Service - 369fc493-b51e-4683-9f7d-05ec2e9d2fb1
  • Scoped Service - 657f12a9-0eb4-4670-b5e8-554813e1b623
  • Transient Service - 903983b4-c138-47f6-93d2-59aaa11c37f4

Ok, that's a lot of GUIDs! Let's work our way through the results.

At first glance things look pretty normal in the first two tests. The singleton and transient services are behaving as expected but the ID for the scoped service is the same on both pages. This is because Blazor doesn't have the concept of a scoped lifetime, at least not currently, scoped simply acts the same as singleton.

In the final two tests we can see we are getting totally different results for each service. This is because Blazor is running on the client only and a full page refresh or opening the app in a new tab creates a new instance of the application.

Now let's take a look at Razor Components.

Razor Components

Once again, I've started with a fresh new app and registering all the services with the service container. I've also updated the Index and Counter components to display the service ID's, the same way I did for the Blazor app.

Let's run the same tests and see what we get.

Load home page

  • Singleton Service - 92232d6f-dc4f-44e3-9652-9f1741d221df
  • Scoped Service - 830ba955-ba12-4323-b740-4a73f2cd221e
  • Transient Service - f79bbfbd-33a6-416d-a5f1-1836574fd824
  • Singleton Service - 92232d6f-dc4f-44e3-9652-9f1741d221df
  • Scoped Service - 830ba955-ba12-4323-b740-4a73f2cd221e
  • Transient Service - 8e2ac662-e717-4fe4-b0e8-23ce9b2df117

Reload the page

  • Singleton Service - 92232d6f-dc4f-44e3-9652-9f1741d221df
  • Scoped Service - 55b51812-dfd3-4020-8dc6-dca734c94a9e
  • Transient Service - b4158dd0-625c-421e-b4e3-d10647df1864

Open app in new incognito tab

  • Singleton Service - 92232d6f-dc4f-44e3-9652-9f1741d221df
  • Scoped Service - 33ee94be-1fae-4e7f-a801-7ec58a1f1692
  • Transient Service - b489a7ec-dab0-4361-ab32-c05729cd58b1

Again, at first glance things look the same as the Blazor app. We are getting the same singleton instance across both pages and different transient instances. We are also getting the same scoped instance across pages just as we saw previously.

I guess this isn't really a surprise, after all, Razor Components and Blazor are the same framework, just with different rendering models. But look at the last two tests, the singleton service is the same as it was for the first two tests but the scoped services are different.

Unlike Blazor, Razor Components live on the server, well, almost. When a user loads a Razor Components application, a SignalR connection is established between the client and the server. Scoped services are scoped to this connection. This means that the user will continue to receive the same service instance for the duration of their session, as it's all considered part of the same request.

This explains why we are getting a different scoped instance for the last two tests, each test is creating a new request hence a new scoped service instance.

Wrapping up

I hope you have a better understanding of how service scopes work in both Blazor and Razor Components.

From our testing we now know that in Blazor apps there are actually only 2 service lifetimes, singleton and transient. And that we can count on these scopes behaving as expected while running the app in a single session.

With Razor Components, we saw all 3 service lifetimes are available however, scoped instances seemed to behave a bit different. The scoped service lived much longer than a scoped service in a say, a traditional MVC application.