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

3D

The map already knows how to draw.
You decide what to hand it, and how often.

Opening · live demo · ready04

Live demo

Finished dashboard · 50 trucks
Architecture · the pipeline05

Six stages from truck to map

Server
1Store

latest report per truck

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

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
3Newest frame

each frame replaces the last

last
newest
keep latest
4Fleet state

a queue, or one slot per truck

Truck 6915
Truck 74188
Truck 82302
keep latest
5Map edits

one call per report, per truck, or for all

ApplyEdits
batch
6Cadence

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 tick; missed ticks are not replayed
    await BroadcastAsync(_frameBuilder.CreateFrame(), stoppingToken);

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

Learn more: PeriodicTimer

ServerStoreSendBrowserNewest frameFleet stateMap editsCadence09

The browser keeps the newest frame

Receivedevery frame is
deserialized
10 a second at stress
Newest frame keep latest
Page tickreads the slotread: frame 43

Under stress the page reads about 2 of every 10 frames.

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

Implementation #1 · in the browser Every report goes to the map

// VehicleGraphicFactory.cs · abbreviated: one graphic per report
new Graphic(new Point(vehicle.Longitude, vehicle.Latitude),
    attributes: new AttributesDictionary(new Dictionary<string, object?>
    {
        ["oid"]      = objectId ?? vehicle.VehicleId,
        ["speedKph"] = vehicle.SpeedKph,
        ["status"]   = vehicle.Status.ToString()
    }))
{ Id = graphicId ?? Guid.NewGuid() };
// FleetMap.razor · ApplyVehicleDelta, abbreviated
FeatureEditsResult result = await _vehicleLayer!.ApplyEdits(
    new FeatureEdits { UpdateFeatures = updates },
    cancellationToken: _lifetime.Token);

Raw queue · first in, first out

Map editone at a time
20,000 safety stop ◂ reports
Baseline safety stop
◂◂◂telemetry still arriving behind it
Incoming5,000records / s Pending20,000safety cap
Baseline mode at the stress preset, showing the red baseline safety-stop banner and the metrics strip.
Live in baseline: the safety stop
ServerStoreSendBrowserNewest frameFleet stateMap editsCadence12

Stage 5 · 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

Honest result: under 1 truck a second became 30 to 70. But each call now takes 4 to 6 s, so every truck still waits.
More trucks a second is not a smoother map.

Roads we tried

  • GraphicsLayer in a loop: a margin, not a multiple
  • 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

Map calls a second: about 0.19 at 100 ms, about 0.20 at 250 ms. One edit outlasts either interval.

Implementations · measurements14

Where does the time go?

ServerStoreSendBrowserNewest frameFleet stateMap editsCadence15

Implementation #5 · server and browser Experiment Only send what the map can see

receive 1,600 records/s160 per frame Measured: stress preset, zoomed in on Truck 007.

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.

Implementations · comparison16

Compare the same workload

#Browser keepsMap updateServer sends
1raw queue, 20,000 capone call per reportevery frame
2latest per truckone call per truckevery frame
3latest per truckone call per batchevery frame
4latest per truckone batch, 250 msevery frame
5latest per truckone batch, 250 msonly what the map sees
Normal load~17 msper map update · 50 trucks Tenth the rate2.3 → 0.2 sper map update GPU = softwaresamepainting is not the cost
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 stages after them
How often it changestheirsyours
Formattheirsyours
Filteringnot at the sourcestage 2, per connection

Learn more: ArcGIS Living Atlas

Reliability · stale and reconnect · resilient18

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 all modes: latest per truck, one frame per interval
1Store
latest report per truck
2Send
every truck, one message, on a clockviewport per connection: about 60% fewer trucks, no faster
Browser
3Newest frame
each frame replaces the last
4Fleet state
a queue, or one slot per truckbaseline every report: fell overlatest one slot per truck
5Map edits
one call per report, per truck, or for allbatched one call for all the trucks
6Cadence
one page timer, 100 or 250 msscheduled a 250 ms tick
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, not undo it, and under stress the change that clearly helped was sending less.

Close · questions20

Questions

QR code that opens the write-up and sample code for this session.
Write-up, live demo, and sample code

Sample code and write-up

https://timpurdum.dev/post/2026/10/15/techbash.html
QR code that opens the Sessionize feedback form for Real-Time Data Visualization in Blazor.
Session feedback on Sessionize. Two minutes, and it decides whether this talk runs again.
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 mode through ?mode=, so recovery is a URL, not a branch.

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 models own their state and cleanup; 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 recordsraw queue size in baseline; dirty vehicle count in optimized modes
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

Release build, same seed and preset, 30 s warm-up, 60 s per mode, three repeats, counters reset between runs. The viewport runs fix the map extent: one zoomed on the selected truck, one at the full region.

Environment

Run   2026-09-17 · Dell XPS 8960
Build   Release · .NET 10 · Core 4.6.2
Chunk size   200 features
Browser   Chromium 153, headed, RTX 3050
Window   30 s warm-up · 60 s run · 3 repeats
Workload   500 vehicles · 10 frames/s

Full record of the 2026-09-17 four-mode run, not the later run in the main talk: 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 are named expansion points, not features hidden in the demonstration.

Appendix · choosing the update pathA5

Choosing the update path

The JavaScript engine is not the constraint

A JavaScript-only control moved the same 500 points: one collection call took 10.9 ms at 119.5 fps and a single-feature call 0.1 ms. A JavaScript GraphicsLayer matched it on throughput only; GeoBlazor's own was not measured. A tenfold drop in offered records cut the dashboard's edit p50 from 2.3 s to 0.2 s (2026-09-19), so receive work dominates.

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 call took about 994 ms against 154 ms for ApplyEdits: context only, not a timing result.
  • 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 batched recorded the same edit durations, and edit duration excludes painting.