Designing a Persisted Tracking Session State Machine
If you’ve built background location tracking in React Native, you’ve probably learned the hard way that your location SDK’s own enabled flag is not a reliable source of truth for "is this user currently supposed to be tracked." SDK state lives in memory (or native process state) and resets, glitches, or drifts from reality every time the app is killed, restarted, or recovered by a watchdog.
Get Jashwant Rana’s stories in your inbox
Join Medium for free to get updates from this writer.
What you actually need is a small, explicit, persisted state machine that represents your business intent — separate from whatever the SDK thinks is happening.
Why SDK state isn’t enough
Consider what “tracking” really means in a field-service or delivery app:
- A user starts a shift → tracking should be active.
- A user is assigned a visit → tracking should stay active, and now there’s an associated
visitId. - The app gets killed by the OS mid-shift → tracking should still be considered active once recovered, even though the SDK’s in-memory
enabledbriefly becamefalse. - A user ends their shift → tracking should stop, and the session should be cleared entirely.
None of that “should be” logic is something the location SDK knows about. It only knows “is the native tracker currently running.” Your app needs its own persisted concept of intent, so a watchdog or recovery routine can ask “should this be running?” independently of “is this running?”
Defining the session shape
export type TrackingSession = {
shiftActive?: boolean;
shiftPending?: boolean;
visitId?: string | null;
startedAt?: string;
};Four fields, each carrying distinct meaning:
shiftActive— the user has explicitly started a tracked shift.shiftPending— a shift is queued to start (e.g., waiting on a permission grant or battery-optimization dialog) but hasn't fully activated yet. This matters because a session that's "pending" should still be treated as needing tracking infrastructure (like the watchdog) primed and ready.visitId— the app can also track individual visits independent of a full shift; a non-null visit ID means tracking is warranted even ifshiftActiveis somehow false.startedAt— when this session began, useful for debugging drift ("this session has been active for 14 hours, that's suspicious") and for auditing.
Reading and writing the session
Persistence uses AsyncStorage, with normalization on write so a partial update never produces a malformed session:
export async function getTrackingSession(): Promise<TrackingSession | null> {
try {
const sessionStr = await AsyncStorage.getItem(localsConst.active_visit_tracking.key);
return parseTrackingSession(sessionStr);
} catch (error) {
console.error("[TrackingRuntime] Error reading tracking session:", error);
return null;
}
}export async function setTrackingSession(session: TrackingSession | null): Promise<void> {
try {
if (!session) {
await AsyncStorage.removeItem(localsConst.active_visit_tracking.key);
return;
} const normalized: TrackingSession = {
shiftActive: !!session.shiftActive,
shiftPending: !!session.shiftPending,
visitId: session.visitId ?? null,
startedAt: session.startedAt ?? new Date().toISOString(),
}; await AsyncStorage.setItem(
localsConst.active_visit_tracking.key,
JSON.stringify(normalized),
);
} catch (error) {
console.error("[TrackingRuntime] Error saving tracking session:", error);
}
}
Two defensive habits worth calling out:
!!session.shiftActive— coercesundefinedtofalseexplicitly, rather than storingundefinedin JSON (which silently disappears on serialize, making the field ambiguous on the next read).session.visitId ?? null— explicitnullrather thanundefined, so "no visit" is a distinguishable, storable value rather than an absent key.
Handling legacy / malformed data gracefully
Real apps evolve. Maybe an earlier version of your app stored just a raw visit ID string instead of a structured object. Your parser needs to tolerate that without throwing away the session or crashing:
const parseTrackingSession = (raw: string | null): TrackingSession | null => {
if (!raw) return null;try {
const parsed = JSON.parse(raw); if (typeof parsed === "string") {
return { visitId: parsed };
} if (parsed && typeof parsed === "object") {
return {
shiftActive: !!parsed.shiftActive,
shiftPending: !!parsed.shiftPending,
visitId: parsed.visitId ?? null,
startedAt: parsed.startedAt ?? parsed.started_at ?? undefined,
};
}
} catch {
return { visitId: raw };
} return null;
};
Notice the fallback in the catch block: if JSON.parse fails entirely (say, an old version stored a raw, non-JSON string), we still salvage it as a visit ID rather than treating the session as corrupted and losing the user's in-progress tracking state. Also note parsed.started_at ?? undefined — tolerating a snake_case key from an older backend contract without needing a full migration script.
Merging partial updates safely
Most callers don’t want to construct a full session object every time — they want to update one field (say, attach a visitId) without clobbering the rest:
export async function updateTrackingSession(
partial: Partial<TrackingSession>,
): Promise<TrackingSession | null> {
const current = await getTrackingSession();
const merged: TrackingSession = {
...(current || {}),
...partial,
startedAt: current?.startedAt ?? new Date().toISOString(),
}; await setTrackingSession(merged);
return merged;
}The key detail: startedAt is preserved from the existing session if one exists, and only set fresh if this is a brand-new session. This is what makes "how long has this shift been running" a trustworthy metric instead of resetting every time some unrelated field (like visitId) gets updated.
Deriving “should tracking be active” from the session
This is the payoff — a single, reusable predicate that both your UI and your watchdog can call, instead of each reimplementing “well, is shiftActive true, or is there a visitId, or…” with slightly different logic each time:
export const hasActiveTrackingSession = (session: TrackingSession | null): boolean =>
!!(session?.shiftActive || session?.shiftPending || session?.visitId);This one function is what a watchdog calls to decide whether it’s even worth checking if the SDK is enabled. It’s also what your UI calls to decide whether to show “Tracking Active” or “Not Tracking” — meaning your UI and your recovery logic can never disagree about the current state, because they’re both reading the same derived truth from the same persisted source.
Wiring session changes to side effects
Session updates aren’t just data writes — they should trigger the watchdog to engage or disengage:
const updateTrackingSession = useCallback(
async (partial: Partial<TrackingSession>): Promise<TrackingSession | null> => {
const session = await updateTrackingSessionStorage(partial);if (session?.shiftActive || session?.shiftPending || session?.visitId) {
await startTrackingWatchdog();
} return session;
},
[],
);
And symmetrically, clearing the session tears the watchdog down too:
const clearTrackingSession = useCallback(async (): Promise<void> => {
await clearTrackingSessionStorage();
await stopTrackingWatchdog();
}, []);The takeaway
Treat “is the user being tracked” as a first-class, persisted domain concept — not a read of whatever your location SDK’s in-memory state happens to say at that instant. A small, normalized, tolerant-of-legacy-data session object, combined with one canonical “should be active” predicate, gives your watchdog, your UI, and your recovery logic a single shared source of truth. That consistency is what actually prevents the class of bugs where the UI says “tracking on” while the SDK quietly isn’t, or vice versa.
Topics: React Native · State Management · Mobile Development · AsyncStorage · Software Architecture










