TechBash 2026 · Real-time data visualization01

Thursday 11:30 a.m. · Sagewood

Real-Time Data
Visualization in Blazor

Tim Purdum · TechBash 2026

Your data has a heartbeat, and your dashboard can keep up with it.

GeoBlazor Core 4.6.2Blazor WebAssemblyASP.NET Core SignalR

The finished Pocono-region fleet map with vehicles drawn and OpenStreetMap attribution visible. Live · simulated telemetry
Opening · the problem02

Which vehicles need attention?

Truck 007Licensed to report.
Live · reporting

TRUCK 007

Position
41.0621, −75.3390
Speed
42 km/h
Last report
0.4 s ago
Stale · not reporting

TRUCK 007

Position
last known 41.0588, −75.3407
Speed
42 km/h (last report)
Last report
12 s ago
500trucks 10reports / s 5,000reports / s
sent as 10 messages / secondeach carrying the whole fleet

Synthetic stress, not real truck behavior.

Opening · the tool03

Interactive ArcGIS maps in Blazor, from C#

No hand-written JavaScript.

Core

Open source · this whole demo

Pro

Feature editing & sketching

The map already knows how to draw.
Getting live data onto it is the hard part.

Opening · live demo04

Live demo

Finished dashboard · 50 trucks
Architecture · the pipeline05

From truck to map

Server
Store

latest report per truck

Truck 6
912913914915
Truck 7
41874188
Truck 8
23012302
keep latest
Send

every truck, one message, on a clock

500 trucks
batch

Following report 4188 from Truck 7

keep latest a newer one replaces an older one

batch many go on together

Browser
Newest frame

each frame replaces the last

last
newest
keep latest
Fleet state

keep every report, or the latest per truck

Truck 6915
Truck 74188
Truck 82302
keep latest
Map edits

one call per report, per truck, or for all

ApplyEdits
batch
Cadence

one page timer, 100 or 250 ms

Architecture · hosting06

Blazor Server or WebAssembly?

WebAssembly (this talk): C# runs in the browser

ServerIncoming telemetryASP.NET Core
SignalR hubserialize #1
BrowserFleet stateC# in WebAssembly
JS interopserialize #2
BrowserMap and chartArcGIS JS
Still usablestale parts flagged

Blazor Server: C# runs on the server

ServerIncoming telemetryASP.NET Core
in processno serializing
ServerFleet stateC#, one per user
JS interop
over the circuit
serialize #1
BrowserMap and chartArcGIS JS
Reconnecting…

Pick Server when

Internal app, reliable network, modest messages.

Pick WebAssembly when

It must stay usable through a disconnect.

ServerStoreSendBrowserNewest frameFleet stateMap editsCadence07

The latest report, into one frame

1 Store: latest report per truck keep latest Truck 5Truck 6Truck 7 418641874188Truck 8Truck 9… 500 2 Send batchFleetFrameevery truck · one message
// FleetDashboard.Shared · Telemetry.cs
public sealed record VehicleReport(
    int            VehicleId,
    long           Sequence,
    DateTimeOffset ObservedAtUtc,
    double         Longitude,
    double         Latitude,
    double         SpeedKph,
    string         RouteId);
// One frame carries the whole roster
public sealed record FleetFrame(
    Guid                         RunId,
    long                         FrameSequence,
    DateTimeOffset               PublishedAtUtc,
    IReadOnlyList<VehicleReport> Vehicles);

Fields → one marker

Longitude, Latitude → point geometry
VehicleId → object ID and attributes
SpeedKph, ObservedAtUtc → details and staleness

Frame-level, not vehicle-level

RunId changes on a server restart and on a reset, so old sequence numbers stop counting.

ServerStoreSendBrowserNewest frameFleet stateMap editsCadence08

Send the latest when you're ready

The server
// FleetBroadcaster.cs · abbreviated
using var timer = new PeriodicTimer(FrameInterval());
while (await timer.WaitForNextTickAsync(stoppingToken))
{
    // a slow send delays the next frame; missed intervals are not replayed
    await BroadcastAsync(_frameBuilder.CreateFrame(), stoppingToken);

    // re-read the frame rate each interval
    TimeSpan interval = FrameInterval();
    if (timer.Period != interval) timer.Period = interval;
}

Learn more: PeriodicTimer

ServerStoreSendBrowserNewest frameFleet stateMap editsCadence09

The browser keeps the newest frame

Receivedeach frame that arrives
is deserialized
offered: 10 a second at stress
Newest frame keep latest
Page tickreads the slotread: frame 43

At a 100 ms page tick the page reads about 9 of the 10 frames offered each second. When it reads slower than frames arrive, the newest frame wins.

Architecture · GIS terms10

Map, layer, feature

The map zoomed to truck 007, with the selected truck ringed and its route name shown.
Truck 007 selected
FeatureOne truck: a point plus attributes
oid, vehicle, speedKph, route, status
LayerThe fleet: a FeatureLayer
The routes: a GraphicsLayer
// FleetMap.razor · abbreviated
<MapView OnClick="SelectAtPoint">
  <Map>
    <GraphicsLayer Title="Stored demonstration routes" />
    <FeatureLayer Title="Fleet vehicles" ObjectIdField="oid" ... />
    <GraphicsLayer Title="Selected vehicle" />
  </Map>
</MapView>

ObjectIdField names the attribute GeoBlazor pairs with a stable Graphic.Id to update the right feature.

An edit is one ApplyEdits call.

Learn more: docs.geoblazor.com

ServerStoreSendBrowserNewest frameFleet stateMap editsCadence11

Fleet state: what waits for the map

One app, two ways to hold the same reports. The URL picks one, and everything else stays the same.

Coalesce current state. Never coalesce events.

A truck's older position is state, and safe to drop. A sale or an alarm is an event. Drop one and the information is gone.

ServerStoreSendBrowserNewest frameFleet stateMap editsCadence12

The second batch Map edits: one call for every truck

Before · one awaited edit per feature

foreach (VehicleListItem update in updates)
{
    await ApplyVehicleDelta([update], []);
    RemoveAppliedDelta([update], []);
    appliedCalls++;
}

After · one awaited collection edit

// FleetMap.razor · DrainUpdates, EditGranularity branch
await ApplyVehicleDelta(updates, removals);
RemoveAppliedDelta(updates, removals);
appliedCalls++;

500 map edits become 1 call, then 3 chunks

500 × ApplyEdits(1 feature)
1 × ApplyEdits(500 features)
GeoBlazor splits it internally

Edits sent per truck at ~6.7 ms each: ~3.4 seconds for 500 trucks.
All 500 in one call: ~75 ms.

Roads we tried

  • GraphicsLayer in a loop: one call per truck, no bulk update, and it did not beat the collection call in the JavaScript test
  • SetSource: 2 of 40 trucks still clickable
ServerStoreSendBrowserNewest frameFleet stateMap editsCadence13

Cadence

Timer 250 ms, was 100
Chart 1 a second

Every tick of the page timer

  • Reads the newest frame and updates fleet state
  • Hands the map the pending batch, and redraws the chart and counters
  • The chart keeps 60 samples. The counters report over a 5-second window

Same edit, half the work. Map calls a second: 7.3 at 100 ms, 3.7 at 250 ms. Median edit: 75 ms, then 76 ms. Browser main thread busy in a 10 s trace: about 94% at 100 ms, about 48% at 250 ms.

Measurements · where the time goes14

Where does the time go?

AOT · published, interpreted vs AOT

One call, all trucks75 vs 54ms · median edit Read every 250 ms76 vs 57ms · median edit Download, MB2.9 vs 51dotnet.native.wasm

One 10 s whole-fleet trace each: main thread busy about 94% vs 61%, .NET about 49% vs 20%. AOT cut the .NET share by about 60%.

The floor · is it the map engine?

500 features, one call10.9 msplain JavaScript, no .NET GeoBlazor JavaScriptabout 5%of the main thread

The remaining time is .NET on the browser's main thread, plus GeoBlazor's interop.

ServerStoreSendBrowserNewest frameFleet stateMap editsCadence15

Per connection Experiment Testing it: send only what the map sees

receive 705 records/s191 per frame Measured: stress preset, published build, only what is in view, zoomed in on Truck 7 · median of 3 runs.

InsideFull rate, every frame.

OutsideOnce a second, so the list stays complete.

Selected truckFull rate, wherever it goes.

Still safe

Every truck still arrives once a second.

Measurements · comparison16

Compare the same workload

SettingChangesEdit p50, ms (range)AOT p50, msMap calls/sLive
Keep every reportFleet state7.8 (7.0 to 8.1)7.053100%
Keep the latest per truckFleet state6.7 (6.5 to 6.8)5.859100%
One call, all trucksMap edits75.1 (68.0 to 82.2)54.27.3100%
Read every 250 msCadence76.1 (65.4 to 78.7)57.43.7100%
In view, zoomed inSend27.5 (21.7 to 33.6)20.33.7100%
In view, full fleetSend71.3 (70.8 to 75.1)53.83.7100%
Safety stop3 of 3keep every report, no other setting Zoomed vs. 250 ms27.5 vs. 76.1ms · ranges don't overlap Map calls a second59 vs. 3.7keep the latest per truck vs. reading every 250 ms
Beyond the trucks · hosted layers17

Streams someone else hosts

NOAA weather radarfree · refreshes every 5 minutes

NWS watches and warningsfree · refreshes every 3 minutes

TrafficEsri · needs an ArcGIS API key · every 5 minutes

Someone else's streamYour stream
Codea refresh intervala store, frames, and the browser code after them
How often it changestheirsyours
Formattheirsyours
Filteringnot at the sourceat Send, per connection

Learn more: ArcGIS Living Atlas

Reliability · stale and reconnect18

Connected does not mean current

Vehicle 7 marked Stale with its last-known position, an amber marker, and a gap in the speed chart.
Agent 007 has gone dark. Last observation: 9 seconds ago.

Stale after 5 s

Measured from ObservedAtUtc, never from frame receipt. Checked again on every page tick.

Live Reconnecting Live
Break, reconnect, one snapshot merge, movement returns.

Reconnect rules

Auto-reconnect for dropped transports · merge by sequence · RunId guards resets · no replay

ServerStoreSendBrowserNewest frameFleet stateMap editsCadence19

What to take home

Server every setting: the latest per truck, one frame per interval
Store
latest report per truck
Send
every truck, one message, on a clockonly what is in view, per connection: about 190 of 500 trucks per frame, edit time 27.5 ms
Browser
Newest frame
each frame replaces the last
Fleet state
keep every report, or the latest per truckkeep every report: fell overkeep the latest per truck: 500 at most
Map edits
one call per report, per truck, or for allone call, all trucks
Cadence
one page timer, 100 or 250 msread every 250 ms
Bounded state One map edit in flight No edit outlives its component

The server batches and thins the data before the browser sees it, so the browser's job is to keep that work. Under stress, the biggest gain came from sending less in each frame, not from sending fewer frames.

Publish before you measure. Try AOT.

Close · thank you19A

Thank you to the TechBash 2026 sponsors

Close · questions20

Questions

Thank you to TechBash

QR code that opens the write-up and sample code for this session.
WRITE-UP

Write-up and links

timpurdum.dev/post/2026/10/15/techbash.html

Sample app: github.com/dymaptic/GeoBlazor-Samples · projects/FleetDashboard

Appendix · running the sampleA1

Running the sample

The sample README showing the requirements and the key configuration.
The sample README: requirements and key configuration.

Core registration

builder.Services.AddGeoBlazor(builder.Configuration);

Core registration and ArcGIS authentication are separate concerns.

Configuration keys

GeoBlazor:RegistrationKey
GeoBlazor:LicenseKey  (fallback)
ArcGISApiKey  (optional · turns on traffic)

No real key is committed. Presenter credentials never ship.

Checkpoints

One launch serves every setting through ?mode=, so recovery is a URL change.

Appendix · interactive server variantA2

What changes for Interactive Server?

Server process
Telemetry serviceevents, in process
Server-side state serviceper-user state
Component dispatchercircuit updates
GeoBlazor interoprenders in the browser
  • The SignalR hub is not needed: the circuit already is the connection.
  • Both hosting models keep their own state and clean up after themselves. On Server, per-user state lives on the server and every update crosses the circuit.
  • Circuit messages default to 32 KB, and GeoBlazor's own Server samples raise that limit.
  • Do not add a same-process hub client just to imitate the WebAssembly architecture.
Appendix · measurement definitionsA3

Measurement definitions

MetricDefinition
Records / svehicle reports in newly observed frames. A repeated frame adds nothing
Pending recordsrecords waiting for the map, from the raw queue or the dirty vehicles
Replaced statepending reports replaced by a newer report for the same vehicle
Application map calls / scalls to FeatureLayer.ApplyEdits, not JavaScript calls
Features submitted / sfeature edits passed to the adapter, by add, update or delete
Edit durationmonotonic elapsed time around the awaited call, including internal chunks
Selected observation agenow minus the last successfully applied observation time
Failure countfailed feature edits and failed batches
Reconnectsclient reconnect events, a separate counter
Received frames / s, receipt-to-apply delaydefined in the demo plan, not measured

Protocol

Published build (dotnet publish -c Release), same preset, 30 s warm-up, 60 s per setting, three rounds with the builds alternated, counters reset between runs. The in-view runs fix the map extent: one zoomed on the selected truck, one at the full region.

Environment

Runs   2026-10-13 and 2026-10-14
Machine   TimP_Dymaptic, i7-14700, 64 GB
Build   dotnet publish -c Release · Core 4.6.2
Runtime   interpreted, AOT · chunk size 200
Sample   865312d · 500 vehicles · 10 frames/s
Browser   Chromium 153, headed, hardware GPU
Window   30 s warm-up · 60 s run

Full record of the published runs behind slides 12 to 16: assets/measurement-environment.txt. Paint rate was not measured.

Appendix · beyond the sampleA4

Beyond the sample

Durable event history

Keep an append-only record when replay and audit matter more than freshness.

Authorization

Decide who may see which vehicles before the frame leaves the server.

Routing

Routing chooses a path. This demo follows stored paths and claims nothing about legal truck access.

StreamLayer

Coming in a later GeoBlazor release, it moves the live feed into the map itself.

These four are where I would take the sample next.

Appendix · choosing the update pathA5

Choosing the update path

The ArcGIS engine is not the cost

A JavaScript-only control moved the same 500 points: one collection call took 10.9 ms with the page's requestAnimationFrame rate at 119.5 a second, and a single-feature call 0.1 ms. That control had no .NET and no interop in it. A JavaScript GraphicsLayer matched it on throughput only. GeoBlazor's own was not measured.

Three paths GeoBlazor exposes

  • One collection FeatureLayer.ApplyEdits call: today's path, chunked at 200 graphics.
  • FeatureLayer.SetSource, a bulk replacement: data stayed current but the rendered view desynced. After 40 calls, hit testing returned the vehicle's own feature for 2 of 40 and some feature for 27 of 40.
  • A GraphicsLayer: one interop call per graphic, no bulk update, no renderer.

Measure before choosing

  • Visible movement, current attributes and status
  • Same object identity, working hit testing and selection
  • Join and leave handling

Keep one edit in flight. Quote no timing before all of it.

No stream layer in 4.6.2

GeoBlazor 4.6.2 wraps no StreamLayer, the layer Esri designed for this. Keeping FeatureLayer also keeps the UniqueValueRenderer, the ObjectIdField, and queryFeatures.

Paint rate was never measured. Software and hardware WebGL runs of the whole-fleet edit (2026-09-17) recorded the same edit durations, and edit duration excludes painting.

Appendix · publishing and AOTA6

Publishing and AOT

Measure a published build

dotnet build output runs the browser client with debugging on, and it ran about 30 times slower. Alternated in the same half hour, whole-fleet edit p50 was 2,563 and 2,408 ms from build output against 84 ms published. Build output with only WasmDebugLevel=0 changed ran at published speed: 83.6 and 78.8 ms against 88.5 and 108.2 ms published, in the same rounds.

The publish command

# one line, wrapped to fit
dotnet publish FleetDashboard.Server.csproj -c Release
  -p:WasmApplicationEnvironmentName=Development

Since .NET 10 the client's environment is fixed at build time. Without the property, the published client is Production and skips appsettings.Development.json.

Add AOT

Add -p:RunAOTCompilation=true. It needs the wasm-tools workload.

InterpretedAOT
One call, all trucks75 ms54 ms
Read every 250 ms76 ms57 ms
dotnet.native.wasm2.9 MB51 MB

When AOT pays off

  • It pays when CPU-heavy .NET runs on the browser's main thread. Here it cut whole-fleet edit p50 by about 25 to 28% and the .NET share of main-thread time by about 60% (one trace per mode).
  • It costs download size and a longer publish. Per-vehicle calls, about 6 ms, gained about 1 ms.

Edit p50 is the median of the alternating runs (three, or two for the interpreted one-call runs), 2026-10-13 and 2026-10-14, one machine. These are observations, not a benchmark.