Keeping a GeoBlazor Map Current Under a Fast Telemetry Feed

GeoBlazor fleet dispatch dashboard for the Pocono region of Monroe County, Pennsylvania: a map of green truck markers along four road routes near Mount Pocono and Tannersville, a Live status bar reading 50 vehicles, 45 moving, 0 stale, and a vehicle roster listing trucks 001 through 004 beside the map.

Putting live vehicle positions on a GeoBlazor map takes a few lines of code. Say you run a fleet of vehicles, each with a GPS tracker that sends a telemetry message twice a second, and you want to hold every vehicle's current location in a C# dictionary and draw it on the map. Two problems show up fast: deciding which incoming message is a vehicle's current location, and getting that location onto the map without the feed outrunning it. Store every message as it arrives, and the map falls behind, quietly showing vehicles where they used to be.

In GeoBlazor, a FeatureLayer and its ApplyEdits method fix this. ApplyEdits sends a set of feature additions, updates, and deletions to the layer in one call. One awaited edit runs per timer tick, instead of one per record. It updates many features in one operation, so map work scales with how many vehicles moved rather than how many records arrived.

The dashboard below is the demo I built for my TechBash 2026 session, running live in this page. It simulates 50 trucks on four road routes in Monroe County, Pennsylvania, each reporting twice a second. Select a truck in the list or on the map to follow it. Then open Display preview controls under the map and pause truck 007 to watch it go stale. The code excerpts below are trimmed, and the full project is on GitHub.

GeoBlazor fleet dispatch dashboard for the Pocono region of Monroe County, Pennsylvania: a map of green truck markers along four road routes near Mount Pocono and Tannersville, a Live status bar reading 50 vehicles, 45 moving, 0 stale, and a vehicle roster listing trucks 001 through 004 beside the map.

The full project is a client/server app. Its ASP.NET Core server runs the simulator and broadcasts each FleetFrame over SignalR. Its Blazor WebAssembly front end receives those frames and runs the state class, the 250 millisecond map timer, and every ApplyEdits call in the browser. The demo in this page collapses both halves into one Blazor Server circuit, so the pipeline runs on the server and only the ArcGIS view renders in the browser.

The code in this post is the front end, and both versions run the same logic. Two cycles run at different speeds: an ingest cycle that keeps up with the trackers, and a map cycle on its own fixed cadence. The diagram shows both, and which side each one runs on.

Where the fleet dashboard's two cycles run The full app splits across two machines. On the ASP.NET Core server, the FleetSimulator builds a FleetFrame every 500 milliseconds, a FleetBroadcaster writes frames to a bounded channel, and the FleetHub broadcasts them over SignalR. In the Blazor WebAssembly browser client, a SignalR client receives each frame, Accept keeps the newest record per vehicle in a dictionary, and a dirty set collects the changes. A 250 millisecond timer captures the dirty records and sends them to ApplyEdits on a client-side FeatureLayer, then the browser paints the map. The embedded demo runs the whole pipeline in one Blazor Server circuit instead. Server · ASP.NET Core Browser · Blazor WebAssembly GPS trackers simulated, 2 records/s each FleetSimulator a FleetFrame every 500 ms FleetBroadcaster bounded channel, drop oldest FleetHub SignalR broadcast SignalR SignalR client receives each FleetFrame Accept: newest record per vehicle C# dictionary in the browser Dirty set changed since the last map edit 250 ms timer 4 ticks/s, in the browser CaptureDirty() snapshot, then clear ApplyEdits → FeatureLayer client-side map, then the browser paints Staleness check now − ObservedAtUtc > 5 s a status change marks it dirty repeat while pending records remain
The full app splits the pipeline. The ASP.NET Core server simulates the vehicles and broadcasts frames over SignalR; the Blazor WebAssembly client keeps the newest record per vehicle and drives the map edits in the browser. The embedded demo runs both halves in one Blazor Server circuit.

How Do You Keep a Telemetry Backlog from Growing?

If you store every arriving record as it comes in, your queue can grow without bound, so this dashboard keeps only the latest record per vehicle in a dictionary keyed by vehicle ID. Each VehicleReport carries an identity, a per-vehicle sequence stamped by the tracker, and the time the vehicle produced the observation:

public sealed record VehicleReport(
    int VehicleId,
    long Sequence,
    DateTimeOffset ObservedAtUtc,
    double Longitude,
    double Latitude,
    double SpeedKph,
    string RouteId);

public sealed record FleetFrame(
    Guid RunId,
    long FrameSequence,
    DateTimeOffset PublishedAtUtc,
    IReadOnlyList<VehicleReport> Vehicles);

The dashboard's state lives in a FleetDashboardState class. Its _reports dictionary holds the latest record per vehicle, and a second dictionary, _dirtyReports, tracks which vehicles changed since the last batch went to the map. Each FleetFrame that arrives is passed to the class's Accept method, which loops over the frame's records and keeps only the ones newer than what it already holds. With the roster bookkeeping trimmed out, that loop looks like this:

foreach (VehicleReport report in frame.Vehicles)
{
    if (_reports.TryGetValue(report.VehicleId, out VehicleReport? current)
        && report.Sequence <= current.Sequence)
    {
        continue;
    }

    _reports[report.VehicleId] = report;
    MarkDirty(report);
    AddToHistory(report);
}

MarkDirty writes the record into _dirtyReports, replacing any older record for the same truck that hasn't reached the map yet. AddToHistory feeds the selected truck's speed chart, sampled at most once per second.

Sequence is what tells you whether a record is newer than the one you already hold, which is the comparison the loop above makes. In this feed the tracker stamps the sequence and counts up once per message it sends, so the receiving side never assigns it. Some feeds hand ordering to the ingestion server or a broker instead, and there the sequence records when the message arrived rather than when the vehicle observed its position. ObservedAtUtc is when the vehicle produced the observation, and it is what you compare against the current time to decide that a vehicle has gone stale:

VehicleStatus status = instant - report.ObservedAtUtc > TimeSpan.FromSeconds(5)
    ? VehicleStatus.Stale
    : report.SpeedKph > 0 ? VehicleStatus.Moving : VehicleStatus.Idle;

Track both the observed at time and the sequence number instead of relying on frame arrival time. A vehicle that stopped reporting still appears in every complete frame the simulator publishes, so a timestamp taken from FleetFrame.PublishedAtUtc would hide an outage. Status computed from ObservedAtUtc goes stale once the vehicle's latest observation is more than five seconds old, even while new frames keep arriving. That's exactly what you see when you pause truck 007 in the demo.

Sequence and ObservedAtUtc can disagree, and that is expected, because they answer different questions. Sequence orders records: it rejects duplicates and out-of-order arrivals even when a tracker's clock jumps backward or forward. ObservedAtUtc measures staleness: it reflects the device's own clock, which is the only clock that shows a vehicle has gone silent. The two drift apart whenever a tracker's clock is wrong, or when a buffered message arrives late carrying a higher sequence and an older observation. This simulator stamps both from the same frame time, so they stay in step here. A well-behaved feed keeps them in step too, and many trackers send the observation time in milliseconds as the sequence, so one number serves both roles. Separate them when you can, and when you cannot, know which question the single field answers.

The map needs one more step. A vehicle that goes quiet sends no new record, so nothing marks it as changed, and its marker would stay green. When the dashboard computes each vehicle's status, it also adds any vehicle whose status changed to _dirtyReports, so the stale symbol reaches the map in the next batch.

Keeping only the latest record works here because a position answers one question: where is this vehicle now? The newest record replaces the old one completely. This is different than something like a business transaction, where each phase of it is a fact worth storing, and keeping only the newest silently loses the earlier ones along with their count and order.

How Do You Send Many Vehicle Updates in One Map Edit?

Rather than one map edit per observation, the dashboard empties _dirtyReports on each timer tick and hands what it held to the map as a single batch. The timer loop itself is in the next section.

public PendingFleetBatch CaptureDirty()
{
    lock (_sync)
    {
        VehicleReport[] reports = _dirtyReports.Values.OrderBy(report => report.VehicleId).ToArray();
        int[] removedVehicleIds = _removedVehicleIds.Order().ToArray();
        _dirtyReports.Clear();
        _removedVehicleIds.Clear();
        return new PendingFleetBatch(Array.AsReadOnly(reports), Array.AsReadOnly(removedVehicleIds));
    }
}

Copying the records into an array before clearing _dirtyReports lets new records accumulate safely while the previous batch is still in flight. Records that arrive during the ApplyEdits call land in _dirtyReports for the next tick.

On the map, the vehicles live in a client-side FeatureLayer with an explicit object ID field and point geometry:

<FeatureLayer @ref="_vehicleLayer" Title="Fleet vehicles" ObjectIdField="oid"
              GeometryType="FeatureGeometryType.Point" Source="_initialSource" Fields="_fields"
              Renderer="_statusRenderer">
    <SpatialReference Wkid="4326" />
</FeatureLayer>

Each changed vehicle becomes a GeoBlazor Graphic: a point at the reported location, with the object ID in its oid attribute and the truck name, speed, route, and status alongside it. The renderer colors each marker by that status attribute.

return new Graphic(new Point(longitude: vehicle.Longitude, latitude: vehicle.Latitude,
    spatialReference: new SpatialReference(wkid: 4326)),
    attributes: new AttributesDictionary(new Dictionary<string, object?>
    {
        ["oid"] = objectId ?? vehicle.VehicleId,
        ["vehicle"] = $"Truck {vehicle.VehicleId:D3}",
        ["speedKph"] = vehicle.SpeedKph,
        ["route"] = vehicle.RouteName,
        ["status"] = vehicle.Status.ToString()
    }))
{ Id = graphicId ?? Guid.NewGuid() };

Each truck also keeps the same Graphic.Id for its whole life. GeoBlazor keeps a C# copy of the layer's Source and swaps each updated graphic into it, and two graphics count as the same one when their Id values match. A fresh Id on every update would leave that copy growing with stale duplicates.

The additions, updates, and removals then go to the layer in one call:

FeatureEditsResult result = await _vehicleLayer!.ApplyEdits(new FeatureEdits
{
    AddFeatures = additions,
    UpdateFeatures = updates,
    DeleteFeatures = removals
}, cancellationToken: _lifetime.Token);

FeatureEdits accepts AddFeatures, UpdateFeatures, and DeleteFeatures collections, so the same call reconciles vehicles that joined or left the roster. FeatureEditsResult returns a separate array of FeatureEditResult for each kind of edit, and every entry has its own Error. If any entry fails, the demo treats the whole batch as failed and leaves those vehicles pending, so the next tick retries them with their newest positions. After three failures in a row, the map pauses and the dashboard shows a retry button instead of trying forever.

How Often Should a GeoBlazor Map Update?

The dashboard asks for a map update every 250 milliseconds, on its own PeriodicTimer. Each tick receives the latest frame, captures the dirty set, and passes it to the map component:

using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(250), _clock);

while (await timer.WaitForNextTickAsync(_lifetime.Token))
{
    await InvokeAsync(async () => { await Advance(); StateHasChanged(); });
}

The map component merges each batch into its own pending set, keyed by vehicle, and drains it behind a _busy flag. A tick that arrives while an edit is still in flight just adds to the pending set, and the drain loop picks those vehicles up as soon as the current edit returns. Trimmed of its selection and failure checks, the drain looks like this:

if (_disposed || _busy || _failed) return;

_busy = true;
try
{
    while (_pendingUpdates.Count > 0 || _pendingRemovals.Count > 0)
    {
        VehicleListItem[] updates = _pendingUpdates.Values.ToArray();
        int[] removals = _pendingRemovals.ToArray();
        await ApplyVehicleDelta(updates, removals);
        RemoveAppliedDelta(updates, removals);
    }
}
finally
{
    _busy = false;
}

RemoveAppliedDelta only runs after the edit succeeds, which is what keeps failed vehicles pending for the retry described above.

Four requested map updates per second is not four browser frames per second, because the browser repaints on its own schedule no matter how often you push edits. I start at 250 milliseconds and measure before I trust the number. An edit that runs longer than the cadence just delays the next batch, because the drain loop never starts a second edit while one is in flight.

What Happens in JavaScript When You Call ApplyEdits?

One ApplyEdits call is not always translated to one JavaScript call. GeoBlazor splits a collection edit into internal chunks before it crosses into JavaScript, to avoid hanging the UI on large transfers. In GeoBlazor Core 4.6.2, ApplyEdits sizes those chunks from the MapView's GraphicSerializationChunkSize setting, which defaults to 200 graphics in a browser host. The 50 trucks in the demo fit in one chunk. Update 500 vehicles in one application call and three sequential JavaScript calls happen underneath it. You control the chunk size, so setting view.GraphicSerializationChunkSize = 500 sends the entire fleet in a single call. That brings back the large payload chunking exists to avoid, so measure before you raise it.

The chunking does not change the design. The dashboard still asks for one awaited update per tick, whichever update path it uses. What it changes is what your counters mean. A metric labeled "map calls per second" counts ApplyEdits calls. It does not count interop calls or painted frames. Keep the application call, the internal chunks, and the awaited edit duration in separate columns, and measure all three before you settle on a cadence. The demo's Display preview controls panel shows its own versions of these counters.

Load matters more than chunk tuning. When I ran the WebAssembly version of this dashboard at 500 trucks reporting ten times a second, the median edit took 2.3 seconds. Most of that time went to the browser's main thread receiving the data, not to the map drawing it. That's why the embedded demo runs a lighter fleet.

The next version of GeoBlazor moves from chunked, serialized data to streaming across the C#/JS boundary. It also adds support for the ArcGIS StreamLayer, which is built for exactly this kind of feed and should improve performance for larger data sets.

I keep one position per truck, send the map one batch per tick, and give it its own timer. That's what keeps the 50 trucks above current, and it's why a paused truck turns stale after five seconds instead of sitting frozen on the map looking healthy.

Frequently Asked Questions

How do you update many moving points on a GeoBlazor map efficiently?

Keep the latest record per vehicle in a dictionary, track which vehicles changed, and on a timer send all changed vehicles to a client-side FeatureLayer in one ApplyEdits call. Map work then scales with how many vehicles moved, not how many records arrived.

Why not update the map once for every telemetry record?

Each map edit crosses from .NET into JavaScript, and a fast feed produces records faster than the map can apply them one at a time. Obsolete updates queue up and the map ends up showing vehicles where they used to be.

How do you detect a vehicle that stopped reporting?

Compare the time the vehicle produced its last observation against the current time. The frame's publish time keeps advancing even when a vehicle goes silent, so only the observation time shows the outage. The demo marks a vehicle stale after five seconds.

Does GeoBlazor send one ApplyEdits call to JavaScript in a single transfer?

Not always. GeoBlazor splits large collection edits into chunks sized by the MapView GraphicSerializationChunkSize setting, which defaults to 200 graphics in a browser. An edit of 500 vehicles becomes three sequential JavaScript calls.

Can Sequence and ObservedAtUtc disagree?

Yes. Sequence orders records and rejects duplicates or out-of-order arrivals, while ObservedAtUtc measures staleness from the device's own clock. A wrong device clock or a late-arriving message can make a higher sequence carry an older observation. Keep both when the feed provides them. Many trackers send the observation time in milliseconds as the sequence instead, so one field serves both roles.

Related resources

An unhandled error has occurred. Reload 🗙