Skip to content

flush() resolves before network send + queue splice complete — events re-sent on every subsequent app launch (missing await in processQueue) #443

Description

@Raaz45

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.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions