Overview
Mixpanel supports both client-side (browser/mobile SDK) and server-side tracking, each with distinct advantages and use cases. A robust implementation often combines both approaches, using each where it provides the most value. Understanding when to track from the client versus the server is critical for data accuracy, privacy compliance, and system reliability.
The key difference: client-side tracks what users see and interact with; server-side tracks what your application does and knows to be true.
Client-Side Tracking
What is Client-Side Tracking?
Client-side tracking uses JavaScript (web) or native SDKs (mobile) embedded in your application to capture events directly from user devices. The Mixpanel SDK runs in the user's browser or mobile app and sends events to Mixpanel's servers.
// Browser SDK
import mixpanel from 'mixpanel-browser';
mixpanel.init('YOUR_PROJECT_TOKEN');
mixpanel.track('Button Clicked', {
'Button Name': 'Sign Up'
});
When to Use Client-Side Tracking
| Use Case | Why Client-Side |
|---|---|
| UI interactions | SDK can access DOM events, clicks, form submissions |
| Page/screen views | Automatically captures navigation and routing |
| Client state | Knows what the user sees (scroll depth, viewport) |
| Device/browser data | Auto-captures OS, browser, screen size, language |
| Real-time user properties | Can immediately set people properties on interaction |
| A/B test assignments | Client-side experimentation frameworks |
| Geographic data | IP-based location captured automatically |
| Session tracking | SDK manages session identification and super properties |
Client-Side Advantages
- Automatic context capture: Browser, OS, screen resolution, language automatically included
- User interaction tracking: Can attach event listeners to DOM elements
- Session continuity: SDK maintains distinct_id across page loads
- No server load: Events sent directly to Mixpanel, not through your servers
- Real-time: Events fire immediately when actions occur
- People profiles: Can set and update user properties in real-time
Client-Side Limitations
| Limitation | Impact | Mitigation |
|---|---|---|
| Ad blockers | 20-40% of events may be blocked | Implement server-side proxy |
| Browser restrictions | Safari ITP, cookie blocking | Use server-side for critical events |
| Data manipulation | Users can modify client code | Validate critical data server-side |
| Privacy concerns | PII exposure in network requests | Hash/encrypt sensitive data |
| Incomplete data | Users may close browser before event sends | Use beacon API for exit events |
| No authentication | Can't verify user identity client-side | Server-side validates purchases |
| Network dependency | Offline users can't send events | Implement offline queuing |
Client-Side Implementation Example
// Initialize Mixpanel
mixpanel.init('YOUR_PROJECT_TOKEN', {
debug: true,
track_pageview: true,
persistence: 'localStorage'
});
// Track page view
mixpanel.track('Page Viewed', {
'Page Name': document.title,
'URL': window.location.href,
'Referrer': document.referrer
});
// Track button click
document.getElementById('cta-button').addEventListener('click', function() {
mixpanel.track('CTA Clicked', {
'Button Location': 'homepage_hero',
'Button Text': this.textContent
});
});
// Set user properties
mixpanel.identify('user_12345');
mixpanel.people.set({
'$email': 'user@example.com',
'Account Type': 'premium',
'$last_login': new Date().toISOString()
});
Server-Side Tracking
What is Server-Side Tracking?
Server-side tracking sends events from your backend application code to Mixpanel's servers. Events are triggered by application logic, database changes, webhook callbacks, or scheduled jobs.
// Node.js example
const Mixpanel = require('mixpanel');
const mixpanel = Mixpanel.init('YOUR_PROJECT_TOKEN');
mixpanel.track('Purchase Completed', {
distinct_id: 'user_12345',
'Order ID': 'ORD-98765',
'Revenue': 99.99,
'$amount': 99.99
});
When to Use Server-Side Tracking
| Use Case | Why Server-Side |
|---|---|
| Transactional events | Payment processing, order confirmation |
| Sensitive data | Revenue, pricing, internal IDs |
| Webhook callbacks | Payment processor notifications, third-party integrations |
| Background processes | Scheduled jobs, batch operations, cron tasks |
| Database triggers | Events fired when data changes |
| API responses | Events based on server-side logic |
| Security-critical events | Cannot be manipulated by users |
| High-value conversions | Must be tracked regardless of ad blockers |
| B2B account events | Company-level activities across multiple users |
| System health | Application monitoring, error tracking |
Server-Side Advantages
- Reliable delivery: Not affected by ad blockers or client-side failures
- Data integrity: Cannot be manipulated by users
- Complete context: Access to full database and business logic
- Sensitive data: Can safely track revenue, pricing, internal metrics
- Webhook support: Can receive and track third-party notifications
- Offline-friendly: Works regardless of client state
- Authoritative source: Represents ground truth from your system
Server-Side Limitations
| Limitation | Impact | Mitigation |
|---|---|---|
| No automatic context | Must manually set browser, OS, location | Store device data in database, send with events |
| distinct_id management | Must track and pass user IDs | Maintain session mapping |
| Server load | Events processed through your infrastructure | Use async processing, batch events |
| No DOM access | Can't track clicks, scrolls, client interactions | Use client-side for UI events |
| Latency | May not be real-time if queued | Implement near-real-time processing |
| IP geolocation | Mixpanel sees server IP, not user IP | Pass user IP explicitly |
Server-Side Implementation Examples
Node.js
const Mixpanel = require('mixpanel');
const mixpanel = Mixpanel.init('YOUR_PROJECT_TOKEN');
// Track event
mixpanel.track('Purchase Completed', {
distinct_id: 'user_12345',
'Order ID': 'ORD-98765',
'Revenue': 99.99,
'$amount': 99.99,
'$insert_id': 'ORD-98765', // Deduplication
'Payment Method': 'credit_card',
'Item Count': 3
});
// Set user properties
mixpanel.people.set('user_12345', {
'$email': 'user@example.com',
'Lifetime Value': 450.00,
'Last Purchase Date': new Date().toISOString()
});
// Increment counter
mixpanel.people.increment('user_12345', {
'Purchase Count': 1,
'Total Spent': 99.99
});
Python
from mixpanel import Mixpanel
mp = Mixpanel('YOUR_PROJECT_TOKEN')
# Track event
mp.track('user_12345', 'Purchase Completed', {
'Order ID': 'ORD-98765',
'Revenue': 99.99,
'$amount': 99.99,
'$insert_id': 'ORD-98765',
'Payment Method': 'credit_card',
'Item Count': 3
})
# Set user properties
mp.people_set('user_12345', {
'$email': 'user@example.com',
'Lifetime Value': 450.00,
'Last Purchase Date': datetime.now().isoformat()
})
# Increment counter
mp.people_increment('user_12345', {
'Purchase Count': 1,
'Total Spent': 99.99
})
Ruby
require 'mixpanel-ruby'
tracker = Mixpanel::Tracker.new('YOUR_PROJECT_TOKEN')
# Track event
tracker.track('user_12345', 'Purchase Completed', {
'Order ID' => 'ORD-98765',
'Revenue' => 99.99,
'$amount' => 99.99,
'$insert_id' => 'ORD-98765',
'Payment Method' => 'credit_card',
'Item Count' => 3
})
# Set user properties
tracker.people.set('user_12345', {
'$email' => 'user@example.com',
'Lifetime Value' => 450.00,
'Last Purchase Date' => Time.now.iso8601
})
PHP
<?php
require 'vendor/autoload.php';
$mp = Mixpanel::getInstance('YOUR_PROJECT_TOKEN');
// Track event
$mp->track('Purchase Completed', [
'distinct_id' => 'user_12345',
'Order ID' => 'ORD-98765',
'Revenue' => 99.99,
'$amount' => 99.99,
'$insert_id' => 'ORD-98765',
'Payment Method' => 'credit_card',
'Item Count' => 3
]);
// Set user properties
$mp->people->set('user_12345', [
'$email' => 'user@example.com',
'Lifetime Value' => 450.00,
'Last Purchase Date' => date('c')
]);
?>
Hybrid Approach: Best of Both Worlds
Most production implementations use both client-side and server-side tracking strategically.
Hybrid Architecture Pattern
User Browser Your Server Mixpanel
| | |
|--- UI Events (client) --------|--------------------------->|
| (clicks, views, etc.) | |
| | |
|--- Purchase Request --------->| |
| | |
| |--- Verify Payment -------->|
| | |
| |--- Purchase Event -------->|
| | (server-side) |
| | |
|<-- Confirmation Page ---------| |
| | |
|--- Confirmation View ---------|--------------------------->|
| (client-side) | |
Hybrid Implementation Example
// CLIENT-SIDE: Track user interaction
// file: checkout.js
mixpanel.track('Checkout Initiated', {
'Cart Total': 99.99,
'Item Count': 3
});
// Submit payment to server
fetch('/api/purchase', {
method: 'POST',
body: JSON.stringify({
items: cartItems,
distinct_id: mixpanel.get_distinct_id() // Pass to server
})
});
// SERVER-SIDE: Track authoritative purchase
// file: purchase.controller.js
app.post('/api/purchase', async (req, res) => {
const { items, distinct_id } = req.body;
// Process payment
const order = await processPayment(items);
// Track from server (authoritative)
mixpanel.track('Purchase Completed', {
distinct_id: distinct_id,
'$insert_id': order.id,
'Order ID': order.id,
'Revenue': order.total,
'$amount': order.total,
'Payment Method': order.paymentMethod,
'Item Count': items.length,
ip: req.ip, // Pass user IP for geolocation
time: Date.now()
});
// Update user profile
mixpanel.people.increment(distinct_id, {
'Purchase Count': 1,
'Total Spent': order.total
});
res.json({ success: true, order: order });
});
// CLIENT-SIDE: Track confirmation view
// file: confirmation.js
mixpanel.track('Order Confirmation Viewed', {
'Order ID': orderData.id
});
Decision Matrix: Client vs Server vs Both
| Event Type | Client | Server | Both | Reasoning |
|---|---|---|---|---|
| Button Click | ✓ | Only client knows about UI interaction | ||
| Page View | ✓ | Browser navigation event | ||
| Product View | ✓ | Client-side interaction | ||
| Add to Cart | ✓ | ✓ | Client for UX, server for backup | |
| Checkout Start | ✓ | UI interaction | ||
| Purchase | ✓ | ✓ | Server is authoritative, client confirms view | |
| Signup | ✓ | ✓ | Server creates account, client tracks form | |
| Login | ✓ | ✓ | Either works, server for API auth | |
| Profile Update | ✓ | Server validates and saves | ||
| Subscription Change | ✓ | Payment processor webhook | ||
| Password Reset | ✓ | Email sent from server | ||
| Search | ✓ | Client knows search term | ||
| Video Play | ✓ | Media player event | ||
| Error Occurred | ✓ | ✓ | Both client and server errors | |
| Email Sent | ✓ | Server action | ||
| Webhook Received | ✓ | External system notification |
Coordination and Deduplication
The Challenge
When tracking the same event from both client and server, you risk duplicate events:
User clicks "Purchase" → Client tracks "Purchase" → Server processes → Server tracks "Purchase"
Result: 2 events in Mixpanel for 1 purchase
Solution: $insert_id
Use the same $insert_id on both client and server to deduplicate:
// CLIENT-SIDE
const orderId = generateOrderId(); // Same ID used server-side
mixpanel.track('Purchase Initiated', {
'$insert_id': `purchase_${orderId}`,
'Order ID': orderId,
'Cart Total': 99.99
});
// Submit to server with order ID
submitPurchase(orderId);
// SERVER-SIDE
app.post('/api/purchase', async (req, res) => {
const orderId = req.body.orderId;
const order = await processPayment(req.body);
mixpanel.track('Purchase Completed', {
distinct_id: req.body.distinct_id,
'$insert_id': `purchase_${orderId}`, // Same insert_id
'Order ID': orderId,
'$amount': order.total
});
res.json({ success: true });
});
Result: Only one event appears in Mixpanel, with properties from whichever arrived first.
Deduplication Strategies
| Strategy | When to Use | Example |
|---|---|---|
| Same event name + $insert_id | Tracking same event from both sides | Purchase with order ID |
| Different event names | Tracking different aspects | "Checkout Initiated" (client) + "Purchase Completed" (server) |
| Client-only with server validation | High-confidence client events | Add to Cart (client validates stock) |
| Server-only for critical paths | Must be 100% accurate | Revenue events |
Timestamp Coordination
Ensure timestamps are consistent:
// CLIENT-SIDE: Capture client time
const clientTime = Date.now();
mixpanel.track('Purchase Initiated', {
'time': clientTime
});
// Send to server
fetch('/api/purchase', {
method: 'POST',
body: JSON.stringify({
clientTime: clientTime
})
});
// SERVER-SIDE: Use server time (more reliable)
mixpanel.track('Purchase Completed', {
distinct_id: userId,
time: Date.now() // Server authoritative time
});
Identity Management Across Client and Server
Maintaining Consistent distinct_id
// CLIENT-SIDE: Get distinct_id and send to server
const distinctId = mixpanel.get_distinct_id();
fetch('/api/event', {
method: 'POST',
body: JSON.stringify({
distinct_id: distinctId,
event_data: { /* ... */ }
})
});
// SERVER-SIDE: Use the distinct_id from client
app.post('/api/event', (req, res) => {
const { distinct_id, event_data } = req.body;
mixpanel.track('Server Event', {
distinct_id: distinct_id,
...event_data
});
});
Aliasing in Hybrid Setup
// CLIENT-SIDE: User signs up
const anonymousId = mixpanel.get_distinct_id();
// Submit signup form
const userId = await signup(formData);
// Create alias (ONLY on client, ONLY once)
mixpanel.alias(userId, anonymousId);
mixpanel.identify(userId);
// Send userId to server for future events
sessionStorage.setItem('userId', userId);
// SERVER-SIDE: DON'T call alias, just use userId
app.post('/api/track-event', (req, res) => {
const userId = req.session.userId;
mixpanel.track('Server Event', {
distinct_id: userId, // Just use the userId
'Event Property': 'value'
});
});
Critical: Only call alias() once, typically client-side on signup. Server-side should only use the final distinct_id.
IP Address and Geolocation
Client-Side (Automatic)
// Mixpanel automatically captures user's IP and derives location
mixpanel.track('Page Viewed');
// Results in properties: $city, $region, $country
Server-Side (Manual)
// Must explicitly pass user's IP address
mixpanel.track('Purchase Completed', {
distinct_id: userId,
ip: req.ip || req.connection.remoteAddress, // User's IP, not server IP
'Order ID': orderId
});
Without passing ip, Mixpanel will use your server's IP, resulting in incorrect geolocation.
Performance Considerations
Client-Side Batching
mixpanel.init('YOUR_PROJECT_TOKEN', {
batch_requests: true, // Batch events before sending
batch_size: 50, // Send after 50 events
batch_flush_interval_ms: 5000 // Or after 5 seconds
});
Server-Side Queuing
// Use a queue for high-volume server events
const Queue = require('bull');
const mixpanelQueue = new Queue('mixpanel-events');
mixpanelQueue.process(async (job) => {
const { eventName, properties } = job.data;
await mixpanel.track(eventName, properties);
});
// Track event via queue
function trackEvent(eventName, properties) {
mixpanelQueue.add({
eventName,
properties
});
}
// Usage
trackEvent('Purchase Completed', {
distinct_id: userId,
'$amount': 99.99
});
Batch Processing Server-Side
// Collect events and send in batch
const Mixpanel = require('mixpanel');
const mixpanel = Mixpanel.init('YOUR_PROJECT_TOKEN');
const eventBatch = [];
function queueEvent(eventName, properties) {
eventBatch.push({ eventName, properties });
if (eventBatch.length >= 50) {
flushEvents();
}
}
function flushEvents() {
const batch = [...eventBatch];
eventBatch.length = 0;
batch.forEach(({ eventName, properties }) => {
mixpanel.track(eventName, properties);
});
}
// Flush on interval
setInterval(flushEvents, 5000);
Error Handling and Reliability
Client-Side Retry Logic
function trackWithRetry(eventName, properties, maxRetries = 3) {
let retries = 0;
function attempt() {
mixpanel.track(eventName, properties, (err) => {
if (err && retries < maxRetries) {
retries++;
setTimeout(attempt, 1000 * retries); // Exponential backoff
}
});
}
attempt();
}
Server-Side Error Handling
async function trackEvent(eventName, properties) {
try {
await mixpanel.track(eventName, properties);
} catch (error) {
console.error('Mixpanel tracking failed:', error);
// Store failed event for retry
await db.failedEvents.insert({
eventName,
properties,
error: error.message,
timestamp: Date.now()
});
}
}
// Retry failed events
async function retryFailedEvents() {
const failedEvents = await db.failedEvents.find({ retryCount: { $lt: 3 } });
for (const event of failedEvents) {
try {
await mixpanel.track(event.eventName, event.properties);
await db.failedEvents.delete(event.id);
} catch (error) {
await db.failedEvents.update(event.id, {
retryCount: event.retryCount + 1
});
}
}
}
Testing and Validation
Testing Client-Side Tracking
// Use Mixpanel test mode
mixpanel.init('YOUR_PROJECT_TOKEN', {
debug: true,
test: true // Events won't be sent to production
});
// Mock for unit tests
jest.mock('mixpanel-browser');
test('tracks purchase event', () => {
trackPurchase({ orderId: '123', total: 99.99 });
expect(mixpanel.track).toHaveBeenCalledWith('Purchase Completed', {
'Order ID': '123',
'$amount': 99.99
});
});
Testing Server-Side Tracking
// Use separate test project token
const mixpanel = Mixpanel.init(
process.env.NODE_ENV === 'production'
? process.env.MIXPANEL_TOKEN
: process.env.MIXPANEL_TEST_TOKEN
);
// Validation function
async function validateTracking(eventName, properties) {
const requiredProps = ['distinct_id', 'Order ID', '$amount'];
for (const prop of requiredProps) {
if (!properties[prop]) {
throw new Error(`Missing required property: ${prop}`);
}
}
await mixpanel.track(eventName, properties);
}
Troubleshooting
| Symptom | Likely Cause | Solution |
|---|---|---|
| Duplicate events from client & server | Same event name, no $insert_id | Add unique $insert_id to both |
| Server events have wrong location | Using server IP instead of user IP | Pass ip: req.ip in properties |
| Client events missing in reports | Ad blocker | Implement server-side proxy |
| distinct_id different between client & server | Not passing ID from client | Send distinct_id in API requests |
| Events delayed on server | Synchronous tracking blocking requests | Use async queues |
| User properties not updating | Server-side people.set failing | Check distinct_id matches |
| $amount not appearing | Property sent as string | Ensure numeric: parseFloat(amount) |
| Alias created twice | Called from both client & server | Only alias client-side |
| Events lost during deploy | No queue or retry | Implement event queuing |
| Timestamp inconsistency | Client and server clocks out of sync | Use server time as authoritative |
Best Practices
Client-Side Best Practices
- Track UI interactions exclusively client-side: Clicks, scrolls, views
- Use client for initial user journey: Anonymous browsing before signup
- Implement retry logic: For critical events that must not be lost
- Respect ad blockers: Implement server-side proxy for critical events
- Minimize PII in client events: Hash or exclude sensitive data
- Test across browsers: Ensure compatibility with Safari, Firefox, Chrome
- Monitor performance: Ensure tracking doesn't slow down UX
Server-Side Best Practices
- Use server for authoritative events: Purchases, subscriptions, revenue
- Pass user IP address: For accurate geolocation
- Implement error handling: Retry failed events
- Use async processing: Don't block API responses
- Batch events when possible: Reduce API calls
- Maintain distinct_id consistency: Pass from client to server
- Never alias server-side: Only call alias client-side on signup
- Include $insert_id on critical events: Prevent duplicates
- Log failures: Monitor for tracking issues
- Test thoroughly: Validate events appear correctly in Mixpanel
Hybrid Best Practices
- Use $insert_id for deduplication: When tracking same event from both
- Client for UX, server for truth: Client tracks interactions, server tracks facts
- Pass distinct_id from client to server: Maintain user identity
- Coordinate timestamps: Use server time as authoritative
- Track different event names: When tracking different aspects (Initiated vs Completed)
- Document your strategy: Maintain clear documentation of what's tracked where
- Monitor both paths: Ensure neither client nor server tracking is failing
- Plan for failures: Graceful degradation if one path fails