loading
Warning:
The half every app needs, even without rules of its own The browser, or the site that linked to you, may prerender your page anyway. While IsPrerendering is true the page is running in a hidden tab nobody has opened: analytics events, a play(), a POST, anything that counts a visit is happening for a user who may never arrive. Hold those back and run them from OnActivated.
Note:
Not Blazor's prerendering This is the browser's prerender - "a hidden tab", not "on the server". The two are unrelated and can both be happening at once.

How did this page get here?

IsSupported / IsPrerendering / WasPrerendered / GetActivationStart

GetActivationStart is worth more than it looks: every other timestamp on the page is measured from the moment the prerender began, so a load time computed without subtracting it counts time the user never spent waiting.

C#
@inject Bit.Butil.Speculation speculation

if (await speculation.IsPrerendering())
{
    // running in a hidden tab - hold the analytics call back
}

var activationStart = await speculation.GetActivationStart();
var realLoadTime = loadEnd - activationStart;
Live sample
state output
Results will appear here when you interact with the samples.

Do the deferred work at the right moment

OnActivated

Fires once, on a page that was prerendered, at the moment the user actually navigates to it - and never on a page that wasn't. So it is a place to put work, not the only place: pair it with an IsPrerendering check that is false.

Razor
@implements IAsyncDisposable
@inject Bit.Butil.Speculation speculation
@inject AnalyticsService analytics

@code {
    private ButilSubscription? _subscription;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender is false) return;

        // A prerendered page runs in a hidden tab that may never be shown, so anything that counts
        // a visit has to wait for the activation that makes it real.
        if (await speculation.IsPrerendering())
        {
            _subscription = await speculation.OnActivated(activationStart =>
            {
                analytics.PageView();      // now there is really someone here
            });
        }
        else
        {
            analytics.PageView();
        }
    }

    public async ValueTask DisposeAsync()
    {
        if (_subscription is not null) await _subscription.DisposeAsync();
    }
}
Live sample
activation output
Nothing will fire here unless this page was itself prerendered.

Ask for a page in advance

Prefetch / Prerender / AddRules

Prefetch fetches the response and stops there - cheap, no side effects, safe to be generous with. Prerender loads and runs the whole page in a hidden tab, which is what makes the navigation instant and also what makes it expensive: bandwidth, CPU and battery for a page the user may never open. Keep it to a handful of destinations you are confident about, and remember those pages will see IsPrerendering true.

Razor
@implements IAsyncDisposable
@inject Bit.Butil.Speculation speculation

@code {
    private ButilSubscription? _prefetch;
    private ButilSubscription? _prerender;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender is false) return;

        _prefetch = await speculation.Prefetch(["/url", "/selection"]);
        _prerender = await speculation.Prerender(["/getting-started"], SpeculationEagerness.Conservative);

        // anything the two convenience methods don't cover - a rule set of your own:
        var documentRules = @"{ ""prerender"": [ { ""where"": { ""selector_matches"": "".product-link"" } } ] }";
        await speculation.AddRules(documentRules);
    }

    // Removes the rules, cancelling any speculation the user never went on to use.
    public async ValueTask DisposeAsync()
    {
        if (_prefetch is not null) await _prefetch.DisposeAsync();
        if (_prerender is not null) await _prerender.DisposeAsync();
    }
}
Live sample
rules output
Results will appear here when you interact with the samples.

API reference

Member
Signature
Description
IsSupported
ValueTask<bool> IsSupported()
True when the runtime supports speculation rules. Returns default (false) during prerender/SSR instead of throwing.
IsPrerendering
ValueTask<bool> IsPrerendering()
True while this document is being prerendered in a hidden tab.
WasPrerendered
ValueTask<bool> WasPrerendered()
Whether this page was prerendered before the user arrived at it.
GetActivationStart
ValueTask<double> GetActivationStart()
When the prerender began, relative to activation, in milliseconds. 0 for a page that was never prerendered.
Prerender
ValueTask<ButilSubscription?> Prerender(string[] urls, SpeculationEagerness eagerness = Moderate)
Asks the browser to load and run these pages in advance. Dispose the subscription to remove the rules.
Prefetch
ValueTask<ButilSubscription?> Prefetch(string[] urls, SpeculationEagerness eagerness = Moderate)
Asks the browser to fetch these responses in advance, without running them.
AddRules
ValueTask<ButilSubscription?> AddRules(string rulesJson)
Adds a speculation-rules document verbatim - for document rules, cross-site prefetches and per-rule referrer policies.
OnActivated
ValueTask<ButilSubscription> OnActivated(Action<double> handler)
Called when a prerendered page is activated, with the activation start. Never fires on a page that wasn't prerendered.
DisposeAsync
ValueTask DisposeAsync()
On scope/circuit teardown, removes any rule sets and activation listeners whose subscription was never disposed - a rule set left behind would keep asking the browser to load pages for a component that is gone.
SpeculationEagerness
Immediate | Eager | Moderate | Conservative
How keen the browser should be - the dial between wasted work and saved time.
An unhandled error has occurred. Reload 🗙