Environment
mixpanel-react-native: 3.5.0
Native iOS pod: Mixpanel-swift 6.5.0
Platform: iOS (not yet confirmed on Android)
Installed via CocoaPods (MixpanelReactNative 3.5.0 → Mixpanel-swift 6.5.0)
Bug
Events are being received multiple times (observed 5×, 6×, and 12× in production data) with the exact same $insert_id for a single logical event. Since $insert_id is generated once per track() call, identical $insert_ids across duplicates rule out the SDK being called multiple times from app code — this points to the same locally-queued event record being re-transmitted across multiple flush cycles.
Root cause
In javascript/mixpanel-core.js, processQueue (lines ~103-137):
const processQueue = async (token, type) => {
const processBatch = async () => {
const queue = MixpanelQueueManager.getQueue(token, type);
if (queue.length > 0) {
const batch = queue.slice(0, batchSize);
try {
await MixpanelNetwork.sendRequest({ token, data: batch, endpoint: type, ... });
await MixpanelQueueManager.spliceQueue(token, type, 0, batch.length);
// ...
} catch (error) {
handleBatchError(token, error, type, processBatch);
}
}
};
processBatch(); // <-- not awaited
};
processBatch is async and does the real work (network send, then spliceQueue to remove the sent events from the persisted queue), but processQueue calls it without await and returns immediately. flush() (lines ~90-101) does await processQueue(token, type) for each queue type, but since processQueue never awaits its own inner processBatch(), that outer await doesn't actually wait for the send+splice to finish — flush() resolves almost instantly, well before the request completes or the queue gets truncated.
The queue itself is persisted to AsyncStorage (mixpanel-persistent.js → AsyncStorageAdapter), which survives app termination and JS context reloads. So if the app is backgrounded/killed (or the JS context is otherwise torn down) any time after flush() returns but before the in-flight processBatch() promise chain resolves and its spliceQueue call persists, the already-sent (or in-flight) events are never removed from local storage. On the next app launch, MixpanelQueueManager.initialize() reloads the queue from AsyncStorage, finds those same un-spliced records still present, and the next flush() re-sends them — same object, same $insert_id, unchanged.
Impact
Raw event volume inflated (5-12× observed) in production.
Masked in Mixpanel's own reports by server-side $insert_id deduplication, but affects any raw data export or downstream system that doesn't dedupe on $insert_id.
No data-loss risk and no billing impact (since Mixpanel dedupes by $insert_id), but raw pipeline consumers get materially wrong counts.
Environment
mixpanel-react-native: 3.5.0
Native iOS pod: Mixpanel-swift 6.5.0
Platform: iOS (not yet confirmed on Android)
Installed via CocoaPods (MixpanelReactNative 3.5.0 → Mixpanel-swift 6.5.0)
Bug
Events are being received multiple times (observed 5×, 6×, and 12× in production data) with the exact same $insert_id for a single logical event. Since $insert_id is generated once per track() call, identical $insert_ids across duplicates rule out the SDK being called multiple times from app code — this points to the same locally-queued event record being re-transmitted across multiple flush cycles.
Root cause
In javascript/mixpanel-core.js, processQueue (lines ~103-137):
const processQueue = async (token, type) => {
const processBatch = async () => {
const queue = MixpanelQueueManager.getQueue(token, type);
if (queue.length > 0) {
const batch = queue.slice(0, batchSize);
try {
await MixpanelNetwork.sendRequest({ token, data: batch, endpoint: type, ... });
await MixpanelQueueManager.spliceQueue(token, type, 0, batch.length);
// ...
} catch (error) {
handleBatchError(token, error, type, processBatch);
}
}
};
processBatch(); // <-- not awaited
};
processBatch is async and does the real work (network send, then spliceQueue to remove the sent events from the persisted queue), but processQueue calls it without await and returns immediately. flush() (lines ~90-101) does await processQueue(token, type) for each queue type, but since processQueue never awaits its own inner processBatch(), that outer await doesn't actually wait for the send+splice to finish — flush() resolves almost instantly, well before the request completes or the queue gets truncated.
The queue itself is persisted to AsyncStorage (mixpanel-persistent.js → AsyncStorageAdapter), which survives app termination and JS context reloads. So if the app is backgrounded/killed (or the JS context is otherwise torn down) any time after flush() returns but before the in-flight processBatch() promise chain resolves and its spliceQueue call persists, the already-sent (or in-flight) events are never removed from local storage. On the next app launch, MixpanelQueueManager.initialize() reloads the queue from AsyncStorage, finds those same un-spliced records still present, and the next flush() re-sends them — same object, same $insert_id, unchanged.
Impact
Raw event volume inflated (5-12× observed) in production.
Masked in Mixpanel's own reports by server-side $insert_id deduplication, but affects any raw data export or downstream system that doesn't dedupe on $insert_id.
No data-loss risk and no billing impact (since Mixpanel dedupes by $insert_id), but raw pipeline consumers get materially wrong counts.