What you’re seeing is normal behavior for phone GPS, especially:
In a small area (like a sculpture garden),
With offline positioning (no Wi-Fi / network assistance),
And using high-frequency updates with BestForNavigation.
A few key points about GPS on mobile devices:
Typical real-world horizontal accuracy is 3–10 meters under good outdoor conditions. Near buildings, trees, or indoors, it easily gets worse.
Even if the user is completely still, the reported latitude/longitude will wander inside an error circle defined by coords.accuracy.
That means in a small map (say 30–50 m across), the natural noise of GPS can be a large percentage of the whole area. So the marker looks like it’s “jumping all over” even though it’s just wandering within the uncertainty bubble.
So there is nothing “wrong” with Expo Location itself. You’re just seeing the raw limitations of consumer GPS very clearly because your map is small and your update settings are aggressive.
You’re doing some reasonable things already (UTM transform, simple smoothing), but a few details make the jitter very visible instead of hiding it.
subscription = await Location.watchPositionAsync(
{
accuracy: Location.Accuracy.BestForNavigation,
timeInterval: 500,
distanceInterval: 0.5,
},
(newLocation) => {
setLocation(newLocation);
}
);
Problems here:
BestForNavigation is designed to be responsive, not perfectly stable.
timeInterval: 500 ms and distanceInterval: 0.5 m means you’re asking for very frequent, very fine-grained updates.
At that resolution, you see every tiny fluctuation in the GPS solution as a “movement”.
You’re essentially subscribing to noise in high definition.
const acc = location.coords.accuracy;
if (acc && acc > 8) return;
You’re discarding any reading where the accuracy is worse than 8 m. In many real environments, values below 8 m are rare. So what happens?
You ignore a lot of readings.
You occasionally accept “good” ones that might still be off by several meters.
That makes your smoothed position jump from one “good” estimate to another instead of drifting slowly.
setSmoothedUtm(prev => {
if (!prev) return { x: rawUtm.x, y: rawUtm.y };
const dx = rawUtm.x - prev.x;
const dy = rawUtm.y - prev.y;
const movement = Math.sqrt(dx * dx + dy * dy);
if (movement < 0.3) return prev;
const alpha = 0.8;
return {
x: prev.x * (1 - alpha) + rawUtm.x * alpha,
y: prev.y * (1 - alpha) + rawUtm.y * alpha
};
});
Two main issues:
A dead-zone of 0.3 m is meaningless when GPS noise is 3–10 m. Almost every jitter is bigger than 0.3 m, so almost everything is treated as “real movement”.
alpha = 0.8 heavily favors the new (noisy) value:
new = 20% old + 80% new → that’s closer to “forward the noise” than “smooth it”.
So the filter doesn’t do much to hide the GPS wandering.
You cannot turn GPS into a centimeter-accurate indoor tracker with code. But you can:
Reduce how much jitter the user sees.
Make your routing logic robust to noise.
Align UX with the actual accuracy you have (meters, not centimeters).
Below are practical changes that work within the limitations.
Relax watchPositionAsync so you don’t hammer the GPS and you don’t get spammed with tiny fluctuations.
For example:
subscription = await Location.watchPositionAsync(
{
accuracy: Location.Accuracy.High, // or Location.Accuracy.Balanced
timeInterval: 2000, // at most once every 2 seconds
distanceInterval: 2, // only if moved ~2 meters
},
(newLocation) => {
setLocation(newLocation);
}
);
This does two things:
Reduces CPU/battery.
Prevents your UI from reacting to every sub-meter wobble.
In many cases, High or Balanced gives more stable behavior than BestForNavigation for walking around at low speed.
Next, make the smoothing logic match real GPS behavior:
Accept readings up to ~15–20 m accuracy (beyond that, ignore).
Ignore position changes smaller than 2–3 m as noise.
Use a small alpha (0.1–0.3) so the filtered position moves slowly.
Example:
useEffect(() => {
if (!rawUtm || !location) return;
const acc = location.coords.accuracy ?? 999;
// Ignore very noisy readings
if (acc > 20) return;
setSmoothedUtm(prev => {
if (!prev) {
return { x: rawUtm.x, y: rawUtm.y };
}
const dx = rawUtm.x - prev.x;
const dy = rawUtm.y - prev.y;
const movement = Math.sqrt(dx * dx + dy * dy);
// Ignore small movements that are likely just jitter
if (movement < 2) {
return prev;
}
// Strong smoothing: mostly keep old position
const alpha = 0.2; // 20% new, 80% old
return {
x: prev.x * (1 - alpha) + rawUtm.x * alpha,
y: prev.y * (1 - alpha) + rawUtm.y * alpha,
};
});
}, [rawUtm, location]);
Now:
The marker will not “twitch” for tiny fluctuations.
Larger moves (user actually walking) will slowly pull the marker toward the new position.
You still respect accuracy constraints, but you’re not unrealistically strict.
You currently trigger arrival when:
if (distanceToDestination !== null && distanceToDestination < 3) {
Alert.alert('Arrived!', ...)
}
If your GPS accuracy is ±5–10 m, checking for < 3 m is optimistic. The position might easily jump between 2 m and 8 m from the sculpture while the user stands in the same place.
Use a larger radius (e.g. 7–10 m) and maybe require the distance to stay under that threshold for a couple of consecutive readings:
const ARRIVAL_RADIUS = 8; // meters
useEffect(() => {
if (distanceToDestination == null || selectedDestination == null) return;
if (distanceToDestination < ARRIVAL_RADIUS) {
Alert.alert('Arrived!', `You reached ${TEST_POSITIONS[selectedDestination].label}`);
setSelectedDestination(null);
}
}, [distanceToDestination, selectedDestination]);
It’s better UX to say “you’re here” a bit early than never say it because of GPS jitter.
You’re mapping UTM → pixels based on sculpture coordinates that are very close together. When your entire site is, say, 30 m wide and your screen is 300 px wide:
1 meter ≈ 10 pixels.
Normal GPS noise of 5–10 m → 50–100 pixels of jump.
That’s why the dot looks like it’s flying around.
There is no way around this from GPS alone. What you can do:
Visually de-emphasize the exact dot and focus on a “you’re roughly here” indicator (e.g. circle radius = accuracy).
Optionally snap the user’s position to the nearest path or nearest sculpture when they’re within a certain distance. That’s a UX trick: you’re acknowledging that the exact GPS coordinate is fuzzy and instead projecting them to a discrete point that makes sense for your map.
For a small offline map, if you need sub-meter or 2–3 m reliable precision, you won’t get it on regular phones with only GPS, especially offline and near structures.
Real alternatives (if this were a production problem):
BLE beacons / UWB anchors indoors.
QR / NFC markers near sculptures that the user scans to “locate” themselves.
Letting the user manually mark their approximate location on the map as a reset.
That’s beyond Expo Location and outside pure code solutions.
Your device isn’t “broken”; you’re just seeing normal GPS error at a very small scale.
The combo of BestForNavigation, high-frequency updates, very strict accuracy filter, and weak smoothing makes the jitter extremely visible.
Use:
Less aggressive watchPositionAsync settings,
Realistic accuracy + movement thresholds,
Stronger smoothing,
A larger arrival radius, and
UI that communicates “approximate location”, not exact centimeters.
You can’t make GPS behave like a laser pointer, but you can make the experience look stable and usable for an offline sculpture map.