Skip to content
whovisited.

Conversion tracking · Debugging

Duplicate conversion events: safe retries versus a second event

Learn when WhoVisited deduplicates a retried event, why a second track call is different, and how to test conversion signals without counting an action twice.

Start by asking whether the event ID changed

You inspect a visitor journey and find two `demo_request_accepted` events. That can mean two different things. The tracker may have retried one event after a temporary network problem, or your application may have called `track()` twice and created two new events. The rows look similar in a report, but they have different identities and different fixes.

In the current WhoVisited tracker, each call to `window.whovisited.track(name, properties)` creates a fresh event ID with `crypto.randomUUID()`. When the tracker retries a queued batch after a network exception, an expired token, a rate limit, or a server error, it puts the original event objects back into the queue. Their IDs stay the same. The ingestion endpoint checks those IDs against events already stored for that website and skips matching rows.

Calling `track()` again creates a new random ID, even when the event name and properties are identical. The server cannot infer that two different IDs describe the same demo request. A retry can be recognized as a replay of one event; a second call is a second event unless your application prevents it.

What the ingestion endpoint deduplicates

The events endpoint verifies the ingestion token and event shape, checks the site and origin, then looks up submitted IDs in that workspace and website. IDs found in storage are omitted from the pending insert list. The response reports how many events were accepted and how many were duplicates. If an event with a particular ID was committed but its response never reached the browser, a later retry with that same ID can be recognized as an already stored event.

This protects against repeated delivery of the same event ID. It does not deduplicate by event name, session, page, form contents, or a business transaction ID. It is also not a promise of exactly-once delivery: browser collection depends on connectivity, consent, blockers, token validity, and the server's response. A fresh page load or application callback can generate a new event ID for the same real-world action.

Rate limits count attempted events in the submitted batch before duplicate IDs are removed. Monthly event usage, however, increases only for pending events that are new to storage. Replaying a batch can therefore still consume request-rate capacity while not adding another monthly event. Do not use tracker retries as a way to verify whether a customer operation succeeded.

Why a React integration may create a second ID

A conversion event should describe an accepted application outcome. If an Effect watches a `success` state and calls the tracker, React Strict Mode can expose a duplicate in development: when Strict Mode wraps the app at its root, React runs an extra Effect setup and cleanup cycle on mount. If Strict Mode wraps only a subtree, React does not repeat that subtree's initial mount Effects. An Effect can also run again when dependencies change or a route remounts. React documents Effects as automatic synchronization; they are not the same as a callback caused by one specific user action.

Use the application response that confirms success as the point where you decide to report the outcome. A click, pending state, or generic HTTP 2xx response may not mean the backend accepted the operation. Keep the server's idempotency and operation record responsible for preventing duplicate signups, bookings, or payments. Analytics should observe that result and must not become the mechanism that makes it happen once.

A development-only duplicate is a useful clue about an Effect, but it does not prove that production can never emit duplicates. A user can double-submit, a route can be revisited, or application code can handle the same accepted response more than once. Compare the event IDs and the source callback before attributing two rows to a transport retry.

Guard a repeated callback with an application operation key

If the server returns the same opaque operation key when an accepted request is replayed, the browser can use an in-memory set to prevent the same callback from creating a second analytics event during the current page lifetime. This key belongs to your application contract. It is not a WhoVisited event ID and should not be sent as an event property.

typescript
type Tracker = {
  track(name: string, properties?: Record<string, string>): void;
};

const attemptedOperations = new Set<string>();

function reportAcceptedDemo(
  operationKey: string,
  tracker?: Tracker,
): "tracker-not-ready" | "already-attempted" | "attempted" {
  if (!tracker) return "tracker-not-ready";
  if (attemptedOperations.has(operationKey)) return "already-attempted";

  attemptedOperations.add(operationKey);
  try {
    tracker.track("demo_request_accepted", { form: "demo" });
  } catch {
    // Analytics errors must not undo an accepted application action.
  }
  return "attempted";
}

const calls: string[] = [];
const fakeTracker: Tracker = { track: (name) => calls.push(name) };
console.log(reportAcceptedDemo("opaque-operation-17", fakeTracker));
console.log(reportAcceptedDemo("opaque-operation-17", fakeTracker));
console.log(calls.length); // 1

The synthetic example prints `attempted`, `already-attempted`, and `1`. In an app, call this only after checking the response contract that means the operation was accepted, and pass a stable opaque key for that operation. The key is kept in the page's memory and is not included in the event properties. A new page load or another tab has a new set, so the backend still needs its own idempotency rule.

The helper does not confirm analytics delivery. WhoVisited's `track()` method returns no delivery receipt, and a user may have denied consent or blocked the request. If the tracker is not ready yet, wait for your integration's readiness path and call the helper with the same accepted-operation key. Do not repeat a business operation because its analytics event is missing.

Separate event rows from converted sessions

WhoVisited's Growth goal calculation checks whether at least one retained event matching the selected goal exists in a website session. Two matching event rows in one session can still contribute one converted session for that goal. This session-level rule can keep a duplicate callback from doubling the converted-session count, while the visitor timeline may still contain two event records.

A qualifying session is not the same as a unique signup, booking, or payment. The same person can create sessions on different visits; a session can also contain an event without a completed server-side transaction if the browser code reports the wrong moment. Keep database totals as the source for business operations and use analytics to study the sessions associated with them.

Test retries and repeated calls separately

  1. Use a test site and synthetic action. Call the event once and inspect the accepted event in the visitor journey; keep Authorization headers and visitor identifiers out of screenshots and tickets.
  2. For an ingestion retry, replay the same synthetic event payload with its original event ID in a controlled test environment. After the first insert succeeds, the replay should be reported as a duplicate rather than adding a second stored row.
  3. Call `track()` twice for the same synthetic action. Each fresh call gets a new ID, so expect two event records; then test the application operation-key guard and confirm the repeated callback makes only one track call in that page lifetime.
  4. In a React development build with Strict Mode, inspect whether a success Effect runs more than once. Move action-specific reporting to the accepted-operation handler or make the effect's synchronization safe. Keep Strict Mode enabled so it can reveal lifecycle issues.
  5. Check the configured Growth goal separately. Confirm whether it counts a qualifying session and compare that number with the server-side accepted-operation count over the same window.

These checks distinguish event delivery from application behavior. A matching duplicate ID suggests a replay; separate IDs suggest separate tracker calls. Neither case tells you whether the underlying business operation happened once. For examples of sending an event after a successful request and reading conversion goals, see the [Next.js signup tracking tutorial](https://whovisited.net/blog/nextjs-signup-conversion-tracking) and the [success-page versus custom-event guide](https://whovisited.net/blog/success-page-vs-custom-event-goals).

Keep the event name stable and properties coarse, such as a fixed form label. Do not attach an email, account ID, order ID, or raw operation key to make events appear unique. If you need a durable count of unique accepted operations, calculate it from your application's idempotent server records and use WhoVisited to understand the surrounding visit context.

References and further reading

  1. React: Separating Events from Effects
  2. React: StrictMode
  3. React: useEffect
  4. WhoVisited: signup conversion tracking in Next.js