Building a ride-hailing or delivery app? One of the toughest challenges is implementing smooth, real-time rider tracking without the dreaded "flickering marker" problem. This comprehensive guide shows you how to create buttery-smooth marker animations across React Native, Flutter, iOS, and Android.
<a name="why-flicker"></a>
Most developers start with an approach like this:
// ❌ WRONG: Polling that causes flickering
setInterval(async () => {
const riders = await fetchNearbyRiders();
setMarkers(riders); // Recreates ALL markers
}, 5000);
Problems:
<a name="solution"></a>
Strategy: Initial Load → Real-Time Updates → Smooth Animations
LocationUpdated events for individual changesThis approach eliminates flickering and creates smooth, professional marker movement.
<a name="react-native"></a>
npm install laravel-echo pusher-js react-native-maps
// RiderMap.js
import React, { useEffect, useRef, useState } from "react";
import MapView, { Marker, PROVIDER_GOOGLE } from "react-native-maps";
import Echo from "laravel-echo";
import Pusher from "pusher-js";
// Configure Echo
const echo = new Echo({
broadcaster: "pusher",
key: process.env.REVERB_APP_KEY,
wsHost: process.env.REVERB_HOST,
wsPort: 8080,
wssPort: 443,
forceTLS: false,
enabledTransports: ["ws", "wss"],
authEndpoint: "https://your-api.com/broadcasting/auth",
auth: {
headers: {
Authorization: `Bearer ${token}`,
},
},
});
const RiderMap = ({ initialLocation, token }) => {
const [riders, setRiders] = useState({});
const markersRef = useRef({});
// STEP 1: Load initial riders once
useEffect(() => {
fetchInitialRiders();
}, []);
const fetchInitialRiders = async () => {
const response = await fetch("/api/riders/nearby", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
latitude: initialLocation.latitude,
longitude: initialLocation.longitude,
radius: 1,
}),
});
const { data } = await response.json();
const ridersMap = {};
data.riders.forEach((rider) => {
ridersMap[rider.id] = {
latitude: parseFloat(rider.latitude),
longitude: parseFloat(rider.longitude),
...rider,
};
});
setRiders(ridersMap);
};
// STEP 2: Listen to real-time updates
useEffect(() => {
const channel = echo.channel("rider-locations");
channel.listen(".location.updated", (event) => {
const { rider_id, location } = event;
updateSingleRider(rider_id, {
latitude: parseFloat(location.latitude),
longitude: parseFloat(location.longitude),
...location,
});
});
return () => echo.leave("rider-locations");
}, []);
// STEP 3: Update only the specific rider
const updateSingleRider = (riderId, newLocation) => {
setRiders((prev) => {
const existing = prev[riderId];
if (!existing) {
return { ...prev, [riderId]: newLocation };
}
// Animate the marker smoothly
const marker = markersRef.current[riderId];
if (marker) {
marker.animateMarkerToCoordinate(
{
latitude: newLocation.latitude,
longitude: newLocation.longitude,
},
1000 // 1 second animation
);
}
return {
...prev,
[riderId]: { ...existing, ...newLocation },
};
});
};
return (
<MapView
provider={PROVIDER_GOOGLE}
style={{ flex: 1 }}
initialRegion={{
...initialLocation,
latitudeDelta: 0.01,
longitudeDelta: 0.01,
}}
>
{Object.entries(riders).map(([id, rider]) => (
<Marker
key={id}
ref={(ref) => {
if (ref) markersRef.current[id] = ref;
}}
coordinate={{
latitude: rider.latitude,
longitude: rider.longitude,
}}
title={rider.name}
/>
))}
</MapView>
);
};
export default RiderMap;
<a name="flutter"></a>
# pubspec.yaml
dependencies:
google_maps_flutter: ^2.2.0
pusher_channels_flutter: ^2.0.0
http: ^1.1.0
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:pusher_channels_flutter/pusher_channels_flutter.dart';
class RiderMap extends StatefulWidget {
final LatLng initialLocation;
final String token;
const RiderMap({required this.initialLocation, required this.token});
@override
State<RiderMap> createState() => _RiderMapState();
}
class _RiderMapState extends State<RiderMap> {
GoogleMapController? _controller;
final Map<String, Marker> _markers = {};
final Map<String, Timer> _animations = {};
PusherChannelsFlutter pusher = PusherChannelsFlutter.getInstance();
@override
void initState() {
super.initState();
_initPusher();
_loadRiders();
}
// STEP 1: Initialize WebSocket connection
Future<void> _initPusher() async {
await pusher.init(
apiKey: 'YOUR_KEY',
cluster: '',
authEndpoint: 'https://your-api.com/broadcasting/auth',
onEvent: _handleLocationUpdate,
);
await pusher.subscribe(channelName: 'rider-locations');
await pusher.connect();
}
// STEP 2: Load initial riders
Future<void> _loadRiders() async {
final response = await http.post(
Uri.parse('https://your-api.com/api/riders/nearby'),
headers: {'Authorization': 'Bearer ${widget.token}'},
body: jsonEncode({
'latitude': widget.initialLocation.latitude,
'longitude': widget.initialLocation.longitude,
}),
);
final data = jsonDecode(response.body);
setState(() {
for (var rider in data['riders']) {
_markers[rider['id']] = Marker(
markerId: MarkerId(rider['id']),
position: LatLng(
double.parse(rider['latitude']),
double.parse(rider['longitude']),
),
infoWindow: InfoWindow(title: rider['name']),
);
}
});
}
// STEP 3: Handle real-time updates
void _handleLocationUpdate(PusherEvent event) {
if (event.eventName != '.location.updated') return;
final data = jsonDecode(event.data);
final riderId = data['rider_id'];
final location = data['location'];
final newPos = LatLng(
double.parse(location['latitude']),
double.parse(location['longitude']),
);
_animateMarker(riderId, newPos);
}
// STEP 4: Animate marker smoothly
void _animateMarker(String riderId, LatLng target) {
_animations[riderId]?.cancel();
final current = _markers[riderId]?.position;
if (current == null) return;
const duration = Duration(seconds: 1);
const steps = 30;
final stepDuration = Duration(milliseconds: 1000 ~/ steps);
int step = 0;
_animations[riderId] = Timer.periodic(stepDuration, (timer) {
step++;
final progress = step / steps;
final eased = 1 - pow(1 - progress, 3); // Ease-out cubic
final lat = current.latitude +
(target.latitude - current.latitude) * eased;
final lng = current.longitude +
(target.longitude - current.longitude) * eased;
setState(() {
_markers[riderId] = _markers[riderId]!.copyWith(
positionParam: LatLng(lat, lng),
);
});
if (step >= steps) {
timer.cancel();
_animations.remove(riderId);
}
});
}
@override
Widget build(BuildContext context) {
return GoogleMap(
initialCameraPosition: CameraPosition(
target: widget.initialLocation,
zoom: 14,
),
onMapCreated: (controller) => _controller = controller,
markers: Set.from(_markers.values),
);
}
@override
void dispose() {
_animations.forEach((_, timer) => timer.cancel());
pusher.disconnect();
super.dispose();
}
}
<a name="ios"></a>
# Podfile
pod 'PusherSwift'
import UIKit
import MapKit
import PusherSwift
class RiderMapViewController: UIViewController {
@IBOutlet weak var mapView: MKMapView!
private var riders: [String: RiderAnnotation] = [:]
private var pusher: Pusher!
private var animationTimers: [String: Timer] = [:]
override func viewDidLoad() {
super.viewDidLoad()
setupPusher()
loadInitialRiders()
}
// STEP 1: Setup WebSocket
func setupPusher() {
let options = PusherClientOptions(
authMethod: .endpoint(
authEndpoint: "https://your-api.com/broadcasting/auth"
)
)
pusher = Pusher(key: "YOUR_KEY", options: options)
let channel = pusher.subscribe("rider-locations")
channel.bind(eventName: ".location.updated") { [weak self] event in
self?.handleLocationUpdate(event)
}
pusher.connect()
}
// STEP 2: Load initial riders
func loadInitialRiders() {
// Make API call and add annotations
// ... (API call code)
}
// STEP 3: Handle location update
func handleLocationUpdate(_ event: PusherEvent) {
guard let data = event.data?.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let riderId = json["rider_id"] as? String,
let location = json["location"] as? [String: Any],
let lat = Double(location["latitude"] as? String ?? ""),
let lng = Double(location["longitude"] as? String ?? "") else {
return
}
let newCoord = CLLocationCoordinate2D(latitude: lat, longitude: lng)
animateMarker(riderId: riderId, to: newCoord)
}
// STEP 4: Animate marker
func animateMarker(riderId: String, to target: CLLocationCoordinate2D) {
animationTimers[riderId]?.invalidate()
guard let annotation = riders[riderId] else { return }
let from = annotation.coordinate
let duration: TimeInterval = 1.0
let steps = 30
var step = 0
let timer = Timer.scheduledTimer(withTimeInterval: duration / Double(steps), repeats: true) { [weak self] timer in
step += 1
let progress = Double(step) / Double(steps)
let eased = 1 - pow(1 - progress, 3)
let lat = from.latitude + (target.latitude - from.latitude) * eased
let lng = from.longitude + (target.longitude - from.longitude) * eased
annotation.coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lng)
if step >= steps {
timer.invalidate()
self?.animationTimers.removeValue(forKey: riderId)
}
}
animationTimers[riderId] = timer
}
}
class RiderAnnotation: NSObject, MKAnnotation {
@objc dynamic var coordinate: CLLocationCoordinate2D
var title: String?
let riderId: String
init(riderId: String, coordinate: CLLocationCoordinate2D, title: String?) {
self.riderId = riderId
self.coordinate = coordinate
self.title = title
}
}
<a name="android"></a>
// build.gradle
dependencies {
implementation("com.pusher:pusher-java-client:2.4.0")
implementation("com.google.android.gms:play-services-maps:18.2.0")
}
import com.google.android.gms.maps.*
import com.google.android.gms.maps.model.*
import com.pusher.client.Pusher
import com.pusher.client.PusherOptions
import org.json.JSONObject
import android.os.Handler
import android.os.Looper
class RiderMapActivity : AppCompatActivity(), OnMapReadyCallback {
private lateinit var map: GoogleMap
private val markers = mutableMapOf<String, Marker>()
private val animations = mutableMapOf<String, Handler>()
private lateinit var pusher: Pusher
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_map)
setupMap()
setupPusher()
loadInitialRiders()
}
private fun setupMap() {
val mapFragment = supportFragmentManager
.findFragmentById(R.id.map) as SupportMapFragment
mapFragment.getMapAsync(this)
}
// STEP 1: Setup WebSocket
private fun setupPusher() {
val options = PusherOptions().apply {
setHost("your-host")
setWsPort(8080)
}
pusher = Pusher("YOUR_KEY", options)
val channel = pusher.subscribe("rider-locations")
channel.bind(".location.updated") { event ->
handleLocationUpdate(event.data)
}
pusher.connect()
}
// STEP 2: Load initial riders
private fun loadInitialRiders() {
// Make API call and add markers
// ... (API call code)
}
// STEP 3: Handle location update
private fun handleLocationUpdate(data: String) {
val json = JSONObject(data)
val riderId = json.getString("rider_id")
val location = json.getJSONObject("location")
val newPos = LatLng(
location.getString("latitude").toDouble(),
location.getString("longitude").toDouble()
)
runOnUiThread {
animateMarker(riderId, newPos)
}
}
// STEP 4: Animate marker
private fun animateMarker(riderId: String, target: LatLng) {
val marker = markers[riderId] ?: return
animations[riderId]?.removeCallbacksAndMessages(null)
val start = marker.position
val duration = 1000L
val steps = 30
var step = 0
val handler = Handler(Looper.getMainLooper())
val runnable = object : Runnable {
override fun run() {
step++
val progress = step.toFloat() / steps
val eased = 1 - Math.pow((1 - progress).toDouble(), 3.0).toFloat()
val lat = start.latitude + (target.latitude - start.latitude) * eased
val lng = start.longitude + (target.longitude - start.longitude) * eased
marker.position = LatLng(lat, lng)
if (step < steps) {
handler.postDelayed(this, duration / steps)
} else {
animations.remove(riderId)
}
}
}
animations[riderId] = handler
handler.post(runnable)
}
override fun onMapReady(googleMap: GoogleMap) {
map = googleMap
}
override fun onDestroy() {
super.onDestroy()
pusher.disconnect()
}
}
<a name="best-practices"></a>
// Ease-out cubic (smooth deceleration)
const easeOutCubic = (t) => 1 - Math.pow(1 - t, 3);
// Ease-in-out (smooth start and end)
const easeInOutCubic = (t) =>
t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
const MIN_DISTANCE = 0.0001; // ~11 meters
if (calculateDistance(old, new) < MIN_DISTANCE) {
return; // Ignore tiny movements
}
pusher.connection.bind('disconnected', () => {
// Fall back to polling
startPollingFallback();
});
pusher.connection.bind('connected', () => {
stopPollingFallback();
});
// Always clean up
useEffect(() => {
return () => {
// Cancel animations
Object.values(animationRefs.current).forEach(cancel);
// Leave channels
echo.leave('rider-locations');
};
}, []);
<a name="troubleshooting"></a>
Cause: You're still recreating markers instead of updating positions.
Fix: Store marker references and update their coordinates directly:
// ❌ Wrong
setMarkers([...newMarkers]); // Recreates all
// ✅ Right
marker.animateMarkerToCoordinate(newPosition);
Cause: Too few animation steps or blocking the main thread.
Fix: Use 30-60 steps and requestAnimationFrame:
const animate = () => {
// Update position
if (progress < 1) {
requestAnimationFrame(animate);
}
};
Cause: Mobile networks are unstable.
Fix: Implement reconnection logic with exponential backoff:
let retryCount = 0;
pusher.connection.bind('disconnected', () => {
const delay = Math.min(1000 * Math.pow(2, retryCount), 30000);
setTimeout(() => pusher.connect(), delay);
retryCount++;
});
Cause: Too many location updates or animations.
Fix:
Implementing smooth, flicker-free rider movement requires three key ingredients:
This approach works across all platforms and creates the polished experience users expect from modern ride-hailing apps.
With this implementation, your rider markers will glide smoothly across the map, creating a professional and delightful user experience.
Your email address will not be published. Required fields are marked *