Skip to content
ExponaBLOG
KAFKA & WEBSOCKETS · DAY 030 / 100

Notification events vs state transfer: a practical guide

Thin events or fat events?

Expona Admin6 min read

On day 30 of building real-world event-driven systems, you'll hit a decision that shapes everything downstream: do you send thin events with just an ID and type, or fat events packed with the full state consumers need? This choice—notification events vs state transfer—isn't academic; it affects latency, coupling, storage, and how your system scales when you have dozens of consumers.

Two styles — thin events vs fat events

The core tension: a thin event tells consumers that something happened and makes them fetch details; a fat event tells them what happened and everything they need to know.

Aspect Thin Events Fat Events
Payload size ID + type + timestamp Full relevant state, timestamps, history
Consumer responsibility Call producer's API or database to hydrate Use event data directly; optional enrichment
Coupling Tight runtime coupling (consumer → producer API) Loose runtime coupling; stronger schema coupling
Latency impact Higher (round-trip API call per event) Lower (process immediately)
Best for Sensitive data, infrequent reads, small consumer base High-throughput, many consumers, real-time needs

Let's say an order.placed event fires. A thin version:

typescript
{
  "event_id": "evt_12345",
  "event_type": "order.placed",
  "order_id": "ord_789",
  "timestamp": "2025-01-30T10:45:00Z"
}

A fat version:

typescript
{
  "event_id": "evt_12345",
  "event_type": "order.placed",
  "order_id": "ord_789",
  "user_id": "usr_456",
  "total_amount": 199.99,
  "currency": "USD",
  "items": [
    { "product_id": "prod_111", "qty": 2, "unit_price": 49.99 }
  ],
  "shipping_address": {
    "street": "123 Main St",
    "city": "Colombo",
    "country": "LK"
  },
  "payment_method": "card",
  "timestamp": "2025-01-30T10:45:00Z",
  "created_at": "2025-01-30T10:45:00Z"
}

Both are valid. Which you pick depends on your downstream needs, data sensitivity, and consumer count.

Thin events create chatter

With thin events, every consumer that needs details must call back to the producer or a shared database. On a small scale—say 3 consumers—this is fine. At scale, it becomes a problem.

Picture a fulfillment service consuming order.placed events. A thin event forces this flow:

  1. Event arrives: { order_id: "ord_789" }
  2. Consumer calls GET /orders/ord_789
  3. Producer API returns full order details
  4. Consumer processes and acts

Now add a notifications service, inventory service, analytics service, and CRM sync—all reading the same thin event. That's 5 round-trip API calls for a single order placement. In production, with thousands of orders per minute, your order API becomes a bottleneck.

typescript
// Fulfillment service listening to thin events
consumer.on('order.placed', async (event) => {
  // PROBLEM: This call happens for every single event
  const order = await orderApiClient.get(`/orders/${event.order_id}`);
  
  // Now we can fulfill
  await fulfillmentService.create({
    order_id: order.id,
    items: order.items,
    address: order.shipping_address
  });
});

Each API call adds latency, consumes database connections, and creates a dependency: if the producer API is slow or down, every consumer waits. You've coupled the consumer's speed to the producer's infrastructure, even though the producer already had all the data when it emitted the event.

Thin events also make testing harder. Your consumer tests need to mock the producer API, adding complexity.

Fat events decouple

Fat events embed the state consumers actually need. Now the fulfillment service works directly:

typescript
// Fulfillment service with fat events
consumer.on('order.placed', async (event) => {
  // BETTER: Everything is in the event
  await fulfillmentService.create({
    order_id: event.order_id,
    items: event.items,
    address: event.shipping_address
  });
});

No API call. No latency spike. No dependency on the producer's availability. The consumer has local ownership of the data it needs.

This decoupling is powerful in high-throughput systems. If your order service emits 10,000 events per minute and 8 consumers are listening, thin events create 70,000+ API calls per minute going back to the order service. Fat events: zero. The order service scales independently.

There's a trade-off: fat events are bigger (network bandwidth, storage, serialization cost). But in most real systems, the bandwidth cost of one order event (a few KB) is trivial compared to preventing thousands of round-trip API calls.

Fat events also document what data was relevant at the time of the event. A consumer can replay events months later and have the exact state that existed then, which is invaluable for auditing and debugging.

Choose per use case

The decision isn't binary across your whole system. Choose the approach for each event based on the data and consumers involved.

Fat events are better when:

  • You have many consumers (3+) reading the same event
  • Consumers need the data immediately; latency matters
  • The data isn't highly sensitive (PII, financial details in a regulated domain)
  • High throughput means round-trip calls would bottleneck the producer

Example: product.price_updated. Your search index, recommendation engine, and catalog service all listen. Fat events with the full product state (id, name, price, availability) let all three work instantly. The data is public anyway.

Thin events are better when:

  • Only 1–2 consumers need the event
  • The data is sensitive (payment PII, user passwords, medical records)
  • Consumers are low-frequency readers (hourly batch jobs, not real-time)
  • Event volume is low, so API calls don't accumulate

Example: payment.authorized in a fintech system. Only your settlement service and audit log need it. Embedding full card or bank account details in Kafka (where retention can span days) is a compliance risk. Thin event: { payment_id, amount, timestamp }. Consumer calls a secure, rate-limited endpoint to get sensitive details.

Hybrid approach: Some teams send fat events with public or derived data (order items, address) but thin pointers to sensitive data (payment details). The fulfillment service gets everything it needs; the billing service hydrates payment details on-demand from a restricted API.

typescript
// Hybrid: most data is fat, sensitive data is thin
{
  "event_id": "evt_12345",
  "event_type": "order.placed",
  "order_id": "ord_789",
  "items": [...],
  "total_amount": 199.99,
  "payment_id": "pay_555",  // Consumer calls /payments/pay_555 if needed
  "timestamp": "2025-01-30T10:45:00Z"
}

Common mistakes

Mistake 1: Sending fat events when data is sensitive. A team embeds full customer email, phone, and address in every event, including events published to Kafka with multi-day retention. Compliance and security teams rightly object. Know your data classification.

Mistake 2: Thin events without considering consumer count. You design elegant, minimal events. Then 6 months later, you add 5 new consumers. Suddenly that order API is hammered by lookup calls. Plan for future consumers from the start.

Mistake 3: Not versioning fat events. You send a fat event with 10 fields. A consumer expects 12 fields next year. If you've published the old schema for months, old events in Kafka will be missing those fields. Use schema versioning (Avro, Protobuf, or explicit version fields) so consumers can handle evolution.

Mistake 4: Bloated fat events. "Just include everything." You send user events with a nested array of all their orders, preferences, and interactions. Now your event is 500 KB. Serialization is slow, storage is expensive, and consumers only need 3 fields. Be deliberate: include what consumers reasonably need, not what could be relevant.

Key takeaways

  • Thin events (ID + type) require consumers to call the producer API; they decouple data sensitivity but create runtime coupling and API chatter.
  • Fat events (full state) let consumers act immediately without round-trips; they reduce latency and producer load but increase payload size and schema coupling.
  • At scale, thin events become expensive: 10,000 events/min × 5 consumers = 50,000 API calls/min to the producer. Fat events eliminate that cost.
  • Choose per event and use case, not globally. Sensitive data → thin. High-throughput, many consumers → fat.
  • Use a hybrid approach: fat for public/derived data, thin pointers for sensitive details that only specific consumers need.
  • Always version your event schemas so consumers can evolve independently.
  • Don't bloat fat events. Include relevant state, not everything possible.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles