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 count | Extra memory | Extra startup |
|---|---|---|
| Tens of routes (typical) | negligible | negligible |
| ~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/AdditionalAssembliesas 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.