Most modern SaaS products have the same challenge:
"When X happens, we need to notify a lot of things."
For example, when a lead is created:
- create/update a contact in HubSpot for sales
- fire a webhook to your backend
- add the lead to a Mailchimp list
- add the lead to Intercom for support
- log an event in your analytics stack
- notify someone in Slack
That's a classic fan-out problem: one event, many destinations.
The naive approach (and why it hurts)
The straightforward way is to put all the calls in your app:
async function onLeadCreated(lead) {
await Promise.all([
// send the lead to all the needed services
syncToHubSpotForSales(lead),
sendInternalWebhook(lead),
addToMailchimpForEmails(lead),
addToIntercomForSupport(lead),
trackInAnalytics(lead),
]);
}
This works until:
- one destination is slow (your handler is now slow)
- one destination is flaky (you need retries with exponential backoff and jitter)
- each integration wants different payload shapes
- you need per-tenant configuration ("ACME wants this, Contoso wants that")
You quickly end up with:
- per-integration branching (
if tenant === 'acme' then ...) - mixed concerns (business logic + integration plumbing)
- tangled retries and error handling
Pattern: central event + external fan-out
A more robust pattern:
- Your core app emits a single, clean event.
- A dedicated integration layer receives the event.
- The integration layer:
- evaluates rules
- decides which destinations should receive it
- maps the payload
- handles retries, backoff, and dead letters
Your app code stays focused on business events, not integration details.
For a deeper walkthrough of how routing rules determine which data goes where, see our post on event routing for SaaS.
A concrete example
Your app just does this:
import fetch from 'node-fetch';
import { createMeshesMachineToken } from './meshes-auth';
async function emitLeadCreated(workspaceId: string, lead: any) {
const token = await createMeshesMachineToken();
await fetch('https://api.meshes.io/api/v1/events', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
workspace: workspaceId,
event: 'lead.created',
payload: lead,
}),
});
}
Everything else (CRMs, webhooks, Slack, etc.) is configured as rules in the integration layer.
Why event fan-out belongs outside your core app
1. Different destinations fail differently
- CRMs might rate-limit or return 429s.
- Webhooks might timeout or be misconfigured.
- Analytics endpoints might be slow but not critical.
The customer UI needs to return fast, so it can't wait for these things to happen.
You don't want your core app to decide, per destination:
- what's retryable
- how long to backoff
- when to give up
- how to store dead letters
An integration layer can centralize that logic and treat each destination appropriately. When delivery does fail permanently, you need a dead letter queue to catch and replay events—not a silent data loss.
2. Per-tenant customization explodes combinatorics
Imagine:
- 1,000 customers
- each with their own preference of:
- which CRM to use
- which fields to map
- which events they care about
Baking that into your app produces a mess of:
if (tenant === 'acme') { ... }
else if (tenant === 'contoso') { ... }
Versus:
- one event schema per event type
- per-workspace rules + mappings in the integration layer
This is the multi-tenant integration pattern: workspaces isolate each customer's credentials, rules, and delivery state so one tenant's configuration never affects another.
3. Observability becomes manageable
Instead of:
- grepping app logs for "hubspot failed"
- trying to correlate job IDs with webhook IDs
You get:
- one place to see an event and all downstream attempts
- per-destination status and error messages
- retry and dead-letter counts
The build-vs-buy reality
You can build a fan-out system yourself. An event bus, a rule evaluator, per-destination workers, retry tables, dead letter storage, a dashboard—it's all well-understood infrastructure. Most teams estimate a few weeks.
What they underestimate is the ongoing cost: every new destination introduces new auth patterns, new payload shapes, new rate limit behaviors, and new edge cases in error handling. After the third or fourth integration, you're not building product features anymore—you're maintaining a one-off integration platform.
The question isn't "can we build this?" It's "is this where our engineering hours create the most value?"
Implementing fan-out with Meshes
Meshes is designed to be that integration layer:
- Events – you POST events into the API.
- Rules – you configure routing, conditions, and destinations.
- Connections – you securely store OAuth/API keys per workspace.
- Delivery engine – you get fan-out, retries, and dead letters out of the box.
Your architecture becomes:
- App: emit
lead.created. - Meshes:
- match rules on
lead.created - fan-out to HubSpot, Salesforce, webhooks, etc.
- manage retries and failures
- match rules on
You keep one clean pipeline:
Domain events in → integrations out.
When to adopt fan-out architecture
You'll get the most value when:
- you're integrating with more than a couple of third-party services
- customers are asking for per-tenant integration behavior
- you're spending time debugging "where did this webhook go?"
If you're already there, it might be time to centralize event fan-out instead of bolting on another webhook handler in your core app.
That's exactly the job Meshes is trying to do for you.
Ready to centralize your fan-out? Join Meshes and route events to every destination from one API.
