ResourcesGuidesSaaS Analytics GuideConceptsTracking on successful states

Concepts

Tracking on successful states

Track outcomes, not intent. The rule behind most analytics bugs.

Stipe LelasStipe Lelas
May 20, 20266 minRaw .md

Fire your analytics event when the action succeeds, not when the user clicks the button that starts it. That one rule prevents most of the data-quality bugs you'll hit in a SaaS implementation. It's also the easiest to get wrong, because the broken version looks correct in code review and ships without complaint.

The rule

Don't fire an event because the user clicked a button. Fire it because the action the button initiated actually succeeded.

Wrong:

<button onClick={() => {
  amplitude.track("Invoice created");
  submitInvoice();
}}>
  Create invoice
</button>

Right:

const handleSubmit = async () => {
    const result = await submitInvoice();
    if (result.success) {
        amplitude.track("Invoice created");
    }
};

The first version fires the event whether or not the invoice was created. Validation fails? Event fires. Network drops? Event fires. Server throws a 500? Event fires. Your "Invoice created" count is now inflated by every attempt that failed.

The second version fires only after the server confirms the invoice exists. The count reflects what actually happened.

Why this matters

Track attempts instead of outcomes and three things go wrong.

Conversion rates inflate

Your "users who created an invoice" cohort includes users who tried and failed. Anything you build on top of it, like "created an invoice, then sent one", looks healthier than it is, and you end up making product decisions against numbers that were never real.

Failure modes hide

If a third of your "Invoice created" events are actually failures, you don't see a 33% failure rate. You see "Invoice created" climbing, while a third of those users hit a broken screen and the data never tells you.

Trust erodes

The first time someone finds an event tracked on intent, they start wondering which other events lie. That doubt spreads faster than you can audit, and people quietly stop opening the dashboards. Analytics nobody trusts is worse than no analytics, because you paid to build it.

Track the failure too

Firing on success is half the move. The other half is firing a separate event when the action fails, so the failure rate becomes a number you can watch instead of a silence.

const result = await submitInvoice();
if (result.success) {
    amplitude.track("Invoice created");
} else {
    amplitude.track("Invoice creation failure", {
        error_code: result.error,
    });
}

Now "Invoice created" stays clean, and "Invoice creation failure" tells you how often the flow breaks and why. A failure event is an indirect event, so it takes the noun + noun name (Invoice creation failure) rather than the past participle. See Naming conventions.

The pattern

For any event that depends on state changing in your database (an invoice created, a customer added, a subscription started), the shape is the same:

  1. The user takes an action
  2. Your frontend sends the request to your backend
  3. Your backend processes it and returns success or failure
  4. Then the event fires, on the success branch only

You can fire on the client after a successful response, or on the server after the database write completes. Both work. Which one you pick is a client vs. server decision.

Fire on the client:

const result = await createInvoice(data);
if (result.success) {
    amplitude.track("Invoice created", {
        /* properties */
    });
}

Fire on the server:

const invoice = await db.invoices.create(data);
await track(
    "Invoice created",
    {
        /* properties */
    },
    {
        user_id,
        device_id,
        session_id,
    },
);

Micro events: when there's no success state to wait for

This chapter has been about macro events. They mark something that changed in your database. A signup completed, an invoice created, a subscription started. Each has a success state on the server, which is why the rule is to wait for confirmation.

Micro events are the other category. They mark something that happened only in the user's browser. A dropdown opened, a filter applied, a tab switched. There is no server to wait on. The UI either updates or it doesn't, and the UI is the source of truth.

For these, fire on the committed value. The choice the user landed on, not every transient state on the way.

<Select
    onValueChange={(value) => {
        amplitude.track("Filter applied", {
            filter_type: "status",
            value,
        });
    }}
>

Committed means the user landed on a final choice. Capture the dropdown selection, not every hovered option. Capture the slider's released value, not every tick on the way. Capture the tab the user lands on, not the half-second hover that opened a tooltip on the way past.

Overcollection is the micro-event failure mode

Macro events fail by missing the outcome. Micro events fail by firing too often, on actions nobody will ever query.

Every hover, every keystroke, every scroll position, every dropdown that was opened and closed without a choice. Each is cheap in isolation. In aggregate they swamp your warehouse, drive up your event-based bill, and bury the signal nobody can find.

Three disciplines fix most of it.

  • Capture the committed action, not every intermediate state. A search submitted, not every keystroke on the way to it.
  • Debounce high-frequency interactions. If the user drags a slider through twenty positions in three seconds, you want one event, not twenty.
  • Skip events nobody will query. If no decision on the roadmap depends on knowing how often the help icon was hovered, don't track it.

Edge cases

Optimistic UIs

If your app shows the invoice as created while the request is still in flight, the temptation is to fire the event at the same moment. Don't.

Background syncs

If the app saves as the user types, firing on every keystroke tracks nothing meaningful. Pick the moment that carries analytic weight, like the first save or a user-initiated save, and fire there.

Long-running operations

If something takes 30 seconds (a large upload, a queued job), the user may navigate away before a client-side success event can fire. Server-side tracking is the only reliable answer. The event fires when the operation completes, wherever the user happens to be.

What to take away

If an event stands for something that happened in your database, wait for the server to confirm it before you fire. The extra latency between click and event is invisible to the user. The difference in data quality is the difference between dashboards people trust and dashboards they learn to ignore.