Skip to content

Performance & scalability

What routes cost, how matching scales, and how to keep prerendered apps from fetching twice. Numbers come from the repository's runnable benchmark project.

Every route is a live component

Brouter is declarative: each <Broute> (and each discovered @page route, emitted as a synthetic one) is a component instance mounted for the router's lifetime. That is what powers nested layouts, per-route guards/loaders and hierarchical matching - and it differs from the built-in Router, which keeps routes as a plain data table. Two costs to keep separate:

  • Match cost (per navigation) - handled: a first-segment index means matching considers only routes whose first template segment can match the URL's first segment, not all routes.
  • Instantiation cost (steady state) - unmatched routes render nothing, so this is a memory / instance-count cost, not a per-render one.

Indicative numbers

Measured by Tests/Bit.Brouter.Benchmarks (.NET 10, Release) against a RouteTable-style baseline that instantiates only the matched component:

Route countExtra memoryExtra startup
Tens of routes (typical)negligiblenegligible
~500 routes~2.5 MB~4 ms
~1000 routes~5.6 MB~8 ms

Each live route retains on the order of 3-6 KB of managed heap, growing linearly. Material for a very large all-@page app; negligible otherwise. Run dotnet run -c Release in the benchmark project for numbers on your own hardware and route counts.

Hundreds of pages?

  • Benchmark at your real route count before treating any router as a drop-in at that scale.
  • Split routes across lazily-loaded assemblies and register them via OnNavigateAsync / AdditionalAssemblies as they load - routes for pages the user hasn't reached aren't mounted up front (see @page discovery).
  • Keep-alive retention is opt-in and LRU-bounded; brouter.ClearKeepAlive() releases retained pages on demand (see lifecycle & keep-alive).

Prerender state bridging

Under prerendering, a route Loader runs on the server to produce the HTML, then the app becomes interactive and would run it again - a double fetch. Enable PersistLoaderState to capture loader results during prerender (via PersistentComponentState) and restore them on the interactive pass. Restoration degrades gracefully: anything that can't be rehydrated simply loads again.

builder.Services.AddBitBrouterServices(o =>
{
    o.PersistLoaderState = true;

    // For full trimming/AOT safety, supply a source-generated JSON context:
    o.LoaderStateTypeInfoResolver = AppJsonContext.Default;
});

[JsonSerializable(typeof(User))]
[JsonSerializable(typeof(Post))]
partial class AppJsonContext : JsonSerializerContext { }

Without a resolver, persistence uses reflection-based System.Text.Json - enable it when your loader data types are JSON-serializable and preserved under trimming. Loader results containing live tasks (deferred data) are skipped and simply re-run.

Not-found under static SSR

On .NET 10, when Brouter matches nothing during static SSR it calls NavigationManager.NotFound(), so the response carries a real HTTP 404 (and drives UseStatusCodePagesWithReExecute when configured) instead of a 200 with fallback HTML - good for crawlers, CDNs and correctness alike.