When we started Fleeticc, the first product question was not which map library to pick. It was how a dispatcher sees a vehicle move without refreshing the page. GPS hardware already pushes coordinates every few seconds. The hard part is getting those updates to the web dashboard and mobile apps reliably, at scale, without burning server resources on empty polling loops.
We evaluated three patterns: HTTP polling, WebSockets, and Server-Sent Events (SSE). Polling was ruled out on day one. WebSockets looked attractive until we mapped them onto our AWS load-balancer setup and our mostly one-way data flow. SSE won, and this post explains how we wired it through NestJS, Redis, and the Fleeticc ingest layer on AWS.
The problem with polling
The naive approach, hitting a REST endpoint every five seconds and repainting markers, fails in production for three reasons. Latency is bounded below by the poll interval, so a vehicle turning a corner appears to teleport. Cost scales with connected users times poll frequency, even when nothing moved. Under load, polling creates thundering herds that compete with the same APIs handling trip history, reports, and geofence checks.
- 5-second polling gives an average of 2.5 seconds of stale positions on the map
- 50 dispatchers at 12 requests per minute equals 600 requests per minute just to detect zero changes
- Battery drain on mobile from background timers waking the radio repeatedly
- No back-pressure: clients keep hammering even when the fleet is parked overnight
Why we did not choose WebSockets
WebSockets are the default answer in many real-time tutorials. For Fleeticc they were the wrong tool. Our dashboard traffic is overwhelmingly server to client: a new latitude, a geofence alert, a device status flag. Clients rarely push coordinates back over the same channel because devices talk to the Go ingest service directly.
- Bidirectional channels we did not need, adding protocol complexity for no gain
- Sticky sessions or custom connection routing behind AWS ALB, harder ops than plain HTTP
- Reconnect and heartbeat logic duplicated across web (EventSource) and mobile (would need a WS client)
- Binary framing and custom message schemas; SSE sends plain text events the browser parses natively
WebSockets shine when both sides send high-frequency messages: chat, collaborative editing, multiplayer games. Fleet tracking is a broadcast problem. SSE is HTTP, works through standard proxies, and the browser reconnects automatically with Last-Event-ID when a tab sleeps.
SSE vs polling vs WebSocket at a glance
- Polling: simple to build, unacceptable latency, worst cost profile at scale
- WebSocket: lowest latency for bidirectional apps; overkill and harder to operate for read-heavy maps
- SSE: one-way push over HTTP/2, native browser support, auto-reconnect, fits NestJS Observables cleanly
Architecture on AWS
GPS devices send TCP/UDP payloads to a Go ingest service on AWS ECS. Each fix is validated, deduplicated, written to TimescaleDB for history, and published to Redis Pub/Sub keyed by fleet ID. NestJS API tasks subscribe to those channels and fan out to open SSE connections. The Next.js dashboard opens an EventSource per fleet view. Flutter uses the same endpoint with a lightweight SSE client.
- AWS ECS (Fargate): Go ingest and NestJS API, autoscaling on CPU and connection count
- Amazon RDS (PostgreSQL + TimescaleDB): trip history, geofences, fleet metadata
- Amazon ElastiCache (Redis): pub/sub bus between ingest and SSE workers, plus session cache
- Application Load Balancer: idle timeout tuned for long-lived SSE connections
- Amazon S3 and CloudFront: static assets, report exports, marketing site media
- Amazon SES and Firebase FCM: email alerts and mobile push for events SSE does not need to carry
NestJS SSE endpoint (pattern)
The NestJS layer exposes one SSE route per fleet. Internally it returns an RxJS Observable that emits MessageEvent objects whenever Redis delivers a position patch. Auth runs before the stream opens, so anonymous clients never hold a connection.
@Sse('fleets/:fleetId/live')
@UseGuards(FleetAccessGuard)
livePositions(
@Param('fleetId') fleetId: string,
@Req() req: AuthenticatedRequest,
): Observable<MessageEvent> {
return this.fleetStreamService.stream(fleetId, req.user.id);
}
// fleet-stream.service.ts: Redis message to SSE event
stream(fleetId: string, userId: string): Observable<MessageEvent> {
return new Observable((subscriber) => {
const channel = `fleet:${fleetId}:positions`;
const onMessage = (_ch: string, payload: string) => {
subscriber.next({ data: payload, type: 'position' });
};
this.redis.subscribe(channel, onMessage);
return () => this.redis.unsubscribe(channel, onMessage);
});
}Next.js dashboard client
The Fleeticc web dashboard uses the browser EventSource API. On each message we merge the patch into the map layer with no full page reload and no polling timer. When the tab goes to the background, the connection drops gracefully. On focus, EventSource reconnects and we fetch a snapshot REST call to fill any gap.
useEffect(() => {
const source = new EventSource(`/api/fleets/${fleetId}/live`, {
withCredentials: true,
});
source.addEventListener('position', (event) => {
const update = JSON.parse(event.data) as VehiclePosition;
mergeVehicleOnMap(update);
});
source.onerror = () => {
setConnectionState('reconnecting');
};
return () => source.close();
}, [fleetId]);What shipped on the real-time layer
- Live fleet map on web and mobile from the same SSE stream and Redis bus
- Sub-second position latency without polling loops
- AWS infrastructure sized for ingest spikes and long-lived dashboard tabs
- Geofence and alert events pushed over the same SSE channel with typed event names
Fleeticc is live at fleeticc.com with iOS and Android apps on the same backend. For IoT and fleet products, match the transport to the data direction and put the cloud layer behind a pub/sub fan-out. That is what keeps the map honest when vehicles are actually moving.



