Designing an Emergency Panic System for Fleet Drivers: GPS, Confirmation UX, and Instant Dispatch
A panic button is the most important feature you hope nobody ever uses. But when they do, it has to work perfectly - accurate location, zero ambiguity, dispatch in seconds.
I thought building one would be straightforward. A button, a GPS reading, an API call. I said yes to the feature without really thinking through the edge cases.
What I actually had to build was a system that handles GPS permission denial with platform-specific recovery instructions, pre-fetches location during the confirmation step so the user never waits, uses dual-server failover with hard timeouts so a server going down during an emergency doesn't mean the alert doesn't send, and dispatches the panic even if GPS fails entirely - because a panic alert without coordinates is still infinitely more useful than no alert at all.
This is a feature where getting the engineering wrong has direct safety consequences. I thought about that a lot while building it.
Two client types, two completely different layouts
The app serves two client types with different emergency needs - and the difference is significant enough that they get entirely different home screens.
Regular fleet clients have a full suite of tools: real-time tracking, live video, snapshots, investigation, dashboard. The panic button sits in the grid alongside everything else. It's important, but it's one feature among many.
Safe-T clients are different. Their entire use case is emergency response - panic alerts for driver safety, vehicle lock for immobilisation, theft reporting. The home screen reflects that:
Regular Fleet Safe-T
+-----------------------------+ +-----------------------------+
| REAL TIME TRACKING | | REAL TIME TRACKING |
+--------------+--------------+ +--------------+--------------+
| LIVE VIDEO | PANIC | | PANIC | LOCK |
+--------------+--------------+ +-----------------------------+
| SNAPSHOTS | INVESTIGATE | | THEFT |
+--------------+--------------+ +-----------------------------+
| DASHBOARD |
+-----------------------------+
Safe-T clients get three emergency actions - Panic (driver emergency), Lock (immobilise the vehicle remotely), and Theft (vehicle reported stolen) - each triggering different backend responses. The layout is determined by the client type returned from the API at login. No configuration, no settings screen. You log in, the app knows what you are, it shows you what you need.
The confirmation flow - two steps, no more
My first instinct was to make the panic button fire immediately on tap. Fast, no friction, exactly what you want in an emergency.
Then I thought about accidental triggers. A driver reaching across the cab. A tablet dropped on a dashboard. A child in the cab picking up the phone. A false panic alert dispatches emergency response, wastes resources, and erodes trust in the system. If operators start ignoring alerts because they've seen too many false ones, the feature fails at its actual purpose.
But adding three confirmation screens to an emergency button is equally wrong - every second of friction is a second the driver isn't getting help.
I settled on two steps. One tap to open the confirmation modal, one tap to confirm. That's it.
async panic(arg: string) { const modal = await this.modalController.create({ component: ModalForConfirmDialogComponent, componentProps: { title: arg, // "PANIC", "LOCK", or "THEFT" }, }); return await modal.present(); }
The confirmation modal is full-screen with large, colour-coded buttons:
<ion-content> <div class="confirm-container"> <h2>{{ title }}</h2> <p class="confirm-message"> Are you sure you want to send a {{ title }} alert? </p> <div class="confirm-buttons"> <ion-button expand="block" color="danger" (click)="confirm()" [disabled]="isSending"> YES - Send Alert </ion-button> <ion-button expand="block" color="medium" (click)="dismiss()"> NO - Cancel </ion-button> </div> </div> </ion-content>
Full-width expand="block" buttons, red for confirm, grey for cancel. In a high-stress situation fine motor control is compromised - big targets matter. The isSending flag disables the confirm button immediately on tap to prevent double-submission. Network latency shouldn't cause duplicate emergency dispatches.
The confirmation step also serves a second purpose I hadn't planned for: it's where GPS acquisition happens.
GPS - starting before the user confirms
The moment the confirmation modal opens, GPS acquisition starts in the background. The user reads the confirmation dialog and decides whether to tap YES. That takes 2-5 seconds. That's also how long it takes to get a GPS fix. By the time they confirm, the location is ready.
I didn't plan this upfront. I built it the naive way first - GPS acquisition on confirm - and watched the modal sit on a loading spinner for 3 seconds after the user tapped YES before the dispatch fired. That's a long time to wait after pressing an emergency button. Moving acquisition to modal open hid the latency entirely.
The GeolocationService manages the full GPS lifecycle:
@Injectable({ providedIn: "root" }) export class GeolocationService { private _hasLocationPermission = new BehaviorSubject<boolean>(false); private _currentCoordinates = new BehaviorSubject<LocationCoordinates | null>(null); async ensureLocationForPanic(): Promise<LocationCoordinates | null> { if (!this.hasPermission()) { const granted = await this.requestLocationPermission(); if (!granted) return null; } return await this.updateCurrentLocation(); } }
ensureLocationForPanic() is what the modal calls on open. Check permission, request if needed, get position if granted. If the user has denied location permission, the OS blocks re-prompting. The service falls back to platform-specific recovery instructions:
private async showSettingsDialog(): Promise<void> { const isIOS = this.platform.is("ios"); const instructions = isIOS ? 'To enable location:\n\n' + '1. Go to Settings\n' + '2. Scroll down and tap this app\n' + '3. Tap Location\n' + '4. Select "While Using App" or "Always"\n' + '5. Return to the app and try again' : 'To enable location:\n\n' + '1. Go to Settings\n' + '2. Tap Apps or Application Manager\n' + '3. Find and tap this app\n' + '4. Tap Permissions\n' + '5. Turn on Location\n' + '6. Return to the app and try again'; const alert = await this.alertController.create({ header: "Location Permission Required", message: "Location access is required for panic alerts.", buttons: [ { text: "Cancel", role: "cancel" }, { text: "Instructions", handler: async () => { await this.showInstructionsDialog(instructions); }, }, { text: "Try Again", handler: async () => { await this.requestLocationPermission(); }, }, ], }); await alert.present(); }
"Please enable location access" doesn't help a truck driver on a rural route who isn't particularly tech-savvy. The numbered step-by-step instructions for their specific OS do. iOS and Android have completely different settings navigation paths and a generic message leaves the user stranded at exactly the wrong moment.
The most important decision in the whole GPS system: the panic dispatches even if GPS fails. ensureLocationForPanic() returns null if it can't get a position, and the dispatch continues without coordinates. A panic alert with no location is still an alert - operations know a driver is in distress, they can correlate it with the vehicle's last known position from the tracking system, they can call the driver. No coordinates is better than no alert.
Dispatch - hard timeouts and no infinite spinners
The panic service is the only place in the entire app with explicit timeout() operators. Every other service uses the default HTTP timeout. This one doesn't.
sendPanic(body: any) { body.uid = this.authService.fetchUID(); const headers = { "Content-Type": "application/json" }; return this.http .post(this.primaryUrl + "panic/sendPanic", body, { headers }) .pipe( timeout(15000), catchError((error) => { return this.http .post(this.fallbackUrl + "panic/sendPanic", body, { headers }) .pipe( timeout(15000), catchError((fallbackError) => { return throwError( () => new Error( "Both servers are unavailable. Please try again later." ) ); }) ); }) ); }
15 seconds per server. Worst case the dispatch takes 30 seconds. For an emergency that's the outer bound of acceptable - and it's bounded, which is the key thing. The driver sees a result: success, fallback trying, or failure with a retry button. They never see a spinner that runs forever while they're in a dangerous situation wondering if anything is happening.
The backend BFF forwards the payload to the upstream dispatch system:
async function sendPanic(body) { const response = await axios.post( helpers.returnUrl() + '/api/post-panic?uid=' + body.uid, body, { headers: { "Authorization": helpers.returnAuth() } } ); return response.data; }
Vehicle context - don't block on it
For multi-vehicle fleets the panic can optionally include which vehicle the alert is about. Safe-T clients are typically single-vehicle operators so they skip vehicle selection entirely - the alert fires immediately. Regular fleet clients can select a vehicle, but the system doesn't wait for it:
const payload = { uid: this.authService.fetchUID(), clientId: this.clientId, type: this.title, // PANIC, LOCK, or THEFT latitude: this.latitude, longitude: this.longitude, timestamp: new Date().toISOString(), deviceNr: this.selectedVehicle?.device_nr, };
selectedVehicle?.device_nr - optional chaining. If no vehicle is selected, the field is undefined and the dispatch still goes. Every field in this payload is best-effort. The mandatory fields are the user ID, the alert type and the timestamp. Everything else is context that helps the response but doesn't gate it.
What building a safety feature taught me
Every design decision in this system came back to the same question: what happens when something goes wrong? GPS denied. Server down. Driver in a panic. Each of those scenarios has to end with an alert being sent, not with the user staring at an error screen.
The pre-fetch during confirmation wasn't in my original design. The graceful GPS degradation wasn't obvious upfront. The hard timeouts came from imagining a driver pressing confirm and watching a spinner run while something was wrong with the primary server. Building each of these required thinking through the failure path first and then working backwards to the implementation.
Emergency UX is about removing decisions and hiding latency. Every second of cognitive load is a second the driver isn't getting help. Every spinner that runs indefinitely is a second of uncertainty during the worst moment. The best possible outcome is a driver taps the button, confirms, and the alert is gone before they've processed what they just did. That's what I was building towards.
This article is part of a series on building a fleet telematics platform.