Build a fleet tracking dashboard: a practical geospatial guide

A fleet tracking dashboard combines location-event ingestion, a time-aware data model, spatial queries, and a browser map. This guide presents a provider-neutral architecture using GeoJSON, WebSockets, PostgreSQL with PostGIS, and MapLibre GL JS. It focuses on the operational details that determine whether a dashboard remains useful at fleet scale: event timestamps, stale positions, coordinate order, indexing, clustering, retention, and the difference between displaying a vehicle and computing about its relationship to roads, facilities, service areas, and other physical features.

A fleet tracking dashboard is more than a map with moving icons. It is a system for turning location events into an operational view of vehicles, routes, stops, alerts, and the physical places where work happens. The map is the presentation layer; the difficult parts are defining the event contract, preserving historical observations, handling delayed or duplicated messages, and making spatial queries fast enough for interactive use. This guide uses a small but realistic architecture: a vehicle or telematics provider emits position events, an ingestion service validates and stores them, a WebSocket or server-sent event channel distributes current positions, and MapLibre GL JS renders the result. PostgreSQL and PostGIS provide the durable spatial store and support proximity queries such as finding vehicles near a depot or service location. MapLibre supports GeoJSON sources and updating a source when live data changes. (maplibre.org) The exact payload from a GPS device, telematics vendor, or transportation platform is not standardized here. Treat the sample schema as an application contract that you control, and adapt the adapter at the ingestion boundary to the provider's documented format.

How EDK fits

For: Developers and GIS engineers building an internal fleet-operations dashboard, dispatch interface, field-service map, delivery-monitoring tool, or location-aware operations application.

Fleet tracking is a direct example of finding, understanding, and computing about the physical world. A vehicle position is useful by itself, but its operational meaning comes from relationships: which road segment it is near, whether it is inside a depot, how far it is from a customer, whether it has entered a service area, and how its present location compares with previous observations. A world model can provide the surrounding physical context—roads, buildings, facilities, infrastructure, waterways, and administrative or operational areas—while a computation layer can filter, rank, aggregate, measure proximity, detect change, and compare current and historical states. That connection should remain specific rather than promotional. E2 is not being presented here as a telematics provider, GPS hardware system, routing engine, or ready-made fleet dashboard. The relevant editorial connection is that a fleet dashboard becomes more useful when live vehicle events can be interpreted against a searchable, computable representation of the places those vehicles traverse. For example, a dashboard could relate a vehicle to residential roads, service roads, charging stations, parking areas, construction permits, or reported street and utility issues when those datasets are available and appropriate for the operational question.

  • •Enrich current vehicle positions with nearby physical features such as depots, charging stations, service roads, parking areas, or customer locations, then rank the nearest relevant feature using distance and freshness constraints.
  • •Compare historical vehicle tracks with the surrounding world: summarize time spent near facilities, detect repeated visits to the same area, identify routes passing through selected regions, or measure changes in activity around infrastructure and service locations.
  • •Create a common geographic query surface for dispatch and operations teams so they can ask which vehicles are inside an area, near a road or facility, or associated with a set of places without maintaining separate map, spatial-database, and analysis workflows.
Primary output
A live operational map of current vehicle positions with optional historical tracks and spatial context.
Recommended current-state format
GeoJSON FeatureCollection for a small or moderate active fleet; use server-side filtering or tiled/vector data as volume grows.
Distance units in the example
Meters, using PostGIS geography points with ST_DWithin.
Coordinate order
GeoJSON uses [longitude, latitude].
Historical data model
Append-only observations keyed by vehicle and observed timestamp, plus a separate latest-state record.
Provider-specific behavior
GPS payload fields, authentication, update frequency, accuracy, and retention terms vary by provider and are not assumed here.

The EDK outcome

Before you start

Basic JavaScript and SQL knowledge.

A PostgreSQL installation with PostGIS enabled, or access to an equivalent spatial database.

A map style and tile source whose terms, attribution, and access requirements are understood.

A simulated or real position-event feed with a stable vehicle identifier.

A decision about authentication, tenant isolation, retention, and who is authorized to see vehicle locations.

Learning objectives

Design a normalized vehicle-position event contract with explicit timestamps and coordinate semantics.

Separate current vehicle state from historical observations.

Store and query vehicle points using PostGIS geography and spatial indexes.

Render current positions in MapLibre GL JS and update a GeoJSON source.

Recognize when GeoJSON should give way to filtering, clustering, vector tiles, or a different delivery strategy.

Relate live vehicle events to surrounding physical features without confusing map visualization with authoritative spatial computation.

Verification

Replay a fixture containing valid, invalid, duplicate, delayed, and out-of-order events.

Verify that a delayed event is retained in history but cannot overwrite a newer current position.

Check that a known coordinate appears in the correct place and that longitude/latitude are not reversed.

Run EXPLAIN on the radius query and confirm that the spatial index is available to the planner for the workload.

Disconnect the live channel, reconnect it, and confirm that the client does not regress to older positions.

Open the dashboard with zero vehicles, one vehicle, and a large fixture set.

Confirm that stale status is based on observed_at and that users can inspect both observed and received timestamps.

Verify tile attribution, provider terms, authentication, and access controls before exposing the dashboard to users.

Troubleshooting

Markers appear in the wrong country: check GeoJSON coordinate order, SRID assignment, and whether the provider supplied latitude before longitude.

The map is blank: check that the map container has a nonzero height, the style URL is reachable, the tile source permits the request origin, and the browser console has no CORS or authentication errors. MapLibre's style and GeoJSON documentation explicitly require a valid style/source configuration and appropriate cross-origin access for browser-loaded data. (maplibre.org)

Vehicle positions move backward: compare observed_at values and reject late events from the current-state upsert; do not use only server receipt time for ordering.

All vehicles show as online: calculate freshness from observed_at and test with deliberately old timestamps.

The proximity query is slow: confirm the position columns use a spatial type, add a GiST index, use geography consistently for meter distances, and inspect the query plan. ST_DWithin is designed for indexed distance filtering when suitable indexes exist. (postgis.net)

The browser becomes slow during updates: avoid replacing a very large GeoJSON collection at high frequency; send deltas, limit data to the viewport, cluster points, reduce update frequency, or serve larger layers as vector tiles.

Historical tracks consume too much memory: query a bounded time range, simplify lines for display, aggregate old observations, or generate tiled representations rather than returning every raw point.

Reconnects create duplicate or stale markers: make vehicle_id the client-side key, attach a sequence or timestamp to updates, and discard messages older than the current client state.

A vehicle is visually near a road but the API reports no match: inspect the distance tolerance, coordinate system, geometry type, and whether the road dataset actually covers that location. A basemap display is not proof of a spatial join.

Next steps

Add authenticated fleet and tenant filters before connecting real vehicles.

Add a detail panel showing last observation, receipt time, speed, heading, accuracy, and data age.

Introduce geofences or operational areas using explicit spatial predicates and recorded thresholds.

Add historical playback from the observation table with server-side simplification or tiling.

Use surrounding physical-feature data to answer operational questions such as nearest depot, charging station, service road, or active work area.

When the system grows, evaluate vector tiles, event deltas, partitioned history, queue-backed ingestion, and a dedicated observability dashboard.

For batch track analysis, use the related GeoPandas guide; for large browser-delivered layers, use the related vector-tile React guide.

What you need before writing code

Start by defining the operational question. A dispatcher may need current vehicle state and stale-device warnings; an analyst may need six months of tracks; a supervisor may need vehicles within a service area or near a facility. These are different workloads and should not be forced into one table or one API response.

You need a position-event source, such as a GPS device, mobile application, or telematics provider. The provider-specific authentication, message format, update interval, accuracy fields, and licensing terms are outside this generic guide and must be verified in that provider's documentation. If no live feed exists, begin with recorded test events and replace the adapter later.

  • •A documented or simulated location-event feed.
  • •A stable vehicle identifier and an event timestamp.
  • •A decision about whether timestamps are device time, server receipt time, or both.
  • •A PostgreSQL/PostGIS database for current state and history.
  • •A browser application with a map style, a tile source, and a live-update channel.
  • •A policy for access control, retention, personal data, and audit logs.

Step 1: define and validate the position-event contract

Do not let the browser consume an undocumented vendor payload directly. Normalize incoming messages into an application event with explicit units and semantics. A useful minimum contract contains vehicle_id, observed_at, received_at, longitude, latitude, heading, speed, accuracy, and an optional status. Keep both observed_at and received_at: a device can report an old observation after a network delay, and those two timestamps let you distinguish movement from delivery latency.

GeoJSON coordinates are ordered as [longitude, latitude]. Validate that longitude is between -180 and 180 and latitude is between -90 and 90 before creating a geometry. Reject or quarantine invalid events rather than allowing a malformed point to enter the live map.

  • •vehicle_id: stable identifier for the vehicle or asset.
  • •observed_at: timestamp supplied by the device or upstream system.
  • •received_at: timestamp assigned by your ingestion service.
  • •longitude and latitude: decimal degrees in WGS 84 order.
  • •heading: degrees if supplied; define whether 0 means north.
  • •speed: explicitly documented units, such as meters per second or miles per hour.
  • •accuracy_m: estimated horizontal accuracy when available.
  • •status: application state such as moving, idle, offline, or unknown.

Step 2: store current state and historical observations

Use two logical records: one for the latest known state and one append-only table for observations. The current-state table keeps the dashboard query small. The history table supports playback, audit, route analysis, dwell calculations, and later spatial joins.

The following schema is intentionally minimal. Add foreign keys, tenant identifiers, device metadata, and retention partitions for a production system. The unique key on vehicle_id makes an upsert possible, while the GiST indexes support spatial queries.

  • •CREATE EXTENSION IF NOT EXISTS postgis;
  • •CREATE TABLE vehicle_current (vehicle_id text PRIMARY KEY, observed_at timestamptz NOT NULL, received_at timestamptz NOT NULL, position geography(Point, 4326) NOT NULL, heading double precision, speed_mps double precision, accuracy_m double precision, status text, CHECK (ST_Y(position::geometry) BETWEEN -90 AND 90), CHECK (ST_X(position::geometry) BETWEEN -180 AND 180));
  • •CREATE INDEX vehicle_current_position_gix ON vehicle_current USING GIST (position);
  • •CREATE TABLE vehicle_observation (id bigserial PRIMARY KEY, vehicle_id text NOT NULL, observed_at timestamptz NOT NULL, received_at timestamptz NOT NULL, position geography(Point, 4326) NOT NULL, heading double precision, speed_mps double precision, accuracy_m double precision, status text);
  • •CREATE INDEX vehicle_observation_vehicle_time_idx ON vehicle_observation (vehicle_id, observed_at DESC);
  • •CREATE INDEX vehicle_observation_position_gix ON vehicle_observation USING GIST (position);

Step 3: ingest, deduplicate, and publish events

The ingestion service should validate the event, write the historical observation, update current state only when the incoming observation is not older than the stored one, and publish a compact current-state message to connected clients. The exact implementation may use an HTTP endpoint, a message broker, or a provider-specific stream. The important boundary is that provider details stop at the adapter.

A simplified SQL upsert looks like this:

  • •INSERT INTO vehicle_current (vehicle_id, observed_at, received_at, position, heading, speed_mps, accuracy_m, status) VALUES ($1, $2, $3, ST_SetSRID(ST_Point($4, $5), 4326)::geography, $6, $7, $8, $9) ON CONFLICT (vehicle_id) DO UPDATE SET observed_at = EXCLUDED.observed_at, received_at = EXCLUDED.received_at, position = EXCLUDED.position, heading = EXCLUDED.heading, speed_mps = EXCLUDED.speed_mps, accuracy_m = EXCLUDED.accuracy_m, status = EXCLUDED.status WHERE EXCLUDED.observed_at >= vehicle_current.observed_at;
  • •INSERT INTO vehicle_observation (vehicle_id, observed_at, received_at, position, heading, speed_mps, accuracy_m, status) VALUES ($1, $2, $3, ST_SetSRID(ST_Point($4, $5), 4326)::geography, $6, $7, $8, $9);

Step 4: expose a small current-state API

Return a FeatureCollection when the dashboard needs to load or refresh all visible vehicles. The API should support bounding-box filtering, fleet or tenant filtering, status filtering, and a maximum result count. Do not return an unbounded history table to the browser.

A response feature can include only the properties needed for styling and interaction. Keep sensitive fields out of the public response, and enforce authorization on the server rather than relying on map-layer visibility.

  • •SELECT vehicle_id, observed_at, received_at, ST_AsGeoJSON(position::geometry)::json AS geometry, heading, speed_mps, accuracy_m, status FROM vehicle_current WHERE ST_DWithin(position, ST_SetSRID(ST_Point($1, $2), 4326)::geography, $3) ORDER BY observed_at DESC LIMIT $4;
  • •Use a freshness rule such as now() - observed_at > interval '5 minutes' to label a vehicle stale. The threshold is an operational choice, not a universal standard.
  • •For historical playback, query a bounded time interval and simplify or tile the result before sending it to the browser.

Step 5: render and update the dashboard with MapLibre

Install MapLibre GL JS and include its CSS. The map requires a style document and a tile source; this guide does not prescribe a particular basemap provider. Check the provider's attribution, usage limits, terms, and access-token requirements before deployment.

Create one GeoJSON source for current vehicles and a circle or symbol layer for the visual representation. MapLibre documents setData on GeoJSONSource for replacing source data, and its examples include live realtime updates. (maplibre.org)

  • •npm install maplibre-gl
  • •import maplibregl from 'maplibre-gl';
  • •import 'maplibre-gl/dist/maplibre-gl.css';
  • •const map = new maplibregl.Map({ container: 'map', style: MAP_STYLE_URL, center: [-96, 38], zoom: 4 });
  • •map.on('load', async () => { const vehicles = await fetch('/api/vehicles/current').then(r => r.json()); map.addSource('vehicles', { type: 'geojson', data: vehicles }); map.addLayer({ id: 'vehicle-points', type: 'circle', source: 'vehicles', paint: { 'circle-radius': 6, 'circle-color': ['case', ['==', ['get', 'status'], 'stale'], '#9ca3af', '#2563eb'], 'circle-stroke-color': '#ffffff', 'circle-stroke-width': 1 } }); });
  • •function updateVehicles(featureCollection) { const source = map.getSource('vehicles'); if (source) source.setData(featureCollection); }

Step 6: add live delivery, stale-state logic, and spatial context

A live channel can send either complete FeatureCollections or small upsert/delete messages. Complete collections are simpler; deltas use less bandwidth as the fleet grows but require client-side state management. Whichever approach you choose, include an event sequence or server timestamp so the client can ignore an older update that arrives after a newer one.

A minimal browser-side WebSocket pattern is:

  • •const socket = new WebSocket(LIVE_URL);
  • •socket.addEventListener('message', event => { const message = JSON.parse(event.data); if (message.type === 'vehicle_update') { applyVehicleUpdate(message.feature); updateVehicles(buildFeatureCollection()); } });
  • •socket.addEventListener('close', () => scheduleReconnect());
  • •socket.addEventListener('error', () => socket.close());
  • •The map should communicate freshness visually. A gray or outlined marker can indicate stale data; a popup can show observed_at, received_at, speed, and accuracy. Avoid implying that a vehicle is currently at a point when the last observation is old or has poor accuracy.
  • •This is also where a world-model perspective becomes useful. A current position can be joined to nearby roads, facilities, charging stations, parking areas, service locations, or operational boundaries. Those relationships should be computed server-side or in a spatial analysis workflow, not inferred solely from the visual proximity of icons on a basemap.

Step 7: verify the dashboard before production

Test with recorded events before connecting real devices. Include out-of-order timestamps, duplicate messages, invalid coordinates, a vehicle that stops reporting, a burst of updates, and two vehicles with the same display name but different stable identifiers.

Verify the geographic behavior separately from the visual behavior. A marker can appear plausible while the underlying point is reversed, the map is using the wrong coordinate reference system, or the timestamp is being interpreted in local time instead of UTC.

  • •Confirm coordinate order with a known test point.
  • •Confirm observed_at and received_at are stored in UTC.
  • •Confirm older events cannot overwrite newer current state.
  • •Confirm stale vehicles remain visible but are clearly labeled.
  • •Confirm a disconnected client reconnects without creating duplicate markers.
  • •Confirm historical queries have bounded time and result limits.
  • •Confirm the map has attribution and the chosen tile provider permits the intended use.

Workflow

01

1. Establish the event contract

Choose stable identifiers, define observed_at and received_at, document units, and validate longitude/latitude before storage. Keep provider-specific parsing inside an ingestion adapter.

type PositionEvent = { vehicle_id: string; observed_at: string; received_at: string; longitude: number; latitude: number; heading?: number; speed_mps?: number; accuracy_m?: number; status?: string; }; 
02

2. Create spatial storage

Enable PostGIS, create current and historical tables, and add temporal and GiST spatial indexes. Use geography(Point, 4326) when your primary distance operations should be measured in meters.

CREATE EXTENSION IF NOT EXISTS postgis;\nCREATE INDEX vehicle_current_position_gix ON vehicle_current USING GIST (position);\nCREATE INDEX vehicle_observation_vehicle_time_idx ON vehicle_observation (vehicle_id, observed_at DESC);
03

3. Ingest defensively

Write each valid observation to history, then upsert current state only when the incoming observed_at is at least as recent as the stored value. Publish a normalized update after the database write succeeds.

INSERT INTO vehicle_current (...) VALUES (...) ON CONFLICT (vehicle_id) DO UPDATE SET ... WHERE EXCLUDED.observed_at >= vehicle_current.observed_at;
04

4. Add bounded spatial APIs

Expose current vehicles as a FeatureCollection or a bounded query response. Support viewport, fleet, status, freshness, and radius filters. Do not expose unlimited historical data from a current-state endpoint.

SELECT vehicle_id, observed_at, ST_AsGeoJSON(position::geometry)::json AS geometry\nFROM vehicle_current\nWHERE ST_DWithin(position, ST_SetSRID(ST_Point($1, $2), 4326)::geography, $3)\nLIMIT $4;
05

5. Render the current fleet

Initialize MapLibre with an approved style, add a GeoJSON source, add a point layer, and update the source when the API or live channel provides new state.

map.addSource('vehicles', { type: 'geojson', data: featureCollection });\nmap.addLayer({ id: 'vehicle-points', type: 'circle', source: 'vehicles', paint: { 'circle-radius': 6, 'circle-color': '#2563eb' } });\nmap.getSource('vehicles').setData(nextFeatureCollection);
06

6. Add live delivery and stale indicators

Use WebSocket, server-sent events, or another documented live channel. Include ordering information, reconnect with backoff, and calculate freshness from observed_at. Display stale state instead of implying that every marker is current.

const socket = new WebSocket(LIVE_URL);\nsocket.addEventListener('message', ({ data }) => { const message = JSON.parse(data); if (message.type === 'vehicle_update') applyVehicleUpdate(message.feature); });
07

7. Test physical-world relationships

Test proximity to depots, roads, customer locations, and service areas with known fixtures. Verify that the result comes from an explicit spatial query or documented analysis rather than from visual proximity on the map.

SELECT v.vehicle_id, f.feature_id\nFROM vehicle_current v\nJOIN operational_feature f\n  ON ST_DWithin(v.position, f.position, 100);

Questions teams ask

Should I store only the latest position for each vehicle?

No, not if you need playback, route analysis, auditability, dwell calculations, or incident review. Keep a compact current-state record for fast dashboard reads and an append-only observation history with a retention policy. If storage is constrained, archive or aggregate old observations rather than silently discarding the distinction between current state and history.

Should the browser calculate distance to depots or service locations?

Usually no. The server or spatial database should perform authoritative proximity filtering, especially when access control, accuracy, or reproducibility matters. PostGIS ST_DWithin supports distance checks in meters for geography values and can use available spatial indexes. The browser can display the result and provide interaction, but should not be the only place where operational relationships are computed. (postgis.net)

Is GeoJSON suitable for a large fleet?

It can be suitable for an initial current-position layer or a bounded viewport query. It becomes less attractive when every update replaces a very large FeatureCollection, when historical tracks are dense, or when many clients receive the same high-frequency stream. At that point, send deltas, filter by viewport or fleet, cluster points, or move to vector-tile and stream-oriented delivery patterns.

How do I show a vehicle that has stopped reporting?

Calculate freshness from observed_at, not only from the time your server last received a message. Keep received_at as a separate diagnostic field. Apply an operational threshold appropriate to the update interval, label the vehicle stale, and expose the timestamps in the detail panel so users can distinguish an idle vehicle from an offline device.

Can a basemap tell me which road a vehicle is on?

Not reliably by visual inspection alone. A basemap provides context, while road matching or map matching requires a documented algorithm and appropriate road data. If the operational question is whether a vehicle is near a road, facility, boundary, or other physical feature, use an explicit spatial query and record the method and tolerance used.

Keep exploring

Sources

More in Guides