Server-Side vs Client-Side Tracking in PostHog | OpsBlu Docs

Server-Side vs Client-Side Tracking in PostHog

Server-side vs client-side tracking approaches for PostHog. Covers implementation trade-offs, data accuracy, privacy compliance, ad blocker resilience,...

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 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

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.