Build a fleet tracking dashboard production guide
A fleet tracking dashboard is more than a map with moving markers. A reliable implementation needs a stable position-event contract, separate current-state and historical data models, spatial indexes, stale-data handling, deduplication, efficient browser updates, and clear operational boundaries. This provider-neutral guide uses PostgreSQL with PostGIS for spatial storage and queries, WebSockets for live delivery, GeoJSON for browser-compatible features, and MapLibre GL JS for rendering. It also explains how fleet positions become useful when related to the physical world: roads, depots, service areas, charging stations, facilities, hazards, and other geographic features.
This production guide shows how to build the core of a fleet tracking dashboard without tying the design to a particular GPS vendor, telematics provider, map tile service, or routing API. The central design decision is to treat location events as time-stamped observations rather than as permanent truth. The dashboard should distinguish a vehicle’s latest known position from its historical track, expose freshness and quality information to operators, and perform spatial computation in a database rather than in the browser whenever the query concerns many vehicles or many geographic features. The examples use longitude-latitude coordinates in GeoJSON order, PostgreSQL with PostGIS, a small HTTP API, WebSockets for updates, and MapLibre GL JS for map display. Adapt the transport and framework to your application, but preserve the data contracts and operational checks described here.
How EDK fits
For: Engineers and technical product teams building a browser-based fleet operations dashboard, dispatch console, field-service monitor, delivery tracker, or other real-time application that must display and compute about moving assets.
Fleet tracking is a direct example of computing about the physical world: a vehicle position is useful only when it can be interpreted in relation to roads, buildings, depots, service territories, charging stations, restricted areas, incidents, and other geographic features. The dashboard itself is only the presentation layer. The deeper workflow is to ingest observations, identify the relevant physical feature or region, calculate proximity or containment, compare movement over time, and return an operational result. That is where the E2 perspective is relevant without changing the subject of this guide. A world model treats the vehicle as one moving object in a larger searchable representation of Earth. For example, a fleet system may ask which vehicles are within a defined distance of a depot, which routes pass near a road closure, which assets entered a service area, or which trucks have remained near a customer site beyond an expected dwell time. These are geographic computations involving external fleet events and external location data; this guide does not claim that E2 provides the tracking feed, telematics hardware, map tiles, routing engine, or implementation described below.
- •Relate current vehicle positions to physical features such as roads, depots, service areas, charging stations, facilities, or restricted zones using proximity, containment, and nearest-feature queries.
- •Aggregate fleet observations by geographic region, road corridor, operating territory, or facility to compare utilization, dwell time, coverage, incidents, and movement over time.
- •Detect changes in the physical operating context by combining time-stamped vehicle observations with updated geographic data, such as newly added facilities, changed road access, or revised service boundaries.
The EDK outcome
Before you start
A documented position-event source or a simulator that can emit vehicle identifiers, timestamps, coordinates, and optional telemetry.
PostgreSQL with PostGIS installed and enabled.
A server application with authenticated HTTP and WebSocket support.
Basic familiarity with SQL, GeoJSON, browser JavaScript, and map layers.
A policy for access control, retention, privacy, and attribution.
Learning objectives
Design a position-event contract that handles timestamps, coordinate order, quality, and idempotency.
Separate current vehicle state from historical observations for different query patterns.
Build indexed proximity and containment queries with PostGIS.
Deliver current state and incremental updates to a browser map.
Recognize when GeoJSON is sufficient and when clustering, viewport filtering, or vector tiles are needed.
Test the failure modes that make real-time geographic systems misleading or unreliable.
Verification
Insert a valid event and confirm that it appears in both the observation history and current-state response.
Insert the same event twice and confirm that the second attempt is idempotent or rejected without duplicating history.
Insert an older event after a newer event and confirm that historical storage follows policy while current state does not move backward unintentionally.
Query a known depot with ST_DWithin and compare the result with a trusted test fixture.
Send a coordinate in latitude-longitude order deliberately and confirm that validation or integration tests detect the error.
Disconnect a browser client, publish updates, reconnect, and confirm that the snapshot restores the correct latest state.
Advance the freshness clock and confirm that stale vehicles change style and status without disappearing unexpectedly.
Load-test batches of updates and confirm that slow WebSocket clients do not block ingestion.
Troubleshooting
Markers appear in the wrong country: check whether the source adapter supplied [latitude, longitude] while the database or GeoJSON expects [longitude, latitude]. Also check whether a transform was applied twice.
Distance results are in unexpected units: inspect the column type and SRID. Geography distances are in meters; geometry distances use the units of the spatial reference system. (postgis.net)
The map shows old positions as live: calculate and display freshness from observed_at and received_at, and define a visible stale threshold.
Vehicles jump backward: compare event timestamps or source versions before updating current state. Handle late events separately from current-state updates.
The browser becomes slow: reduce property payloads, batch source updates, enable clustering, query only the viewport, or move to vector tiles for supporting layers and large collections.
Proximity queries are slow: confirm a GiST index exists, avoid wrapping the indexed column in an unnecessary transformation, inspect the query plan, and use ST_DWithin for the indexed filter. (postgis.net)
Geofence results disagree at boundaries: verify SRIDs, geometry validity, and the precise boundary semantics required by the product. Test points on, just inside, and just outside the boundary.
Updates disappear after a reconnect: treat the database or durable event log as authoritative and fetch a fresh snapshot after reconnect rather than relying on missed WebSocket messages.
A route is operationally near but not geographically near: distinguish radius distance from road-network travel distance. Use a routing or network-analysis workflow for reachability and ETA questions.
Next steps
Add role-based access control and tenant isolation before exposing vehicle history to multiple organizations.
Add server-side freshness classes, trip segmentation, dwell detection, and geofence events as derived fields rather than client-only calculations.
Introduce viewport-aware queries or vector tiles for large contextual layers such as roads, facilities, service areas, and incidents.
Add replay tooling so operators can inspect what the system knew at a specific historical time.
Join vehicle observations to authoritative physical-world data to calculate nearest facilities, service-area coverage, road-corridor utilization, or changes in operating context.
Document the source, update cadence, coordinate reference system, licensing, and refresh date for every geographic layer shown beside the fleet.
What you need before writing code
You need a source of position events, such as a telematics gateway, mobile application, or simulator. The guide does not prescribe a vendor or a specific GPS protocol. Confirm the provider’s delivery semantics before implementation: whether events can arrive late, whether identifiers are stable, whether timestamps are device-generated or server-generated, and whether coordinates may be missing or invalid.
You also need PostgreSQL with PostGIS enabled, a server runtime capable of serving HTTP and WebSocket connections, and a browser map library. MapLibre GL JS can render interactive maps from vector tiles and can also consume GeoJSON sources; its GeoJSON source API supports replacing source data with setData and offers clustering-related methods for clustered sources. (maplibre.org)
- •PostgreSQL with the PostGIS extension and a GiST spatial index.
- •A position-event ingestion endpoint or message consumer.
- •A current-state API and a historical-query API.
- •A WebSocket endpoint or another authenticated live-update channel.
- •A browser application with MapLibre GL JS or an equivalent renderer.
- •A monitoring plan for ingest delay, rejected events, database latency, WebSocket connections, and stale vehicles.
Step 1: define and validate the position-event contract
Start with a narrow event shape. Keep the original event identifier if the upstream system provides one, and retain both the device timestamp and the server receipt timestamp. The distinction matters: a device may report late because of poor connectivity, while the server may receive events out of order.
Use longitude first and latitude second in GeoJSON-compatible coordinates. Reject impossible latitude values, reject non-finite numbers, and decide how to handle a coordinate of [0, 0]. Do not silently convert coordinate order based on intuition; require the source adapter to document its input format.
- •vehicle_id: stable identifier for the asset.
- •event_id: upstream identifier or a generated idempotency key.
- •observed_at: timestamp supplied by the device or source.
- •received_at: timestamp assigned by the ingestion service.
- •longitude and latitude: numeric coordinates in WGS 84 longitude-latitude order.
- •speed_mps and heading_deg: optional measurements with documented units.
- •accuracy_m: optional estimated horizontal accuracy.
- •status: optional state such as moving, stopped, offline, or unknown.
- •source: adapter or upstream system name.
Step 2: store current state and historical observations
Use one table for the latest known state and another append-only table for observations. The current table should be optimized for the dashboard’s common query: return one row per vehicle. The observations table should support replay, audits, trip reconstruction, dwell analysis, and later aggregation.
The following schema is intentionally small. Add a tenant key, device metadata, and partitioning strategy when your workload requires them.
- •Use geography(Point, 4326) when distance calculations should be expressed in meters over the earth’s surface.
- •Use geometry when the application works in a projected coordinate system or requires geometry-specific operations. PostGIS documents that distance units for geometry come from the spatial reference system, while geography distance is measured in meters. (postgis.net)
- •Create a GiST index on the spatial column and ordinary indexes on vehicle_id and observed_at.
- •Consider time partitioning for high-volume history, but benchmark the partition key and retention process against actual event rates.
Step 3: ingest, deduplicate, and publish events
The ingestion path should validate first, write second, and publish third. Publishing an event before the database transaction succeeds can make the browser display a state that cannot be recovered from the authoritative store.
Deduplicate using the upstream event identifier when available. If the source does not provide one, construct an idempotency key from a documented combination of vehicle identifier, source timestamp, and sequence number. Do not use a rounded coordinate alone: a vehicle can legitimately report the same position repeatedly.
- •Validate schema and units at the adapter boundary.
- •Normalize timestamps to UTC for storage and comparison.
- •Write the observation and update current state in one transaction where possible.
- •Record rejection reasons rather than dropping malformed events silently.
- •Publish a compact current-state message after commit.
- •Use backpressure or a queue so a slow browser does not block ingestion.
Step 4: expose current state and spatial operations
Keep the dashboard API small. A current-state endpoint can return vehicle identifiers, coordinates, status, observed_at, received_at, and a computed freshness class. A separate history endpoint can accept a vehicle identifier and time range, with server-side limits on maximum duration and result size.
Spatial questions should be answered by the database. For example, to find vehicles within 500 meters of a depot, use ST_DWithin against geography columns. PostGIS documents that ST_DWithin tests whether geometries are within a distance, supports geography distances in meters, and includes a bounding-box comparison that can use available indexes. (postgis.net)
- •Use ST_DWithin for indexed radius filtering rather than fetching all vehicles and calculating distances in application code.
- •Use containment predicates such as ST_Within when the question is whether a position lies inside a service area or geofence. Both geometries must use the same coordinate projection. (postgis.net)
- •Return the computed relationship as part of the API response, such as nearest depot, current territory, or geofence status.
- •Limit historical track queries by time window, simplification tolerance, or maximum point count.
- •Make freshness a server-computed field so all clients use the same rule.
Step 5: deliver updates to the browser
A WebSocket connection is useful for low-latency updates, but it is not a substitute for durable state. When a browser connects, send a current snapshot or a versioned cursor first; then send incremental changes. If the connection breaks, the client should reconnect, fetch a fresh snapshot, and resume rather than assuming it received every event.
Keep messages compact. A live update normally needs an identifier, coordinates, observed_at, received_at, and status. Avoid sending full historical trails or repeated static vehicle metadata with every position update.
- •Authenticate the WebSocket handshake and authorize fleet or tenant scope.
- •Send periodic heartbeats and remove dead connections.
- •Use bounded queues per client so slow consumers do not exhaust memory.
- •Reconnect with exponential backoff and refresh the initial snapshot after reconnect.
- •Expose connection state and last-update time in the interface.
Step 6: render the operational map
Use a GeoJSON FeatureCollection for a small or moderate current fleet, with one Point feature per vehicle. MapLibre’s GeoJSON source accepts inline GeoJSON or a URL and can update the source data programmatically. (maplibre.org)
A minimal client-side pattern is:
- •Create one source named vehicles.
- •Create a circle or symbol layer that reads status from feature properties.
- •Update the source collection when a new snapshot or batch of updates arrives.
- •Use popups or a side panel for timestamp, speed, heading, and freshness.
- •Cluster points when overlapping markers make individual vehicles difficult to inspect.
Step 7: test the production failure modes
Test more than the happy path. Replay delayed events, duplicate events, invalid coordinates, missing timestamps, rapid reconnects, database restarts, browser backgrounding, and bursts of updates. Verify that a stale vehicle remains visible but is clearly marked stale rather than appearing active.
Measure end-to-end delay from observed_at to browser receipt, not only server processing time. Test the dashboard with a realistic viewport and fleet size. If a single GeoJSON source becomes too large or updates become expensive, move toward viewport-scoped queries, vector tiles, server-side clustering, or a different delivery strategy.
- •Unit-test event validation and timestamp ordering.
- •Integration-test database transactions and idempotency.
- •Load-test simultaneous ingest and current-state reads.
- •Measure WebSocket fan-out with slow and disconnected clients.
- •Compare map-rendered positions with raw API responses.
- •Test retention and deletion jobs against compliance requirements.
Workflow
Create the PostGIS tables
Enable PostGIS, create a current-state table and an append-only observation table, and add indexes for vehicle identity, timestamps, and spatial predicates. The exact schema should reflect your source’s identifiers and tenancy model.
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,
speed_mps double precision,
heading_deg double precision,
status text NOT NULL DEFAULT 'unknown',
source_version bigint NOT NULL DEFAULT 0
);
CREATE INDEX vehicle_current_position_gix
ON vehicle_current USING GIST (position);
CREATE TABLE vehicle_observation (
event_id text PRIMARY KEY,
vehicle_id text NOT NULL,
observed_at timestamptz NOT NULL,
received_at timestamptz NOT NULL,
position geography(Point, 4326) NOT NULL,
speed_mps double precision,
heading_deg double precision,
status text NOT NULL DEFAULT 'unknown'
);
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);Validate and normalize an incoming event
Validate at the ingestion boundary. Convert source-specific coordinate formats to longitude-latitude order, normalize timestamps to UTC, reject impossible values, and preserve the raw event for debugging only when policy permits. Do not silently replace missing timestamps with the current time without recording that the timestamp was generated by the server.
function normalizeEvent(input) {
const longitude = Number(input.longitude);
const latitude = Number(input.latitude);
if (!Number.isFinite(longitude) || longitude < -180 || longitude > 180) {
throw new Error('invalid longitude');
}
if (!Number.isFinite(latitude) || latitude < -90 || latitude > 90) {
throw new Error('invalid latitude');
}
if (!input.vehicle_id || !input.event_id || !input.observed_at) {
throw new Error('missing required event field');
}
return {
vehicle_id: String(input.vehicle_id),
event_id: String(input.event_id),
observed_at: new Date(input.observed_at).toISOString(),
received_at: new Date().toISOString(),
longitude,
latitude,
speed_mps: input.speed_mps == null ? null : Number(input.speed_mps),
heading_deg: input.heading_deg == null ? null : Number(input.heading_deg),
status: input.status || 'unknown'
};
}Run an indexed proximity query
Represent the depot or other fixed feature as a geography value and use ST_DWithin for the radius filter. Add a freshness condition when the operational question concerns active vehicles rather than last-known positions.
SELECT
v.vehicle_id,
v.observed_at,
v.received_at,
ST_Distance(v.position, d.position) AS distance_meters
FROM vehicle_current AS v
CROSS JOIN (
SELECT ST_SetSRID(ST_Point(-73.9857, 40.7484), 4326)::geography AS position
) AS d
WHERE ST_DWithin(v.position, d.position, 500)
AND v.received_at >= now() - interval '10 minutes'
ORDER BY distance_meters;Return current state as GeoJSON
For a small or moderate fleet, expose a FeatureCollection so the browser can update one map source. Keep operational metadata in properties and preserve the coordinate order expected by GeoJSON.
SELECT json_build_object(
'type', 'FeatureCollection',
'features', COALESCE(json_agg(json_build_object(
'type', 'Feature',
'id', vehicle_id,
'geometry', ST_AsGeoJSON(position::geometry)::json,
'properties', json_build_object(
'vehicle_id', vehicle_id,
'observed_at', observed_at,
'received_at', received_at,
'status', status,
'speed_mps', speed_mps,
'heading_deg', heading_deg
)
)), '[]'::json)
)
FROM vehicle_current;Update the MapLibre source
Create the source once and replace its data when the API returns a new snapshot. For frequent updates, batch changes and avoid rebuilding large property objects unnecessarily. Add a status-aware layer and a visible stale state rather than relying on marker position alone.
map.addSource('vehicles', {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
cluster: true,
clusterMaxZoom: 14,
clusterRadius: 50
});
map.addLayer({
id: 'vehicle-points',
type: 'circle',
source: 'vehicles',
filter: ['!', ['has', 'point_count']],
paint: {
'circle-radius': 6,
'circle-color': [
'match', ['get', 'status'],
'moving', '#16a34a',
'stopped', '#f59e0b',
'offline', '#6b7280',
'#2563eb'
],
'circle-stroke-color': '#ffffff',
'circle-stroke-width': 1
}
});
function renderVehicles(featureCollection) {
map.getSource('vehicles').setData(featureCollection);
}Reconnect and resynchronize
When a WebSocket closes, reconnect with bounded exponential backoff. On successful reconnection, request a fresh current-state snapshot before applying incremental messages. Use a version or timestamp check to prevent an older message from overwriting a newer marker.
let retry = 0;
function connect() {
const socket = new WebSocket('/api/live/vehicles');
socket.onopen = () => {
retry = 0;
fetch('/api/vehicles/current')
.then(response => response.json())
.then(renderVehicles);
};
socket.onmessage = event => {
const update = JSON.parse(event.data);
applyIfNewer(update);
};
socket.onclose = () => {
const delay = Math.min(30000, 500 * (2 ** retry++));
setTimeout(connect, delay);
};
}
connect();Questions teams ask
Should I store fleet positions as geometry or geography in PostGIS?
Use geography when your common distance questions are naturally expressed in meters over the earth’s surface. Use geometry when you need a projected coordinate system or geometry-specific processing. Whichever type you choose, keep the coordinate reference system consistent and document the units. PostGIS states that geometry distance uses the units of the spatial reference system, while geography distance uses meters. (postgis.net)
Is WebSocket delivery enough for a reliable fleet dashboard?
No. WebSockets provide a delivery channel, not durable state or guaranteed replay by themselves. Keep the database or durable event system authoritative, send a fresh snapshot after connection or reconnection, and use message versions or timestamps to handle duplicates and out-of-order updates.
When should I stop using one GeoJSON source?
Move beyond a single frequently replaced GeoJSON collection when browser update cost, payload size, clustering, or memory use becomes a measurable problem. Use viewport filtering, server-side aggregation, clustering, vector tiles, or a hybrid approach. The appropriate threshold depends on feature count, update frequency, device performance, and the amount of property data attached to each feature; there is no universal fleet-size cutoff.
Does a nearby vehicle mean it can reach a facility quickly?
No. ST_DWithin answers a geographic distance question, usually a radius relationship. It does not account for road access, turn restrictions, traffic, vehicle constraints, or travel time. Use a routing or network-analysis service when the operational question is reachable travel distance or estimated arrival time.
How should stale vehicles appear?
Keep the last known position if it is operationally useful, but expose the age of the observation and use a clearly different visual state. A stale marker should not be indistinguishable from a recently reported vehicle. Define the thresholds with operations and document whether they are based on observed_at, received_at, or both.
Keep exploring
build a routing service
A fleet dashboard often needs to distinguish straight-line proximity from road-network distance, travel time, and route reachability.
ingest gtfs realtime
This guide is relevant when fleet or transit operations consume standardized real-time vehicle and service updates.
add vector tiles to a react app production guide
A production dashboard may need vector-tile delivery when live fleet context and supporting geographic layers exceed a practical GeoJSON payload size.
Sources
PostGIS ST_DWithin documentation
Defines distance filtering for geometry and geography, including units, indexing behavior, and examples relevant to proximity queries.
Open sourcePostGIS guidance for radius queries
Explains why ST_DWithin is preferred for indexed radius filtering over ST_Distance or buffered geometry in common query patterns.
Open sourcePostGIS ST_Within documentation
Documents containment semantics and the requirement that geometries use the same coordinate projection.
Open sourceMapLibre GL JS GeoJSONSource API
Documents GeoJSON sources, source updates, and clustering-related methods used by the browser map.
Open sourceMapLibre GL JS introduction and API
Provides the official setup and rendering model for interactive WebGL maps, styles, sources, and layers.
Open source