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_idsession_iduser_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);