Amplitude Server-Side vs Client-Side | OpsBlu Docs

Amplitude Server-Side vs Client-Side

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

Overview

Amplitude supports both client-side and server-side tracking, each with distinct advantages and trade-offs. Understanding when to use each approach - or a hybrid of both - is critical for building a reliable, secure, and performant analytics implementation.

This guide covers the architectural decisions, implementation patterns, and coordination strategies for client-side and server-side tracking with Amplitude.

Client-Side Tracking

What It Is

Client-side tracking uses JavaScript (browser) or native mobile SDKs to send events directly from the user's device to Amplitude's servers.

// Browser - Amplitude JavaScript SDK
amplitude.init('YOUR_API_KEY');
amplitude.track('page_viewed', {
  page_path: window.location.pathname
});
// iOS - Amplitude Swift SDK
Amplitude.instance().logEvent("screen_viewed", withEventProperties: [
  "screen_name": "Home"
])
// Android - Amplitude Kotlin SDK
Amplitude.getInstance().logEvent("screen_viewed", JSONObject().put("screen_name", "Home"))

Advantages of Client-Side Tracking

Advantage Description
Automatic context Captures device info, browser, screen resolution, referrer, UTM params automatically
Session management Handles session_id and session timeout automatically
Real-time attribution Captures marketing attribution (UTM, referrer) at source
Low latency No server round-trip required for events
User interactions Best for capturing UI clicks, scrolls, form interactions
A/B test exposure Tracks which variant users see client-side

Disadvantages of Client-Side Tracking

Disadvantage Description
Ad blockers Privacy extensions can block tracking requests
Client manipulation Users can modify event data or disable tracking
Network dependency Offline users won't track until reconnected
PII exposure Easy to accidentally send sensitive data from client
Timing precision Client clocks may be inaccurate or manipulated
Code visibility API keys and implementation logic are public

When to Use Client-Side Tracking

Use client-side tracking for:

  • Navigation events: Page views, route changes, screen views
  • UI interactions: Button clicks, link clicks, form interactions
  • User engagement: Scroll depth, time on page, video plays
  • Marketing attribution: UTM parameters, referrer tracking
  • Session context: Capturing session-based behavior
  • A/B test tracking: Which variant was shown to user
  • Client-side feature usage: Features executed in browser/app

Client-Side Implementation Example

// Initialize Amplitude
amplitude.init('YOUR_API_KEY', null, {
  includeReferrer: true,
  includeUtm: true,
  saveEvents: true, // Offline queueing
  sessionTimeout: 30 * 60 * 1000 // 30 minutes
});

// Track page view with automatic context
amplitude.track('page_viewed', {
  page_path: window.location.pathname,
  page_title: document.title,
  page_type: 'product'
});

// Track UI interaction
document.querySelector('#add-to-cart').addEventListener('click', () => {
  amplitude.track('product_added_to_cart', {
    product_id: 'SKU-12345',
    price: 79.99,
    quantity: 1
  });
});

// Track with user ID after login
function handleLogin(userId) {
  amplitude.setUserId(userId);
  amplitude.track('user_logged_in', {
    login_method: 'email_password'
  });
}

Server-Side Tracking

What It Is

Server-side tracking sends events from your backend servers to Amplitude's HTTP API, keeping tracking logic and sensitive data server-side.

// Node.js - Amplitude Node SDK
const Amplitude = require('@amplitude/node');
const client = Amplitude.init('YOUR_API_KEY');

client.logEvent({
  event_type: 'purchase_completed',
  user_id: 'user_12345',
  event_properties: {
    order_id: 'ORD-98765',
    total_amount: 156.96,
    currency: 'USD'
  }
});

client.flush();
# Python - Amplitude Python SDK
from amplitude import Amplitude

amplitude = Amplitude('YOUR_API_KEY')

amplitude.track({
  'event_type': 'purchase_completed',
  'user_id': 'user_12345',
  'event_properties': {
    'order_id': 'ORD-98765',
    'total_amount': 156.96,
    'currency': 'USD'
  }
})

amplitude.flush()

Advantages of Server-Side Tracking

Advantage Description
Authoritative data Events can't be manipulated by users
Precise timestamps Server clocks are reliable and consistent
No ad blocker impact Requests originate from server, not blocked
Secure data Sensitive data (revenue, PII) kept server-side
Data enrichment Access to database records for enrichment
Backend events Track events that don't have UI representation
API key security API keys never exposed to client

Disadvantages of Server-Side Tracking

Disadvantage Description
No automatic context Must manually pass device, session, attribution data
Session management You manage session_id and session continuity
Attribution gaps UTM params and referrer must be persisted and passed
Device_id tracking Must capture and store device_id from client
Latency Additional server processing adds latency
Infrastructure Requires server-side infrastructure and maintenance

When to Use Server-Side Tracking

Use server-side tracking for:

  • Commerce events: Purchases, refunds, subscriptions with authoritative data
  • Revenue tracking: Precise monetary transactions
  • Backend operations: Batch jobs, scheduled tasks, system events
  • Sensitive data: Events containing PII or proprietary business logic
  • Database-triggered events: User lifecycle changes from DB updates
  • High-value events: Critical business events requiring reliability
  • Webhook processing: Events triggered by third-party webhooks

Server-Side Implementation Examples

Node.js

const Amplitude = require('@amplitude/node');
const client = Amplitude.init('YOUR_API_KEY');

// Purchase event from order confirmation
app.post('/api/orders/complete', async (req, res) => {
  const { orderId, userId } = req.body;

  // Fetch order from database
  const order = await db.orders.findById(orderId);
  const user = await db.users.findById(userId);

  // Send to Amplitude with enriched data
  client.logEvent({
    event_type: 'purchase_completed',
    user_id: userId,
    device_id: req.cookies.amplitude_device_id, // From client cookie
    time: Date.now(),
    event_properties: {
      order_id: orderId,
      total_amount: order.total,
      subtotal: order.subtotal,
      tax: order.tax,
      shipping: order.shipping,
      currency: order.currency,
      item_count: order.items.length,
      payment_method: order.paymentMethod,
      // Server-side enrichment
      customer_ltv: user.lifetimeValue,
      customer_segment: user.segment,
      is_first_purchase: user.orderCount === 1,
      days_since_signup: Math.floor(
        (Date.now() - user.createdAt.getTime()) / (1000 * 60 * 60 * 24)
      )
    },
    // Revenue tracking
    revenue: order.total,
    price: order.total,
    quantity: order.items.length
  });

  await client.flush();

  res.json({ success: true });
});

Python (Flask)

from amplitude import Amplitude
from flask import Flask, request

app = Flask(__name__)
amplitude = Amplitude('YOUR_API_KEY')

@app.route('/api/subscriptions/create', methods=['POST'])
def create_subscription():
    user_id = request.json['user_id']
    plan = request.json['plan']

    # Fetch user from database
    user = db.users.find_one({'_id': user_id})

    # Track subscription creation
    amplitude.track({
        'event_type': 'subscription_started',
        'user_id': user_id,
        'device_id': request.cookies.get('amplitude_device_id'),
        'event_properties': {
            'plan': plan,
            'price': plan_pricing[plan],
            'currency': 'USD',
            'billing_cycle': 'monthly',
            # Server-side enrichment
            'user_tenure_days': (datetime.now() - user['created_at']).days,
            'previous_plan': user.get('previous_plan', 'free')
        }
    })

    amplitude.flush()

    return {'success': True}

Ruby (Rails)

require 'amplitude-api'

class OrdersController < ApplicationController
  def complete
    order = Order.find(params[:order_id])
    user = order.user

    # Track to Amplitude
    AmplitudeAPI.track(
      user_id: user.id,
      device_id: cookies[:amplitude_device_id],
      event_type: 'purchase_completed',
      time: Time.now.to_i,
      event_properties: {
        order_id: order.id,
        total_amount: order.total,
        currency: 'USD',
        item_count: order.items.count,
        # Server-side enrichment
        customer_segment: user.segment,
        lifetime_value: user.lifetime_value,
        is_repeat_customer: user.orders.count > 1
      }
    )

    render json: { success: true }
  end
end

Hybrid Approach (Client + Server)

The most robust implementations combine both client-side and server-side tracking.

Architecture Pattern

┌─────────────────────────────────────────────────────────────┐
│                         User Device                         │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  Client-Side Amplitude SDK                          │   │
│  │  - UI interactions                                   │   │
│  │  - Page views                                        │   │
│  │  - Attribution capture                               │   │
│  │  - Session management                                │   │
│  └─────────────────┬───────────────────────────────────┘   │
│                    │ Events                                 │
│                    ▼                                         │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  Amplitude Servers                                   │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                              │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  API Calls to Your Backend                          │   │
│  │  - Pass device_id, session_id                        │   │
│  └─────────────────┬───────────────────────────────────┘   │
└────────────────────┼──────────────────────────────────────┘
                     │
                     ▼
┌─────────────────────────────────────────────────────────────┐
│                      Your Backend                            │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  Server-Side Amplitude SDK                          │   │
│  │  - Purchase events                                   │   │
│  │  - Revenue tracking                                  │   │
│  │  - Subscription changes                              │   │
│  │  - Data enrichment                                   │   │
│  └─────────────────┬───────────────────────────────────┘   │
│                    │ Events                                 │
│                    ▼                                         │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  Amplitude Servers                                   │   │
│  └─────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────┘

Hybrid Implementation Example

Client-Side (Browser)

// Capture and store device_id for server-side use
amplitude.init('YOUR_API_KEY');

const deviceId = amplitude.getDeviceId();
const sessionId = amplitude.getSessionId();

// Store in cookie for server access
document.cookie = `amplitude_device_id=${deviceId}; path=/; max-age=31536000`;

// Track client-side events
amplitude.track('page_viewed', {
  page_path: window.location.pathname
});

// On purchase, send to server with context
async function completePurchase(orderData) {
  const response = await fetch('/api/orders/complete', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      ...orderData,
      amplitude_device_id: deviceId,
      amplitude_session_id: sessionId,
      amplitude_user_id: amplitude.getUserId()
    })
  });

  // Also track client-side for UI feedback
  amplitude.track('purchase_initiated', {
    order_id: orderData.orderId,
    total_amount: orderData.total
  });

  return response.json();
}

Server-Side (Node.js)

app.post('/api/orders/complete', async (req, res) => {
  const {
    orderId,
    userId,
    amplitude_device_id,
    amplitude_session_id
  } = req.body;

  // Fetch authoritative order data
  const order = await db.orders.findById(orderId);

  // Track server-side with client context
  client.logEvent({
    event_type: 'purchase_completed',
    user_id: userId,
    device_id: amplitude_device_id, // From client
    session_id: amplitude_session_id, // From client
    insert_id: `purchase_${orderId}`, // Dedupe
    time: Date.now(),
    event_properties: {
      order_id: orderId,
      total_amount: order.total, // Authoritative from DB
      currency: order.currency,
      item_count: order.items.length,
      payment_method: order.paymentMethod
    }
  });

  await client.flush();

  res.json({ success: true });
});

Event Deduplication with insert_id

When tracking the same event from both client and server, use insert_id to prevent duplicates:

Client-Side

const orderId = 'ORD-98765';

amplitude.track('purchase_completed', {
  order_id: orderId,
  total_amount: 156.96
}, {
  insert_id: `purchase_${orderId}` // Deterministic ID
});

Server-Side

client.logEvent({
  event_type: 'purchase_completed',
  user_id: 'user_12345',
  insert_id: `purchase_${orderId}`, // Same insert_id
  event_properties: {
    order_id: orderId,
    total_amount: order.total // Authoritative value
  }
});

Amplitude deduplicates events with the same insert_id within a 7-day window, ensuring only one event is counted even if sent from both client and server.

Decision Matrix

Event Type Client-Side Server-Side Hybrid Reason
Page View Automatic context, no server needed
Button Click UI interaction, client-only
Form Submit Client for UX, server if validation needed
Add to Cart Client sufficient, server if authoritative needed
Purchase Authoritative revenue from server
Refund Backend process, no client representation
Subscription Change Billing system event, server-authoritative
User Signup Client for attribution, server for confirmation
User Login Client sufficient, server if enrichment needed
Video Play Client-side media event
Search Client captures input, server if logging results
Batch Job No client, backend process
Webhook Event External trigger, server-only

Legend: Recommended | Depends on use case | Not recommended

Session and Attribution Management

Client-Side Session Management

Amplitude handles session_id automatically:

amplitude.init('YOUR_API_KEY', null, {
  sessionTimeout: 30 * 60 * 1000 // 30 minutes
});

// Get current session_id
const sessionId = amplitude.getSessionId();

Server-Side Session Continuity

Pass session_id from client to server:

// Client-side
const sessionId = amplitude.getSessionId();

fetch('/api/purchase', {
  method: 'POST',
  body: JSON.stringify({
    orderId: 'ORD-123',
    sessionId: sessionId // Pass to server
  })
});
// Server-side
client.logEvent({
  event_type: 'purchase_completed',
  user_id: 'user_12345',
  session_id: req.body.sessionId, // Use client session
  event_properties: { order_id: req.body.orderId }
});

Attribution Preservation

Capture UTM parameters client-side and persist for server events:

// Client-side: Store UTM in session
const utmParams = {
  utm_source: new URLSearchParams(window.location.search).get('utm_source'),
  utm_medium: new URLSearchParams(window.location.search).get('utm_medium'),
  utm_campaign: new URLSearchParams(window.location.search).get('utm_campaign')
};

sessionStorage.setItem('utm_params', JSON.stringify(utmParams));

// Set as user properties
amplitude.setUserProperties(utmParams);
// Server-side: Retrieve UTM from session or database
app.post('/api/purchase', async (req, res) => {
  const user = await db.users.findById(req.body.userId);

  client.logEvent({
    event_type: 'purchase_completed',
    user_id: req.body.userId,
    event_properties: {
      order_id: req.body.orderId,
      // Include stored attribution
      utm_source: user.utmSource,
      utm_medium: user.utmMedium,
      utm_campaign: user.utmCampaign
    }
  });
});

Security Considerations

API Key Security

Approach Security Level Use Case
Client-side with public API key Low Acceptable for non-sensitive events
Server-side with secret API key High Required for revenue/PII events
Proxy server for client events Medium Add security layer to client tracking

Proxy Pattern for Enhanced Security

Route client-side events through your backend:

// Client-side: Send to your proxy
async function trackEvent(eventName, properties) {
  await fetch('/api/analytics/track', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ eventName, properties })
  });
}

trackEvent('page_viewed', { page_path: window.location.pathname });
// Server-side proxy
app.post('/api/analytics/track', async (req, res) => {
  const { eventName, properties } = req.body;

  // Validate and sanitize
  if (!isValidEvent(eventName)) {
    return res.status(400).json({ error: 'Invalid event' });
  }

  // Add server-side context
  client.logEvent({
    event_type: eventName,
    user_id: req.user?.id,
    device_id: req.cookies.amplitude_device_id,
    event_properties: sanitize(properties)
  });

  await client.flush();

  res.json({ success: true });
});

Data Validation and Enrichment

Client-Side Validation

function trackEventSafe(eventName, properties) {
  // Validate property types
  const validatedProperties = {};

  Object.entries(properties).forEach(([key, value]) => {
    if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
      validatedProperties[key] = value;
    }
  });

  amplitude.track(eventName, validatedProperties);
}

Server-Side Enrichment

async function enrichAndTrack(userId, eventType, properties) {
  // Fetch user data from database
  const user = await db.users.findById(userId);
  const subscription = await db.subscriptions.findOne({ userId });

  // Enrich with server-side data
  const enrichedProperties = {
    ...properties,
    user_segment: user.segment,
    user_ltv: user.lifetimeValue,
    subscription_plan: subscription?.plan || 'free',
    account_age_days: Math.floor(
      (Date.now() - user.createdAt.getTime()) / (1000 * 60 * 60 * 24)
    )
  };

  client.logEvent({
    event_type: eventType,
    user_id: userId,
    event_properties: enrichedProperties
  });

  await client.flush();
}

Troubleshooting

Symptom Likely Cause Solution
Client events blocked Ad blocker Implement server-side proxy or use first-party domain
Server events missing session session_id not passed Capture session_id client-side and pass to server
Duplicate events No insert_id Add unique insert_id to deduplicate
Attribution lost UTM not persisted Store UTM in database/session on first page load
Device_id mismatch Not shared with server Store device_id in cookie and pass to backend
Revenue discrepancies Client and server both tracking Use insert_id to dedupe, prefer server values
Timing inconsistencies Client clock drift Use server timestamps for authoritative events
User_id not set server-side Not passed from client Include user_id in API requests after login

Best Practices

1. Use Client for Context, Server for Authority

  • Client: Capture session, attribution, device context
  • Server: Send authoritative revenue, subscriptions, refunds

2. Pass Identifiers from Client to Server

Always send these from client to server:

  • device_id
  • session_id
  • user_id
  • UTM parameters

3. Deduplicate Critical Events

For events tracked from both sides:

// Use deterministic insert_id based on business key
const insertId = `purchase_${orderId}`;

4. Prefer Server-Side for Revenue

Track all revenue events server-side:

  • More reliable (no ad blockers)
  • Authoritative amounts (from database)
  • Secure (API key not exposed)

5. Validate Data Quality

  • Client: Sanitize before sending
  • Server: Validate against schema

6. Monitor Both Paths

Set up alerts for:

  • Event volume drops (client or server)
  • Discrepancies between client/server event counts
  • Failed server-side API calls

7. Test End-to-End

Validate that:

  • Client events capture attribution
  • Server events maintain session continuity
  • Deduplication works for hybrid events
  • Revenue appears correctly in Amplitude

8. Document Data Flow

Maintain documentation showing:

  • Which events are tracked where (client, server, both)
  • How identifiers flow from client to server
  • Deduplication strategies for each event type

Advanced Patterns

Offline Event Queue (Client)

class AmplitudeOfflineQueue {
  constructor() {
    this.queue = JSON.parse(localStorage.getItem('amp_queue') || '[]');
  }

  track(eventName, properties) {
    if (navigator.onLine) {
      amplitude.track(eventName, properties);
    } else {
      this.queue.push({ eventName, properties, timestamp: Date.now() });
      localStorage.setItem('amp_queue', JSON.stringify(this.queue));
    }
  }

  flush() {
    this.queue.forEach(({ eventName, properties }) => {
      amplitude.track(eventName, properties);
    });
    this.queue = [];
    localStorage.setItem('amp_queue', '[]');
  }
}

window.addEventListener('online', () => {
  const queue = new AmplitudeOfflineQueue();
  queue.flush();
});

Retry Logic (Server)

async function trackWithRetry(event, maxRetries = 3) {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      client.logEvent(event);
      await client.flush();
      return { success: true };
    } catch (error) {
      if (attempt === maxRetries) {
        console.error('Failed to track event after retries:', error);
        // Log to error tracking service
        return { success: false, error };
      }
      // Exponential backoff
      await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt) * 1000));
    }
  }
}

Batching (Server)

const eventBatch = [];

function queueEvent(event) {
  eventBatch.push(event);

  if (eventBatch.length >= 100) {
    flushBatch();
  }
}

async function flushBatch() {
  const batch = [...eventBatch];
  eventBatch.length = 0;

  batch.forEach(event => client.logEvent(event));
  await client.flush();
}

// Flush every 10 seconds
setInterval(flushBatch, 10000);