How We Reduced Platform Event Delivery Costs by 60% (And You Can Too)
A deep dive into the hidden costs of Salesforce Platform Events and the surprising optimization that saved us 50,000+ delivery allocations per day
TL;DR
- The Problem: Platform Events deliver to every browser tab separately, causing 200-300% cost multiplication during peak usage
- Root Cause: Each tab creates a separate CometD subscription - 4 tabs per user = 4x delivery allocation usage
- Solution: Client-side tab coordination where only ONE tab per user subscribes to Platform Events
- Results: 50-70% reduction in delivery costs, eliminated allocation limit issues during peak hours
- Alternative: Consider Salesforce native notifications for async processes (zero Platform Event usage)
- When to optimize: 50+ users with 3+ tabs each, hitting delivery limits, seeing usage spikes
The 100k Mystery
Picture this: Your Salesforce org is humming along nicely. Users love the real-time notifications you've built with Platform Events. Everything seems perfect.
Then you check your Platform Event delivery allocation and see this:
Monday: 75,000 deliveries ✅ Normal
Tuesday: 75,000 deliveries ✅ Normal
Wednesday: 225,000 deliveries ❌ WHAT?!
A 3x spike overnight. No new users. No new features. No bulk data imports. Just... chaos.
Sound familiar? You might be suffering from what I call "The Tab Multiplication Problem" - one of the most expensive hidden costs in Salesforce Platform Events.
The Real Culprit: It's Not What You Think
Your first instinct might be to blame runaway batch jobs publishing too many events, users triggering excessive workflow rules, or integration systems gone rogue.
But here's the twist: The problem isn't how many events you publish. It's how many times each event gets delivered.
The Multi-Tab Reality
Here's what was actually happening to us every single day:
The Pattern We Discovered:
- Users naturally open multiple Salesforce tabs during their workday
- Case record, Account record, Dashboard, Reports
- Each tab loads our Lightning component
- Each component subscribes to Platform Events
- Every single tab receives every single event
The math is brutal:
Single tab usage: 30 users × 1 subscription × 250 events = 7,500 deliveries
Multi-tab reality: 30 users × 3 tabs × 250 events = 22,500 deliveries
A 200% increase from users simply... having multiple tabs open.
Why we initially blamed "meetings": We noticed spikes during meeting hours (9-11 AM, 1-3 PM) because that's when users prep by opening multiple tabs, then leave everything running. But the real culprit was the multiple tabs, not the meetings themselves.
The Platform Event Delivery Model You Need to Understand
Here's what most developers get wrong about Platform Events:
❌ Common Misconception: "Platform Events are delivered once per user session"
✅ Reality: "Platform Events are delivered once per CometD subscriber"
From the official Salesforce documentation:
"The lightning-emp-api component creates a unique CometD connection for every user session... if there are 5K active customers subscribing to events via the component, the site would consume 50K (5K*10) of the event delivery count"
Each browser tab = separate subscription = separate delivery allocation
This means if the same user has multiple tabs open, you get multiple subscriptions. Multiple subscriptions mean multiplied delivery costs. Since most users keep multiple Salesforce tabs open throughout their workday, you end up with 2-4x the expected delivery allocation usage.
The Solution: Tab Coordination Architecture
Instead of fighting this behavior, we embraced it with a coordination pattern:
Primary/Secondary Tab Pattern
// Only ONE tab per user subscribes to Platform Events
Tab 1 (Primary): Creates Platform Event subscription
↓
Platform Events
↓
localStorage
↓
Tab 2,3,4 (Secondary): Poll localStorage for events
Key components:
-
Atomic Coordination Lock
// Prevent race conditions when multiple tabs load const lockAcquired = await this.acquireCoordinationLock(); if (lockAcquired) { // Become primary tab } else { // Become secondary tab } -
Cross-Tab Event Sharing
// Primary tab stores events for secondary tabs localStorage.setItem('events', JSON.stringify(eventData)); // Secondary tabs poll for new events setInterval(() => { this.checkForNewEvents(); }, 3000); -
Automatic Failover
// If primary tab dies, secondary takes over if (!this.isPrimaryTabHealthy()) { this.becomePrimaryTab(); }
The Implementation: Getting Your Hands Dirty
Here's the core coordination logic that solved our problem:
export default class OptimizedPlatformEventComponent extends LightningElement {
// Unique tab identifier
tabId = `tab_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
isPrimaryTab = false;
async connectedCallback() {
// Random delay prevents simultaneous coordination
setTimeout(() => {
this.initializeTabCoordination();
}, Math.random() * 2000);
}
async initializeTabCoordination() {
// Step 1: Try to acquire coordination lock
const lockAcquired = await this.acquireCoordinationLock();
if (!lockAcquired) {
this.becomeSecondaryTab();
return;
}
// Step 2: Check if healthy primary exists
const existingPrimary = this.getExistingPrimaryInfo();
if (existingPrimary && this.isPrimaryTabHealthy(existingPrimary)) {
this.releaseCoordinationLock();
this.becomeSecondaryTab();
return;
}
// Step 3: Become primary
await this.becomePrimaryTab();
}
async becomePrimaryTab() {
this.isPrimaryTab = true;
// Create the ONE Platform Event subscription
this.subscription = await subscribe(
'/event/Messaging_Utility_Event__e',
-1,
this.handlePlatformEvent.bind(this)
);
// Start heartbeat for health monitoring
this.startHeartbeat();
this.releaseCoordinationLock();
}
handlePlatformEvent(response) {
const eventData = response.data.payload;
// Store for secondary tabs
this.storeEventForSecondaryTabs(eventData);
// Process on primary tab
this.processEvent(eventData);
}
}
The Results: 60% Reduction in Delivery Allocation
After implementing tab coordination:
Before the optimization, we were seeing 3 tabs per user × 75,000 events = 225,000 daily deliveries. We'd frequently hit allocation limits during peak usage hours, which led to angry users when notifications stopped working completely.
After implementing tab coordination, we dropped to 1 subscription per user × 75,000 events = 75,000 daily deliveries. That's a 60% reduction in Platform Event costs with zero allocation limit issues, and users still get their notifications on all tabs.
The Gotchas: What We Learned the Hard Way
1. The Browser Storage Maze: Why Simple Solutions Don't Work
Our first instinct: "Just use localStorage to coordinate between tabs!"
❌ What we tried initially:
// Naive approach - check if subscription already exists
connectedCallback() {
const existingSubscription = localStorage.getItem('platform_event_subscription');
if (!existingSubscription) {
// Only subscribe if no existing subscription
this.subscribeToEvents();
localStorage.setItem('platform_event_subscription', 'active');
}
}
The brutal reality: This breaks in unexpected ways depending on HOW users open tabs.
Browser Tab Behavior Matrix
Here's what we discovered through painful testing:
| User Action | localStorage State | sessionStorage State | Our Component Behavior |
|---|---|---|---|
| New tab (Ctrl+T) | ✅ Shared | ❌ New session | ✅ Coordination works |
| Duplicate tab (Ctrl+Shift+K) | ✅ Shared | ✅ Copied | ❌ Both tabs think they're primary |
| Hard refresh (Ctrl+Shift+R) | ✅ Persists | ❌ Cleared | ❌ Original tab loses coordination |
| Back/Forward navigation | ✅ Persists | ✅ Persists | ❌ Multiple subscriptions created |
| Open link in new tab | ✅ Shared | ❌ New session | ✅ Coordination works |
| Restore closed tab (Ctrl+Shift+T) | ✅ Persists | ❌ New session | ❌ Conflicts with existing tabs |
The sessionStorage Trap
❌ sessionStorage approach that failed:
// This seemed logical but broke constantly
connectedCallback() {
const sessionId = sessionStorage.getItem('tab_session_id');
if (!sessionId) {
// Assume this is the primary tab
this.becomePrimary();
sessionStorage.setItem('tab_session_id', this.generateId());
}
}
Why it failed:
- Tab duplication → Multiple "primary" tabs with different session IDs
- Navigation within tab → Session persists, but component reinitializes
- Refresh behavior → Inconsistent across different refresh types
The localStorage Race Condition Nightmare
❌ Simple localStorage coordination that failed:
connectedCallback() {
const primaryTab = localStorage.getItem('primary_tab_id');
if (!primaryTab) {
localStorage.setItem('primary_tab_id', this.tabId);
this.becomePrimary();
}
}
What went wrong:
10:00:01.250 - Tab A: Check localStorage → null
10:00:01.251 - Tab B: Check localStorage → null
10:00:01.252 - Tab A: Set primary_tab_id = 'tab_a'
10:00:01.253 - Tab B: Set primary_tab_id = 'tab_b' (overwrites!)
10:00:01.254 - Both tabs think they're primary
The Solution: Atomic Locking + Verification
After countless failures, we learned that coordination requires:
1. Atomic operations with verification
async acquireCoordinationLock() {
// Try to acquire lock
localStorage.setItem(lockKey, JSON.stringify({
tabId: this.tabId,
timestamp: Date.now()
}));
// CRITICAL: Verify we actually got it
await this.sleep(200);
const verifyLock = localStorage.getItem(lockKey);
const currentLock = JSON.parse(verifyLock);
return currentLock.tabId === this.tabId;
}
2. Heartbeat-based health monitoring
// Don't trust localStorage state - verify primary is alive
isPrimaryTabHealthy(primaryInfo) {
const heartbeat = localStorage.getItem('heartbeat');
const lastBeat = JSON.parse(heartbeat).timestamp;
return (Date.now() - lastBeat) < 60000; // 60 second timeout
}
3. Graceful race condition handling
// If verification fails, gracefully become secondary
if (!lockAcquired) {
console.log('Lost coordination race - becoming secondary');
this.becomeSecondaryTab();
}
2. localStorage Isn't Shared Across Tabs (Wait, What?)
❌ Wrong assumption:
// This doesn't work across tabs
static sharedSubscription = null;
✅ Correct approach:
// localStorage IS shared across tabs for same domain
localStorage.setItem('subscription_info', data);
3. Race Conditions Are Real
When multiple tabs load simultaneously, you get coordination races. Solution: atomic locking with verification.
// Always verify you actually got the lock
localStorage.setItem(lockKey, JSON.stringify({tabId: this.tabId}));
await this.sleep(200); // Brief delay
const verifyLock = localStorage.getItem(lockKey);
if (JSON.parse(verifyLock).tabId !== this.tabId) {
// Lost the race - become secondary
this.becomeSecondaryTab();
}
4. Notification Deduplication Is Essential
Without deduplication, users see the same notification on every tab:
// Check if user already saw this notification
if (this.wasNotificationSeen(eventId)) {
return; // Skip duplicate
}
// Mark as seen immediately for visible tabs
if (!document.hidden) {
this.markNotificationAsSeen(eventId);
}
5. Browser Notifications for Background Tabs
Secondary tabs need browser notifications since users might not see toast notifications:
// Show browser notification when appropriate
if (document.hidden || !this.isPrimaryTab) {
this.showBrowserNotification(eventData);
}
When NOT to Use This Pattern
This optimization isn't always worth the complexity. If you have fewer than 30 concurrent users who typically keep just 1-2 tabs open, or your events are infrequent (less than 50 per day), you probably don't need this yet.
But if you have 50+ concurrent users who commonly have 3+ Salesforce tabs open, you're hitting delivery allocation limits, or you see usage spikes that correlate with peak work hours, then this optimization could save you significant headaches.
The Monitoring Strategy
Track these metrics to know if you need this optimization:
// Monitor subscription multiplication
console.log('Active subscriptions:', subscriptionCount);
console.log('Active user sessions:', userSessionCount);
console.log('Multiplication factor:', subscriptionCount / userSessionCount);
// Multiplication factor > 2.0 = optimization needed
Red flags to watch for:
- Delivery allocation consistently 2-4x higher than expected
- High delivery-to-event ratios (> 3:1)
- Users reporting missing notifications when you hit allocation limits
Alternative Approaches: When Client-Side Coordination Isn't Enough
If the 80% solution isn't sufficient for your use case, here are more robust alternatives that address the fundamental delivery allocation problem.
Approach 1: Server-Side Fan-Out Pattern
Instead of multiple client subscriptions, use a single server-side subscription that fans out to individual users.
Architecture:
Platform Events → Single Heroku/External Service → WebSockets → Individual Browser Tabs
Implementation:
// Heroku Node.js Service
const jsforce = require('jsforce');
const WebSocket = require('ws');
class PlatformEventProxy {
constructor() {
this.userConnections = new Map(); // userId -> Set of WebSocket connections
this.salesforceConnection = new jsforce.Connection();
}
async start() {
// Single Platform Event subscription on server
await this.salesforceConnection.streaming.subscribe(
'/event/Messaging_Utility_Event__e',
(message) => this.fanOutToUsers(message)
);
// WebSocket server for browser connections
this.wss = new WebSocket.Server({ port: 8080 });
this.wss.on('connection', (ws, req) => this.handleUserConnection(ws, req));
}
fanOutToUsers(platformEvent) {
const eventData = platformEvent.sobject;
const targetUserId = eventData.Target_User_Id__c;
if (eventData.Is_Global__c) {
// Send to all connected users
this.userConnections.forEach(connections => {
connections.forEach(ws => this.sendSafely(ws, eventData));
});
} else if (targetUserId) {
// Send to specific user's connections
const userConnections = this.userConnections.get(targetUserId) || new Set();
userConnections.forEach(ws => this.sendSafely(ws, eventData));
}
}
handleUserConnection(ws, req) {
const userId = this.extractUserIdFromAuth(req);
// Track this connection
if (!this.userConnections.has(userId)) {
this.userConnections.set(userId, new Set());
}
this.userConnections.get(userId).add(ws);
// Clean up on disconnect
ws.on('close', () => {
this.userConnections.get(userId).delete(ws);
if (this.userConnections.get(userId).size === 0) {
this.userConnections.delete(userId);
}
});
}
}
Client-side (simplified):
// Lightning Component - much simpler!
export default class OptimizedNotifications extends LightningElement {
websocket;
connectedCallback() {
// Single WebSocket connection per tab
this.websocket = new WebSocket('wss://your-heroku-app.herokuapp.com');
this.websocket.onmessage = (event) => {
const eventData = JSON.parse(event.data);
this.showNotification(eventData);
};
}
disconnectedCallback() {
if (this.websocket) {
this.websocket.close();
}
}
}
Benefits:
- ✅ Guaranteed 1 Platform Event delivery per event (to server)
- ✅ Perfect deduplication (server controls fan-out)
- ✅ User-specific filtering (server-side logic)
- ✅ Real-time delivery to all user's tabs
- ✅ No client coordination complexity
Trade-offs:
- ❌ Additional infrastructure (Heroku/external service)
- ❌ WebSocket connection management
- ❌ Authentication complexity
- ❌ Network latency (extra hop)
Approach 2: Polling-Based Notification System
Replace Platform Events entirely with a polling-based approach for async process notifications.
Database Design:
-- Custom Object: User_Notification__c
Id, User__c, Process_Type__c, Message__c, Status__c,
Created_Date__c, Read_Date__c, Process_Id__c
Server-side (Apex):
// When async process completes
public class AsyncProcessNotifier {
public static void notifyProcessComplete(String processType, String message, Id userId) {
User_Notification__c notification = new User_Notification__c(
User__c = userId,
Process_Type__c = processType,
Message__c = message,
Status__c = 'Unread',
Process_Id__c = generateProcessId()
);
insert notification;
}
@AuraEnabled
public static List<User_Notification__c> getUnreadNotifications() {
return [
SELECT Id, Process_Type__c, Message__c, Created_Date__c
FROM User_Notification__c
WHERE User__c = :UserInfo.getUserId()
AND Status__c = 'Unread'
ORDER BY Created_Date__c DESC
];
}
@AuraEnabled
public static void markNotificationsRead(List<Id> notificationIds) {
List<User_Notification__c> notifications = [
SELECT Id FROM User_Notification__c WHERE Id IN :notificationIds
];
for (User_Notification__c notif : notifications) {
notif.Status__c = 'Read';
notif.Read_Date__c = System.now();
}
update notifications;
}
}
Client-side (polling):
export default class PollingNotifications extends LightningElement {
@wire(getUnreadNotifications)
wiredNotifications({ data, error }) {
if (data && data.length > 0) {
this.processNewNotifications(data);
}
}
connectedCallback() {
// Poll every 30 seconds instead of real-time events
this.pollingInterval = setInterval(() => {
refreshApex(this.wiredNotifications);
}, 30000);
}
processNewNotifications(notifications) {
notifications.forEach(notif => {
this.showToast(notif.Process_Type__c, notif.Message__c);
});
// Mark as read to prevent showing again
const notifIds = notifications.map(n => n.Id);
markNotificationsRead({ notificationIds: notifIds });
}
}
Benefits:
- ✅ Zero Platform Event allocation usage
- ✅ Perfect deduplication (database-driven)
- ✅ Audit trail (all notifications stored)
- ✅ User preference management (notification settings)
- ✅ No coordination complexity
- ✅ Works across devices seamlessly
Trade-offs:
- ❌ Not real-time (30-60 second delays)
- ❌ Additional database storage
- ❌ SOQL query limits (if high volume)
- ❌ More complex read/unread state management
Approach 3: Hybrid Event + Database Pattern
Combine the best of both approaches: use Platform Events for real-time delivery but database for guaranteed delivery.
Implementation:
public class HybridNotificationSystem {
public static void sendNotification(String message, Id userId, String priority) {
// 1. Always store in database (guaranteed delivery)
User_Notification__c dbNotification = new User_Notification__c(
User__c = userId,
Message__c = message,
Status__c = 'Pending',
Priority__c = priority
);
insert dbNotification;
// 2. Try Platform Event for real-time (best effort)
try {
Messaging_Utility_Event__e platformEvent = new Messaging_Utility_Event__e(
Message_Body__c = message,
Target_User_Id__c = userId,
Database_Id__c = dbNotification.Id,
Message_Variant__c = priority.toLowerCase()
);
EventBus.publish(platformEvent);
} catch (Exception e) {
// Platform Event failed, but database notification exists
System.debug('Platform Event failed, falling back to database: ' + e.getMessage());
}
}
}
Client-side coordination:
export default class HybridNotifications extends LightningElement {
receivedEventIds = new Set();
connectedCallback() {
// Subscribe to Platform Events (when working)
this.subscribeToPlatformEvents();
// Fallback polling for missed events
this.startFallbackPolling();
}
handlePlatformEvent(response) {
const eventData = response.data.payload;
const dbId = eventData.Database_Id__c;
// Track that we received this via Platform Event
this.receivedEventIds.add(dbId);
// Show notification immediately
this.showNotification(eventData);
// Mark as read in database
markNotificationRead({ notificationId: dbId });
}
startFallbackPolling() {
// Check database every 60 seconds for missed notifications
setInterval(() => {
this.checkForMissedNotifications();
}, 60000);
}
async checkForMissedNotifications() {
const unreadNotifications = await getUnreadNotifications();
unreadNotifications.forEach(notif => {
if (!this.receivedEventIds.has(notif.Id)) {
// We missed this via Platform Event, show it now
this.showNotification(notif);
markNotificationRead({ notificationId: notif.Id });
}
});
}
}
Benefits:
- ✅ Real-time when Platform Events work
- ✅ Guaranteed delivery via database fallback
- ✅ Automatic gap detection and recovery
- ✅ Reduced Platform Event usage (only when needed)
- ✅ Audit trail and analytics
Approach 5: Salesforce Notifications (Bell Icon)
The often-overlooked native solution that might solve your problem without any custom development.
How it works:
// Apex - Send notification when async process completes
public class AsyncProcessNotifier {
public static void notifyProcessComplete(String title, String body, Id targetUserId, Id recordId) {
// Create custom notification
messaging.CustomNotification notification = new messaging.CustomNotification();
// Set notification properties
notification.setTitle(title);
notification.setBody(body);
notification.setNotificationTypeId(getNotificationTypeId()); // Configure in Setup
notification.setTargetId(recordId); // Optional: link to specific record
// Send to specific user(s)
Set<String> recipientsIds = new Set<String>{targetUserId};
notification.send(recipientsIds);
}
private static String getNotificationTypeId() {
CustomNotificationType notificationType = [
SELECT Id FROM CustomNotificationType
WHERE DeveloperName = 'Async_Process_Notifications'
LIMIT 1
];
return notificationType.Id;
}
}
Setup Required:
-
Setup → Custom Notifications → New Notification Type
- Name: "Async Process Notifications"
- Desktop: ✓ In-App, ✓ Push (optional)
- Mobile: ✓ In-App, ✓ Push
-
User Settings → Notifications → Configure preferences
- Users control when/how they receive notifications
- Automatic mobile push integration
Client-side (automatic):
// NO CODE NEEDED!
// Notifications appear automatically in:
// - Bell icon in Salesforce header
// - Mobile push notifications (if enabled)
// - Email digest (if configured)
// - Desktop push (if enabled)
Benefits:
- ✅ Zero Platform Event allocation usage
- ✅ Native Salesforce integration (bell icon)
- ✅ Automatic mobile push notifications
- ✅ User preference management (users control frequency)
- ✅ Cross-device synchronization built-in
- ✅ Read/unread state management automatic
- ✅ Audit trail (notification history)
- ✅ No custom UI development required
- ✅ Record linking (click notification → go to record)
- ✅ Batching and digest options (avoid spam)
Trade-offs:
- ❌ Limited customization (can't control exact appearance)
- ❌ No real-time toasts (appears in bell, not as toast)
- ❌ Requires notification type setup (admin configuration)
- ❌ User adoption (users must check bell icon)
- ❌ Mobile app required for push (not web-based push)
When to use Salesforce Notifications:
- Async process completion alerts
- System-generated notifications
- Cross-device delivery important
- Want native mobile push
- Users already use Salesforce mobile app
- Don't need immediate toast-style alerts
Updated Recommendation Matrix
| Use Case | Recommended Approach | Platform Event Usage | Setup Complexity |
|---|---|---|---|
| < 50 users, low volume | Salesforce Notifications | Zero | Low |
| < 100 users, need toasts | Client-side coordination | Optimized | Medium |
| 100-500 users, cross-device | Salesforce Notifications + Database | Zero | Low |
| 500-1000 users, real-time critical | Hybrid Event + Database | Reduced | Medium |
| 1000+ users, high volume | Server-side fan-out | Minimal | High |
| Critical processes | Email + Platform Events | Minimal | Low |
| Team notifications | Slack/Teams Integration | Zero | Medium |
| Audit/compliance heavy | Database polling | Zero | Medium |
| Mobile-first users | Salesforce Notifications | Zero | Low |
| Custom UI requirements | Server-side fan-out | Minimal | High |
The "Start Here" Decision Tree
Step 1: Are your notifications for async process completion?
- Yes → Try Salesforce Notifications first (zero Platform Events)
- No → Continue to Step 2
Step 2: Do you need immediate toast-style alerts in the UI?
- Yes → Continue to Step 3
- No → Use Salesforce Notifications or Email
Step 3: Are you hitting Platform Event delivery limits?
- No → Client-side coordination may be sufficient
- Yes → Continue to Step 4
Step 4: Can you add external infrastructure?
- Yes → Server-side fan-out (best solution)
- No → Hybrid Event + Database approach
Step 5: Still having issues?
- → Consider if you actually need real-time notifications
- → Most async processes work fine with Salesforce Notifications
Scenarios Where You Still Get Multiple Deliveries
1. Failover Windows
10:15:30 - Primary tab crashes unexpectedly
10:15:31 - Event published during failover
10:15:45 - Secondary tab detects primary is dead (15-second window)
10:15:46 - Secondary becomes new primary
Result: Events published during seconds 31-46 aren't delivered to anyone
Events published after 46 get delivered to new primary
If old primary "resurrects" briefly, double delivery possible
2. Network Partition Scenarios
User on flaky WiFi:
- Primary tab loses connectivity temporarily
- Secondary tab detects "dead" primary and takes over
- Primary tab reconnects and doesn't realize it lost coordination
- Both tabs are now "primary" until next heartbeat cycle
Result: 2x delivery allocation until coordination self-heals
3. Browser Resource Management
Browser under memory pressure:
- Suspends background tabs to free resources
- Primary tab gets suspended mid-heartbeat
- Secondary takes over thinking primary died
- Browser unsuspends original primary later
- Now you have competing primaries
This is especially common on mobile browsers and resource-constrained devices
4. Cross-Device Reality
User workflow:
- Desktop: 3 tabs open (1 becomes primary)
- Mobile: Opens Salesforce app during meeting
- Tablet: Checks dashboard over lunch
Result: 3 separate "primary" subscriptions across devices
Tab coordination only works within same browser instance
5. localStorage Corruption/Clearing
User actions that break coordination:
- Browser extension clears localStorage
- User manually clears browser data
- Incognito mode doesn't inherit coordination state
- Different sub-domains (my.salesforce.com vs custom.salesforce.com)
Result: All tabs think they're first and become primary
The Dirty Little Secrets
We still see occasional spikes:
Before optimization: Daily spikes of 200k-250k deliveries
After optimization: Daily spikes of 80k-100k deliveries
That's still 20k-30k "waste" deliveries, but it's manageable
Why 100% elimination is impossible:
- Physics: Network delays, browser limitations, timing windows
- User behavior: Unpredictable device usage patterns
- Salesforce: Platform Event delivery happens server-side before client coordination
- Scale: Edge cases multiply with more users
The 80/20 Reality Check
This solution gets you 80% of the benefit with 20% of the perfect coordination complexity.
What it reliably prevents:
- ✅ Normal multi-tab usage multiplication (biggest win)
- ✅ Meeting rush orphaned subscriptions
- ✅ Predictable daily allocation spikes
- ✅ Users getting 4 identical notifications
What it can't prevent:
- ❌ All edge case scenarios
- ❌ Cross-device subscription multiplication
- ❌ Network partition temporary doubles
- ❌ Browser resource management interference
When "Good Enough" Is Good Enough
Consider this optimization successful if:
- Daily allocation usage drops 50-70%
- Meeting-time spikes become manageable
- You stop hitting delivery allocation limits
- Users report better notification experience
Don't chase 100% perfection because:
- Diminishing returns on additional complexity
- Edge cases affect <5% of usage
- Perfect coordination would require server-side changes
- Salesforce doesn't provide the primitives for perfect client coordination
The Pragmatic Client-Side Approach
1. Monitoring and Alerting
// Track coordination health
if (subscriptionCount / userCount > 2.5) {
console.warn('Coordination degrading - investigate');
}
2. Graceful Degradation
// If coordination fails, fallback gracefully
if (this.coordinationFailed) {
// Still subscribe, but with exponential backoff
this.subscribeWithBackoff();
}
3. Business Impact Focus
Success metric: "Users get notifications reliably"
NOT: "Zero duplicate deliveries ever"
The Bottom Line
Platform Events are incredibly powerful, but the delivery model can surprise you with hidden costs. A single user with multiple tabs can consume 4x your expected allocation without you realizing it.
The honest truth: This optimization won't give you perfect coordination, but it will solve 80% of your delivery allocation problems with reasonable engineering effort.
The tab coordination pattern isn't just about saving money (though 50-70% cost reduction is significant). It's about reliability. When you hit allocation limits, Platform Events stop delivering altogether. Your real-time notifications become... not real-time.
What I wish I'd known from the start:
First, understand that Platform Events charge per subscription, not per user. That distinction matters more than you think. Monitor your multiplication factor (active subscriptions vs. user sessions) to spot problems early.
Don't try to build the perfect solution immediately - go for the 80% win with client-side coordination, and don't chase perfection. Browser behavior is wildly inconsistent across different tab operations, so test thoroughly. Plan for the edge cases that will still happen, with good monitoring and graceful degradation.
Most importantly, focus on business impact. Reliable notifications that occasionally duplicate are infinitely better than perfect deduplication that fails when you hit allocation limits.
The next time you see a mysterious 3x spike in Platform Event deliveries, remember: it might not be your code that's broken. It might just be your users opening multiple tabs.
Have you encountered Platform Event delivery allocation issues? Share your experiences in the comments below. And if you implement this pattern, I'd love to hear about your results!
Resources:
- Salesforce Platform Events Documentation
- Platform Event Delivery Limits Blog
- Lightning empApi Component
This post is based on real optimization work done on a production Salesforce org with 200+ daily active users and 25,000+ daily Platform Events.
Comments (0)
Loading comments...