Most instrumentation bugs don't announce themselves. They accumulate silently: a property that sometimes sends as a number and sometimes as a string, an identify() call that fires before the user object has loaded, a track() call deep in a component lifecycle that fires twice on every mount. By the time someone notices that their funnel data looks wrong, the schema has six months of inconsistent history that requires either manual correction or a clean slate.
This guide covers the patterns we see fail most often, and the practices that prevent them. It's oriented around JavaScript/TypeScript frontend instrumentation — single-page apps, React in particular — though the principles apply across frameworks. If you're setting up instrumentation from scratch, reading this before you write your first track() call will save you several painful months of debugging data quality issues while also trying to use that data to make product decisions.
Before You Write a Single Track Call: The Tracking Plan
The most common instrumentation mistake is starting with the code. Before writing any SDK calls, you need a tracking plan: a document that specifies every event you intend to fire, the conditions under which it fires, and the properties it carries. This doesn't need to be elaborate. A spreadsheet with four columns (event name, trigger condition, required properties, owner) is sufficient for a small team.
The tracking plan serves two functions: it forces you to think through the event taxonomy before implementation (catching naming inconsistencies and property design problems at spec time rather than production time), and it creates a record of intent that future developers can reference when they need to add or modify instrumentation.
A minimal tracking plan for a SaaS product onboarding flow might look like:
| Event Name | Trigger | Required Properties | Optional Properties |
|---|---|---|---|
| Signup Started | User submits email on signup form | plan_tier, signup_source | referral_code |
| Signup Completed | User account created successfully | user_id, plan_tier, signup_source | referral_code |
| Onboarding Step Completed | User completes any onboarding checklist item | step_name, step_index, total_steps | time_spent_seconds |
| Project Created | User submits new project form | project_id, project_type | template_used, collaborators_added |
The Identify Pattern: Getting It Right
Identify() is the most consequential call in your instrumentation. It associates all subsequent events from this browser session to a specific user identity. Get it wrong and you'll have fragmented user event streams that make cohort analysis and retention measurement unreliable.
The correct pattern for a React SPA:
// In your auth context or equivalent — fires after login AND after signup
useEffect(() => {
if (user && user.id) {
analytics.identify(user.id, {
email: user.email,
plan_tier: user.planTier, // string: "free", "pro", "enterprise"
signup_date: user.createdAt, // ISO 8601 string
account_id: user.accountId, // for group() lookup
user_role: user.role // "admin", "member", "viewer"
});
// For B2B: always call group() after identify()
if (user.accountId) {
analytics.group(user.accountId, {
account_name: user.accountName,
plan_tier: user.accountPlanTier,
seat_count: user.accountSeatCount,
account_created_date: user.accountCreatedAt
});
}
}
}, [user?.id]); // dependency on user.id, not the whole user object
The critical mistakes to avoid:
- Calling identify() before the user object is fully loaded. If
user.planTieris undefined at the time of the identify() call, your analytics platform records a null or missing value for plan_tier on this identity, and you lose the ability to segment by plan tier for this user's subsequent events. Always gate the identify() call on the user object being fully populated. - Only calling identify() on signup, not on login. On a new device, in a new browser, or after clearing cookies, the user will get a fresh anonymous ID. Without a login-triggered identify(), their new session events won't be associated with their historical identity. identify() must fire on every login.
- Calling identify() outside of an auth context. In React apps, the temptation is to call identify() in multiple components wherever user data is available. This creates race conditions and duplicate calls. Put it in a single auth context component and have everything else downstream of that.
The Track Pattern: Preventing Double-Fires
Double-fires — track() being called twice for a single user action — are one of the most common instrumentation bugs and one of the hardest to detect without looking at raw event data. They inflate your event counts by an unknown multiplier and make funnel step conversion rates meaningless.
Common causes in React:
// WRONG: track() in useEffect without proper dependency control
// In React Strict Mode (development), effects run twice on mount
useEffect(() => {
analytics.track("Page Viewed", { page_name: "dashboard" });
}); // missing dependency array — fires on every render
// WRONG: track() in a component that renders multiple times
// before stable state
function FeaturePanel({ featureId }) {
// This fires once per render, not once per user interaction
analytics.track("Feature Panel Opened", { feature_id: featureId });
return <div>...</div>;
}
// CORRECT: track() on explicit user interaction
function FeaturePanel({ featureId }) {
const handleOpen = useCallback(() => {
analytics.track("Feature Panel Opened", {
feature_id: featureId,
feature_name: getFeatureName(featureId)
});
}, [featureId]);
return <div onClick={handleOpen}>...</div>;
}
// CORRECT: page view tracking with stable dependencies
useEffect(() => {
analytics.track("Page Viewed", {
page_name: "dashboard",
page_path: location.pathname
});
}, [location.pathname]); // only fires when path changes
Property Consistency: The Schema Contract
Properties are where schema debt accumulates fastest. The rules that prevent it:
Use a Centralized Properties Module
Don't define property values inline at each track() call. Create a centralized analytics module that exports typed property builders:
// analytics/properties.ts
export type PlanTier = "free" | "pro" | "enterprise";
export type UserRole = "admin" | "member" | "viewer";
export interface UserProperties {
user_id: string;
plan_tier: PlanTier;
user_role: UserRole;
signup_date: string; // ISO 8601
}
export interface ProjectProperties {
project_id: string;
project_type: "blank" | "template" | "imported";
collaborator_count: number;
}
// Each track call uses typed properties
analytics.track("Project Created", {
...buildProjectProperties(project),
...buildUserContextProperties(user)
} satisfies ProjectCreatedProperties);
TypeScript's satisfies operator (available since TS 4.9) will catch property type mismatches at compile time — before the event ever reaches your analytics platform. This is the closest thing to schema enforcement at the instrumentation layer without a dedicated schema validation tool.
Never Send PII as Event Properties
Email addresses, names, phone numbers, and any other personally identifiable information should never appear as event properties in track() calls. Properties travel through CDPs, data warehouses, and analytics platforms — often with different data retention and access control settings than your user database. PII that leaks into event properties becomes a compliance and security problem that's difficult to remediate retroactively.
The identify() call is designed to hold user attributes including email. Event properties should carry identifiers (user_id, account_id, resource_id) and behavioral context, not personal attributes.
SPA-Specific Patterns
Page Tracking in Client-Side Routing
In a traditional multi-page application, the browser fires a pageload event on every navigation, and analytics SDKs hook into this automatically. In an SPA with client-side routing (React Router, Next.js App Router), navigation doesn't fire a pageload — it fires a JavaScript route change. You need to track this explicitly:
// React Router v6 — in a layout component
import { useLocation } from "react-router-dom";
function AnalyticsWrapper({ children }) {
const location = useLocation();
useEffect(() => {
analytics.page({
name: getPageName(location.pathname),
path: location.pathname,
search: location.search
});
}, [location.pathname]);
return children;
}
Handling Async Events
Some events need to fire only after an async operation succeeds. The common mistake is firing the track() call before confirming the server-side operation completed:
// WRONG: fires even if the API call fails
const handleCreateProject = async () => {
analytics.track("Project Created", { project_name: name }); // too early
await api.createProject({ name });
};
// CORRECT: fires only after confirmed success
const handleCreateProject = async () => {
try {
const project = await api.createProject({ name });
analytics.track("Project Created", {
project_id: project.id,
project_name: project.name,
project_type: "blank"
});
} catch (error) {
// track failure separately if needed
analytics.track("Project Creation Failed", {
error_code: error.code
});
}
};
Validation Before Production
The final step before shipping instrumentation to production: validate that your events are firing correctly in a staging environment. The practical checklist:
- Open your analytics platform's live stream or debugger view (Segment Source Debugger, Mixpanel Event Stream, etc.)
- Walk through every user flow in your tracking plan
- For each flow, verify: the expected events fired, in the expected order, with the expected properties, and each event fired exactly once
- Verify that identify() fires on login and that subsequent events correctly attribute to the identified user (not an anonymous ID)
- Verify that group() fires on login for accounts and that account properties are correct
- Test in both development (React Strict Mode, double-fire environment) and staging (production build settings)
We're not saying this validation process will catch every bug — some edge cases only surface under production load or with real user behavior patterns that are hard to simulate. We're saying that systematic validation in staging catches the 80% of instrumentation bugs that come from straightforward implementation errors, leaving you to investigate only the genuinely subtle issues in production.
The cost of fixing instrumentation bugs after they've been producing bad data for three months is much higher than the cost of the validation session. Get the data right before you need to make decisions from it.