Overview
PostHog supports both client-side (browser/mobile) and server-side (backend) event tracking. Each approach has distinct advantages, and most production implementations use both strategically.
Client-side tracking captures user interactions in real-time as they happen in the browser or mobile app. Server-side tracking captures backend events like payment processing, subscription renewals, or data processing jobs.
The question isn't which to choose, it's which to use when.
Client-Side Tracking
How It Works
The PostHog JavaScript SDK runs in the user's browser, capturing events and sending them to PostHog's ingestion endpoint.
Installation:
posthog.init('YOUR_PROJECT_API_KEY', {
api_host: 'https://app.posthog.com'
});
// Track event
posthog.capture('button_clicked', {
button_name: 'signup_cta'
});
Advantages
Automatic context capture:
- User agent, device type, browser version
- Screen resolution, viewport size
- Referrer, UTM parameters
- IP address (for geolocation)
- Page URL, title
User identity management:
- Anonymous tracking before login (automatic device_id)
- Seamless transition to identified users
- Cookie-based persistence across sessions
Real-time interaction tracking:
- Clicks, form submissions, scrolls
- Page views (including SPA route changes)
- Mouse movements, rage clicks, dead clicks
- Video playback, content engagement
Session recording:
- Only available client-side
- Captures visual DOM interactions
- Debugging tool integration
Feature flags:
- Low-latency flag evaluation
- Local caching for performance
- Real-time flag changes
Disadvantages
Client-side limitations:
- Blocked by ad blockers (mitigate with reverse proxy)
- Affected by browser privacy features (ITP, ETP)
- Requires JavaScript enabled
- User can inspect/modify code
Data reliability:
- Events lost if user navigates away quickly
- Network failures can drop events
- User can disable tracking
PII exposure:
- Events visible in browser network tab
- Sensitive data must be filtered client-side
Best For
- UI interactions (clicks, form submissions)
- Page views and navigation
- Session recordings
- Feature flag evaluation
- Anonymous user tracking
- Real-time feedback and engagement metrics
Server-Side Tracking
How It Works
PostHog SDKs run on your backend servers, capturing business logic events and sending them to PostHog.
Installation (Node.js):
const { PostHog } = require('posthog-node');
const posthog = new PostHog('YOUR_PROJECT_API_KEY');
// Track event
posthog.capture({
distinctId: 'user_123',
event: 'subscription_created',
properties: {
plan: 'Pro',
price: 29.99
}
});
// Shutdown on exit
await posthog.shutdown();
Advantages
Reliable event delivery:
- No ad blockers
- No browser privacy features
- Guaranteed execution
- Retry logic built-in
Secure:
- Events not visible to users
- Sensitive data protected
- API keys not exposed
- Server-to-server communication
Business logic tracking:
- Payment processing
- Subscription renewals
- Background jobs
- Cron tasks
- Webhooks from third parties
Data enrichment:
- Join with database records
- Add server-side context
- Compute derived metrics
- Access to full user profile
No client dependencies:
- Works without JavaScript
- Not affected by browser settings
- Tracks server-side systems (APIs, workers)
Disadvantages
Missing client context:
- No automatic device info
- No browser metadata
- No UTM parameters (unless passed from client)
- No session recording
Identity management complexity:
- Must manually track distinct_id
- No automatic anonymous → identified flow
- Need to sync distinct_id between client and server
Higher latency:
- Not real-time (batched)
- Delayed insights for user actions
Development overhead:
- Requires server-side code changes
- More complex testing
- Need to handle SDK lifecycle (init/shutdown)
Best For
- Payment and subscription events
- Backend business logic
- Scheduled tasks and cron jobs
- Webhook processing
- API endpoint tracking
- Data processing pipelines
- Events requiring database access
- Sensitive operations
Hybrid Approach (Recommended)
Most production implementations use both client-side and server-side tracking strategically.
When to Use Client-Side
User interactions:
// Client-side: Track button click
posthog.capture('checkout_button_clicked', {
cart_total: 99.99,
item_count: 3
});
Navigation:
// Client-side: Track page views
posthog.capture('$pageview', {
page_title: document.title,
page_url: window.location.href
});
Feature flags:
// Client-side: Check feature flag
posthog.onFeatureFlags(() => {
if (posthog.isFeatureEnabled('new-checkout')) {
showNewCheckout();
}
});
When to Use Server-Side
Payment processing:
// Server-side: Track payment success
app.post('/api/payments', async (req, res) => {
const payment = await processPayment(req.body);
posthog.capture({
distinctId: req.user.id,
event: 'payment_completed',
properties: {
amount: payment.amount,
currency: payment.currency,
payment_method: payment.method,
transaction_id: payment.id
}
});
res.json(payment);
});
Subscription renewals:
// Server-side: Track automatic renewal
async function renewSubscription(userId, subscriptionId) {
const renewal = await createRenewal(subscriptionId);
posthog.capture({
distinctId: userId,
event: 'subscription_renewed',
properties: {
subscription_id: subscriptionId,
plan: renewal.plan,
amount: renewal.amount,
renewal_count: renewal.count
}
});
}
Background jobs:
// Server-side: Track data export completion
async function processDataExport(userId, exportId) {
const startTime = Date.now();
try {
await generateExport(exportId);
posthog.capture({
distinctId: userId,
event: 'data_export_completed',
properties: {
export_id: exportId,
duration_ms: Date.now() - startTime,
file_size_mb: getFileSize(exportId)
}
});
} catch (error) {
posthog.capture({
distinctId: userId,
event: 'data_export_failed',
properties: {
export_id: exportId,
error_message: error.message
}
});
}
}
Identity Consistency
When using both client and server, ensure distinct_id matches.
Approach 1: Pass distinct_id to Server
Client-side:
// Get current distinct_id
const distinctId = posthog.get_distinct_id();
// Send to server with API request
fetch('/api/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
plan: 'Pro',
distinct_id: distinctId // Include in request
})
});
Server-side:
app.post('/api/subscribe', async (req, res) => {
const { plan, distinct_id } = req.body;
const subscription = await createSubscription(req.user.id, plan);
// Use same distinct_id from client
posthog.capture({
distinctId: distinct_id,
event: 'subscription_created',
properties: {
plan: plan,
price: subscription.price
}
});
res.json(subscription);
});
Approach 2: Use User ID
Client-side:
// Identify user on login
posthog.identify('user_123', {
email: 'user@example.com',
name: 'Jane Doe'
});
Server-side:
// Use same user ID
posthog.capture({
distinctId: 'user_123', // Same as client
event: 'subscription_created'
});
Approach 3: Store in Session/Cookie
Server-side (set cookie):
app.post('/login', (req, res) => {
const user = authenticateUser(req.body);
// Store user ID in cookie
res.cookie('ph_distinct_id', user.id, {
httpOnly: false, // Accessible to client-side JS
maxAge: 365 * 24 * 60 * 60 * 1000 // 1 year
});
res.json(user);
});
Client-side (read cookie):
// Read distinct_id from cookie
const distinctId = document.cookie
.split('; ')
.find(row => row.startsWith('ph_distinct_id='))
?.split('=')[1];
if (distinctId) {
posthog.identify(distinctId);
}
Performance Considerations
Client-Side Optimization
Batch events:
posthog.init('YOUR_API_KEY', {
loaded: (posthog) => {
posthog.set_config({
batch_size: 50, // Send in batches of 50
batch_interval: 10000 // Every 10 seconds
});
}
});
Lazy load SDK:
// Load PostHog only when needed
async function initAnalytics() {
const posthog = await import('posthog-js');
posthog.default.init('YOUR_API_KEY');
return posthog.default;
}
// Use when user interacts
button.addEventListener('click', async () => {
const posthog = await initAnalytics();
posthog.capture('button_clicked');
});
Server-Side Optimization
Batch events:
const posthog = new PostHog('YOUR_API_KEY', {
flushAt: 100, // Flush after 100 events
flushInterval: 10000 // Or every 10 seconds
});
Don't block requests:
app.post('/api/purchase', async (req, res) => {
const order = await createOrder(req.body);
// Track async, don't wait
posthog.capture({
distinctId: req.user.id,
event: 'purchase_completed',
properties: { order_id: order.id }
});
// Don't await
res.json(order); // Respond immediately
});
Graceful shutdown:
process.on('SIGTERM', async () => {
console.log('Flushing PostHog events...');
await posthog.shutdown(); // Ensures all events sent
process.exit(0);
});
Decision Matrix
| Use Case | Client-Side | Server-Side |
|---|---|---|
| Button clicks | ||
| Page views | (if SSR) | |
| Form submissions | (validation) | |
| Session recordings | (not supported) | |
| Feature flags | (if needed) | |
| Payment processing | ||
| Subscription events | ||
| Background jobs | ||
| Webhooks | ||
| API tracking | (partial) | |
| Error tracking | (client errors) | (server errors) |
| Anonymous users | ||
| Sensitive data |
Legend:
- = Recommended
- = Possible but consider alternatives
- = Not recommended or not possible
Validation and Testing Procedures
Step 1: Verify Client-Side Setup
Check client-side tracking:
// client-side-validation.js
function validateClientSide() {
const results = {
sdkLoaded: typeof posthog !== 'undefined',
distinctId: null,
config: {},
features: {},
tests: []
};
if (results.sdkLoaded) {
results.distinctId = posthog.get_distinct_id();
results.config = {
api_host: posthog.config.api_host,
autocapture: posthog.config.autocapture,
persistence: posthog.config.persistence
};
// Test autocapture
results.features.autocapture = posthog.config.autocapture;
results.tests.push({
feature: 'Autocapture',
enabled: posthog.config.autocapture,
status: posthog.config.autocapture ? 'Enabled' : 'Disabled'
});
// Test session recording
results.features.sessionRecording = !!posthog.config.disable_session_recording === false;
results.tests.push({
feature: 'Session Recording',
enabled: results.features.sessionRecording,
status: results.features.sessionRecording ? 'Enabled' : 'Disabled'
});
// Test feature flags
results.features.featureFlags = posthog.feature_flags !== undefined;
results.tests.push({
feature: 'Feature Flags',
enabled: results.features.featureFlags,
status: results.features.featureFlags ? 'Available' : 'Not available'
});
} else {
results.tests.push({
feature: 'PostHog SDK',
enabled: false,
status: 'Not loaded'
});
}
console.log('=== Client-Side Validation ===');
console.log('SDK Loaded:', results.sdkLoaded);
console.log('Distinct ID:', results.distinctId);
console.log('\nFeatures:');
console.table(results.tests);
return results;
}
// Run validation
validateClientSide();
Step 2: Verify Server-Side Setup
Node.js server-side validation:
// server-side-validation.js
const { PostHog } = require('posthog-node');
async function validateServerSide() {
const results = {
initialized: false,
apiKey: null,
canCapture: false,
canIdentify: false,
canShutdown: false
};
try {
// Initialize PostHog
const posthog = new PostHog(
process.env.POSTHOG_API_KEY,
{ host: 'https://app.posthog.com' }
);
results.initialized = true;
results.apiKey = process.env.POSTHOG_API_KEY ? 'Set' : 'Missing';
// Test capture
try {
posthog.capture({
distinctId: 'test_user_server',
event: 'server_side_test',
properties: { test: true }
});
results.canCapture = true;
} catch (e) {
console.error('Capture test failed:', e.message);
}
// Test identify
try {
posthog.identify({
distinctId: 'test_user_server',
properties: { test_property: 'value' }
});
results.canIdentify = true;
} catch (e) {
console.error('Identify test failed:', e.message);
}
// Test shutdown
try {
await posthog.shutdown();
results.canShutdown = true;
} catch (e) {
console.error('Shutdown test failed:', e.message);
}
console.log('=== Server-Side Validation ===');
console.table(results);
const allPassed = Object.values(results).every(v =>
v === true || v === 'Set'
);
console.log(allPassed ? 'All tests passed' : 'Some tests failed');
return results;
} catch (e) {
console.error('Server-side validation failed:', e.message);
return results;
}
}
// Run validation
validateServerSide();
Step 3: Test Identity Consistency
Verify distinct_id matches across client and server:
// test-identity-consistency.js
// CLIENT-SIDE TEST
function testClientIdentity() {
const distinctId = posthog.get_distinct_id();
console.log('Client distinct_id:', distinctId);
// Send to server for verification
fetch('/api/test/verify-identity', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ distinct_id: distinctId })
})
.then(res => res.json())
.then(data => {
console.log('Identity verification:', data);
});
return distinctId;
}
// SERVER-SIDE ENDPOINT
app.post('/api/test/verify-identity', (req, res) => {
const { distinct_id } = req.body;
// Track server-side event with same distinct_id
posthog.capture({
distinctId: distinct_id,
event: 'identity_verification_test',
properties: {
source: 'server',
timestamp: new Date().toISOString()
}
});
res.json({
success: true,
distinct_id: distinct_id,
message: 'Server received and tracked with same distinct_id'
});
});
// Run test
testClientIdentity();
Step 4: Test Event Attribution
Validate events are attributed correctly:
// test-event-attribution.js
class EventAttributionTester {
constructor() {
this.clientEvents = [];
this.serverEvents = [];
}
async testFullJourney() {
console.log('=== Testing Event Attribution ===\n');
// Step 1: Client-side page view
const distinctId = posthog.get_distinct_id();
console.log('1. User distinct_id:', distinctId);
posthog.capture('test_page_viewed', {
page: 'attribution-test'
});
this.clientEvents.push({
event: 'test_page_viewed',
source: 'client',
distinctId
});
// Step 2: Client-side button click
posthog.capture('test_button_clicked', {
button: 'test-cta'
});
this.clientEvents.push({
event: 'test_button_clicked',
source: 'client',
distinctId
});
// Step 3: Server-side purchase
const response = await fetch('/api/test/purchase', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
distinct_id: distinctId,
amount: 99.99
})
});
const result = await response.json();
this.serverEvents.push({
event: 'test_purchase_completed',
source: 'server',
distinctId: result.distinct_id
});
// Validate
this.validateAttribution();
}
validateAttribution() {
console.log('\n=== Attribution Validation ===\n');
const allEvents = [...this.clientEvents, ...this.serverEvents];
const distinctIds = new Set(allEvents.map(e => e.distinctId));
console.log('Total events:', allEvents.length);
console.log('Client events:', this.clientEvents.length);
console.log('Server events:', this.serverEvents.length);
console.log('Unique distinct_ids:', distinctIds.size);
console.log('\nEvents:');
console.table(allEvents);
if (distinctIds.size === 1) {
console.log('\nAll events attributed to same user');
return true;
} else {
console.error('\nEvents attributed to different users');
console.log('Distinct IDs found:', Array.from(distinctIds));
return false;
}
}
}
// Server-side test endpoint
app.post('/api/test/purchase', (req, res) => {
const { distinct_id, amount } = req.body;
posthog.capture({
distinctId: distinct_id,
event: 'test_purchase_completed',
properties: {
amount,
source: 'server',
timestamp: new Date().toISOString()
}
});
res.json({
success: true,
distinct_id: distinct_id,
amount
});
});
// Run test
const tester = new EventAttributionTester();
tester.testFullJourney();
Step 5: Performance Testing
Measure tracking performance:
// performance-test.js
class TrackingPerformanceTest {
constructor() {
this.results = {
client: [],
server: []
};
}
async testClientPerformance(iterations = 100) {
console.log(`Testing client-side performance (${iterations} events)...`);
const start = performance.now();
for (let i = 0; i < iterations; i++) {
posthog.capture('performance_test_event', {
iteration: i,
timestamp: Date.now()
});
}
const duration = performance.now() - start;
this.results.client = {
iterations,
totalTime: duration.toFixed(2) + 'ms',
avgPerEvent: (duration / iterations).toFixed(2) + 'ms',
eventsPerSecond: ((iterations / duration) * 1000).toFixed(2)
};
console.log('Client-side results:');
console.table(this.results.client);
return this.results.client;
}
async testServerPerformance(iterations = 100) {
console.log(`\nTesting server-side performance (${iterations} events)...`);
const start = Date.now();
const response = await fetch('/api/test/performance', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ iterations })
});
const result = await response.json();
const duration = Date.now() - start;
this.results.server = {
iterations,
totalTime: duration + 'ms',
avgPerEvent: (duration / iterations).toFixed(2) + 'ms',
eventsPerSecond: ((iterations / duration) * 1000).toFixed(2),
serverProcessing: result.processingTime + 'ms'
};
console.log('Server-side results:');
console.table(this.results.server);
return this.results.server;
}
async runFullTest() {
await this.testClientPerformance();
await this.testServerPerformance();
console.log('\n=== Performance Comparison ===');
console.log('Client avg per event:', this.results.client.avgPerEvent);
console.log('Server avg per event:', this.results.server.avgPerEvent);
}
}
// Server-side performance endpoint
app.post('/api/test/performance', async (req, res) => {
const { iterations } = req.body;
const start = Date.now();
for (let i = 0; i < iterations; i++) {
posthog.capture({
distinctId: 'performance_test_user',
event: 'server_performance_test',
properties: { iteration: i }
});
}
const processingTime = Date.now() - start;
await posthog.shutdown();
res.json({
success: true,
iterations,
processingTime
});
});
// Run test
const perfTest = new TrackingPerformanceTest();
perfTest.runFullTest();
Step 6: Integration Testing
End-to-end test with both approaches:
// integration-test.spec.js
const { test, expect } = require('@playwright/test');
test.describe('Hybrid tracking integration', () => {
test('should track events from both client and server', async ({ page }) => {
const clientEvents = [];
const serverEvents = [];
// Intercept client-side PostHog requests
await page.route('**/batch/*', route => {
const postData = route.request().postDataJSON();
if (postData?.batch) {
clientEvents.push(...postData.batch.map(e => ({
...e,
source: 'client'
})));
}
route.continue();
});
// Navigate to page
await page.goto('http://localhost:3000');
// Get distinct_id from client
const distinctId = await page.evaluate(() => {
return window.posthog.get_distinct_id();
});
expect(distinctId).toBeTruthy();
console.log('Distinct ID:', distinctId);
// Trigger client-side event
await page.click('#test-button');
// Wait for client event
await page.waitForTimeout(1000);
// Trigger server-side event
const response = await page.request.post('/api/test/server-event', {
data: {
distinct_id: distinctId,
event_name: 'server_test_event'
}
});
expect(response.ok()).toBeTruthy();
const serverData = await response.json();
serverEvents.push({
event: serverData.event_name,
distinctId: serverData.distinct_id,
source: 'server'
});
// Validate
console.log('\nClient events:', clientEvents.length);
console.log('Server events:', serverEvents.length);
const allDistinctIds = [
...clientEvents.map(e => e.properties?.distinct_id || e.distinctId),
...serverEvents.map(e => e.distinctId)
];
const uniqueDistinctIds = [...new Set(allDistinctIds)];
expect(uniqueDistinctIds.length).toBe(1);
expect(uniqueDistinctIds[0]).toBe(distinctId);
console.log('All events attributed to same user:', distinctId);
});
test('should maintain identity through purchase flow', async ({ page }) => {
await page.goto('http://localhost:3000/product');
// Get initial distinct_id
const initialDistinctId = await page.evaluate(() => {
return window.posthog.get_distinct_id();
});
// Add to cart (client-side)
await page.click('#add-to-cart');
await page.waitForTimeout(500);
// Checkout (client-side)
await page.click('#checkout');
await page.waitForTimeout(500);
// Complete purchase (server-side)
await page.fill('#card-number', '4242424242424242');
await page.click('#submit-payment');
// Wait for server response
await page.waitForSelector('#confirmation');
// Get final distinct_id
const finalDistinctId = await page.evaluate(() => {
return window.posthog.get_distinct_id();
});
// Should remain consistent
expect(finalDistinctId).toBe(initialDistinctId);
console.log('Identity maintained through purchase flow');
});
});
Troubleshooting
Common Issues and Solutions
| Problem | Symptoms | Root Cause | Solution |
|---|---|---|---|
| Distinct ID mismatch | Client and server events show different users | Distinct ID not passed from client to server | Pass distinct_id in API requests; use cookies or headers to sync |
| Server-side events delayed | Events take minutes to appear | Events batched but not flushed | Call posthog.shutdown() or posthog.flush() to force send |
| Client events blocked | No client-side events in dashboard | Ad blocker or network issues | Use reverse proxy; check browser console for errors |
| Memory leak in server | Server memory grows over time | PostHog client not shutdown properly | Always call posthog.shutdown() on process exit |
| Duplicate events | Same event tracked twice | Both client and server tracking same action | Choose one source per event; document which tracks what |
| Missing user context | Server events lack client properties | Server doesn't have access to client data | Pass relevant properties in API requests from client |
| Anonymous users not merging | Same user appears multiple times | No alias call when identifying | Use posthog.alias() on client when user logs in |
| Server SDK not initialized | Server events fail silently | SDK initialization failed | Check API key; add error handling; verify network connectivity |
| Client cookies blocked | Distinct ID changes on each visit | Third-party cookies blocked | Use first-party cookies; implement server-side session storage |
| Wrong API host | Events go to wrong PostHog instance | API host misconfigured | Verify api_host in both client and server config |
| Timezone discrepancies | Event timestamps don't match | Server and client in different timezones | Use UTC timestamps consistently |
| Rate limiting | Some events not processed | Too many events sent too quickly | Implement batching; reduce event volume; check PostHog limits |
| CORS errors | Client can't reach PostHog | CORS headers not configured | Use reverse proxy or verify PostHog CORS settings |
| Server events missing properties | Properties undefined on server | Async data not awaited before tracking | Await data fetching before capturing events |
| Feature flags inconsistent | Different flag values client vs server | Flags not synced | Use server-side rendering or pass flags from server to client |
| Node.js process doesn't exit | Server hangs on shutdown | PostHog client not properly closed | Call await posthog.shutdown() before process.exit() |
| Properties truncated | Long property values cut off | Exceeding PostHog limits | Limit property values; summarize long text |
| Different project IDs | Events split across projects | Different API keys used | Use same API key for client and server |
| SSL/TLS errors | Server can't connect to PostHog | Certificate validation issues | Verify SSL certificates; check Node.js version |
| Client bundle size too large | Page load slow | Full PostHog SDK loaded | Use lazy loading or import only needed modules |
Debug Tools
Client-side debugging:
// Enable verbose logging
posthog.debug(true);
// Check config
console.log('Client config:', posthog.config);
// View distinct_id
console.log('Distinct ID:', posthog.get_distinct_id());
// Check event queue
console.log('Pending events:', posthog._events_queue);
// Force flush
posthog.flush();
Server-side debugging:
const { PostHog } = require('posthog-node');
// Enable debug mode
const posthog = new PostHog('API_KEY', {
host: 'https://app.posthog.com',
// Add custom logger
logger: {
log: (...args) => console.log('[PostHog]', ...args),
error: (...args) => console.error('[PostHog Error]', ...args)
}
});
// Test capture with logging
posthog.capture({
distinctId: 'test_user',
event: 'debug_test'
});
console.log('Event queued');
// Flush and verify
posthog.flush();
console.log('Events flushed');
// Graceful shutdown
process.on('SIGTERM', async () => {
console.log('Shutting down PostHog...');
await posthog.shutdown();
console.log('PostHog shutdown complete');
process.exit(0);
});
Identity verification:
// Client-side
const clientId = posthog.get_distinct_id();
console.log('Client distinct_id:', clientId);
// Server-side
app.get('/api/debug/verify-identity', (req, res) => {
const sessionId = req.session?.distinct_id;
const cookieId = req.cookies?.ph_distinct_id;
res.json({
session_id: sessionId,
cookie_id: cookieId,
match: sessionId === cookieId
});
});
Validation Checklist
Before deploying hybrid tracking to production:
- Client-side SDK loaded and initialized
- Server-side SDK initialized in application
- Same API key used for both client and server
- Distinct ID passed from client to server
- Identity consistency verified across tracking methods
- No duplicate events tracked
- Critical events tracked server-side for reliability
- Server-side SDK shutdown on process exit
- User properties synced between client and server
- Anonymous to identified user flow working
- Cross-browser testing completed
- Server error handling implemented
- Client-side fallbacks in place
- Performance tested under load
- Network failure handling implemented
- Debug logging available for troubleshooting
- Documentation updated for team
- Monitoring and alerts configured
Best Practices
Do:
- Use client-side for UI interactions and user behavior
- Use server-side for business logic and sensitive operations
- Ensure distinct_id consistency across client and server
- Track conversion events server-side for reliability
- Use server-side for financial and subscription events
Don't:
- Send sensitive data (passwords, credit cards) client-side
- Rely solely on client-side for critical business metrics
- Duplicate events across client and server
- Forget to shutdown server-side SDK on process exit
- Mix distinct_id formats between client and server
Testing Both Approaches
Integration test:
describe('Purchase flow', () => {
it('tracks purchase from client and server', async () => {
// Client-side: Click checkout
await page.click('#checkout-button');
// Client-side event: checkout_started
expect(clientEvents).toContainEqual(
expect.objectContaining({
event: 'checkout_started'
})
);
// Complete payment
await completePurchase();
// Server-side event: purchase_completed
expect(serverEvents).toContainEqual(
expect.objectContaining({
event: 'purchase_completed',
distinctId: 'user_123'
})
);
// Verify same user
expect(clientEvents[0].distinctId).toBe(serverEvents[0].distinctId);
});
});
Need help? Check the PostHog SDK documentation or troubleshooting guide.