Skip to content

DynamoDB Streams: Fan-Out, CDC, and Projections

DynamoDB Streams is the single most underused feature on the platform. Most teams turn it on for backup-style use cases (“write to S3 just in case”) and never come back to it. Used well, Streams turn DynamoDB from a database into the source of truth that drives everything downstream - search indexes, materialized views, analytics warehouses, notifications, and event-sourcing read models.

The three patterns below cover 95% of what you’ll ever do with Streams. They compose. The same Stream can drive all three at once.

What Streams actually give you

Every write to a Stream-enabled table appends a record to an ordered log. The record contains:

  • The event type (INSERT, MODIFY, REMOVE)
  • The old image (before the write) and the new image (after) - configurable
  • The partition key and sort key of the affected item
  • A sequence number you can use to detect ordering / replays
  • A userIdentity block (you’ll use this to distinguish TTL deletes from user-initiated deletes)

Records are retained for 24 hours. Within a partition, ordering is preserved. Across partitions, you can’t assume order.

There are two main consumers:

  1. Lambda triggers (the easy path). AWS Lambda subscribes to the stream and invokes your function with batches of records. This is what you’ll use most of the time.
  2. Kinesis Data Streams adapter / EventBridge Pipes. For higher throughput, longer retention, or fan-out to non-Lambda consumers. EventBridge Pipes in particular is the modern way to wire Streams to other AWS services without writing transform code.

Pattern 1: Fan-out to multiple consumers

The most common shape. One write to DynamoDB, multiple downstream side effects: send a notification, update a search index, increment an analytics counter, post to a Slack webhook.

The naive way is to do all of that in the request handler that wrote the item. Five service calls, sequential, blocking the user. Any of them fails and you have to decide whether to roll back the DynamoDB write.

The Stream-based approach: write to DynamoDB once. A Lambda picks up the Stream record and fans it out.

// Lambda triggered by DynamoDB Stream
export async function handler(event: DynamoDBStreamEvent) {
  for (const record of event.Records) {
    if (record.eventName !== "INSERT") continue
    const item = unmarshall(record.dynamodb.NewImage)
    if (!item.pk?.startsWith("ARTICLE#")) continue

    // Fan out in parallel - one slow downstream doesn't block the others
    await Promise.allSettled([
      indexInOpenSearch(item),
      notifySubscribers(item),
      incrementCounter("articles_published"),
      postToSlack(item),
    ])
  }
}

This works because the user-facing write is a single round trip to DynamoDB - no waiting for downstream services. Downstream failures are isolated: if Slack is down, the search index still updates. Lambda retries failed batches automatically, giving you at-least-once delivery without writing your own retry loop. And if downstreams are slow, the Stream just buffers up to its 24-hour limit.

A few things to watch. Lambda can deliver the same record multiple times, so each downstream operation must be safe to apply twice (see idempotency keys). Promise.allSettled lets you log failures without failing the whole batch; if one downstream is consistently failing, dead-letter queue it. If you only care about a subset of items, filter early and return - don’t pay the unmarshalling cost on records you don’t need. Lambda also supports event source filters that prevent your function from being invoked at all for irrelevant records.

Pattern 2: change data capture (CDC) to another database

DynamoDB is good at point lookups and tightly-scoped queries. It’s bad at ad-hoc filtering, full-text search, and analytical aggregations. The standard fix is to project your DynamoDB writes into a database that’s good at those things - OpenSearch for search, Postgres for reporting, Snowflake for warehousing, Redis for fast read caches.

export async function handler(event: DynamoDBStreamEvent) {
  for (const record of event.Records) {
    const pk = record.dynamodb.Keys.pk.S
    if (!pk?.startsWith("PRODUCT#")) continue

    if (record.eventName === "REMOVE") {
      const oldImage = unmarshall(record.dynamodb.OldImage)
      await openSearch.delete({ index: "products", id: oldImage.productId })
      continue
    }

    const newImage = unmarshall(record.dynamodb.NewImage)
    await openSearch.index({
      index: "products",
      id: newImage.productId,
      document: {
        productId: newImage.productId,
        title: newImage.title,
        description: newImage.description,
        price: newImage.price,
        tags: newImage.tags,
      },
    })
  }
}

DynamoDB stays the source of truth. OpenSearch is a derived view that gets eventually-consistent updates. You query DynamoDB for “give me product by ID” and OpenSearch for “find products matching this query.”

This is the same shape as fan-out, narrower in scope. It’s also the ideal job for EventBridge Pipes - you can wire DynamoDB Streams → Pipes → OpenSearch without writing a Lambda at all, just a transform expression in the Pipe definition.

Three things to keep in mind here. First, schema drift: if you change the DynamoDB schema, the projection has to adapt - version your projection code and don’t assume old records have the same shape. Second, initial backfill: Streams only see writes after they’re enabled, so you need a one-time backfill (Scan + write) to populate the downstream from existing data before the Stream takes over. Third, eventual consistency: the downstream lags DynamoDB by seconds to minutes, so don’t read your own writes from the projection.

Pattern 3: Read-model projections (event sourcing)

The most powerful pattern, and the most underused. If you store events in DynamoDB - say, every change to an order’s state - you can build any number of read-side views by projecting the Stream into shaped read tables.

EventTable                  →   StreamLambda   →   OrderSummary table (one row per order)
(append-only)                                  →   CustomerStats table (aggregates)
                                               →   PendingOrdersIndex (sparse)

Each event in the Stream gets routed to one or more projection updaters. The projections themselves can live in the same DynamoDB table (as separate item types in your single-table design) or in a different table optimized for the read shape.

// Projection updater for OrderSummary
export async function handler(event: DynamoDBStreamEvent) {
  for (const record of event.Records) {
    if (record.eventName !== "INSERT") continue
    const evt = unmarshall(record.dynamodb.NewImage)
    if (!evt.pk?.startsWith("ORDER#") || !evt.sk?.startsWith("EVENT#")) continue

    switch (evt.eventType) {
      case "OrderPlaced":
        await OrderSummary.create({ orderId: evt.aggregateId, status: "pending", total: evt.payload.total }).go()
        break
      case "OrderShipped":
        await OrderSummary.update({ orderId: evt.aggregateId })
          .set({ status: "shipped", shippedAt: evt.timestamp })
          .go()
        break
      case "OrderCancelled":
        await OrderSummary.update({ orderId: evt.aggregateId })
          .set({ status: "cancelled", cancelledAt: evt.timestamp })
          .go()
        break
    }
  }
}

The event log is immutable. The projections are derivable. If you ever want a new read shape, write a new projection that reads the event log from the beginning and builds it. This is what makes event sourcing flexible - you’re not locked into the queries you imagined upfront.

The event sourcing pattern walks through this in full detail, including projection rebuilds and snapshot strategies.

Composing all three

These patterns aren’t exclusive. A single Stream subscriber can do fan-out, CDC, and projections all at once - or you can run multiple subscribers, each with a focused job.

The latter is usually cleaner. Lambda functions per concern:

  • index-search-projection - syncs to OpenSearch
  • notification-fanout - sends emails / push / Slack
  • analytics-counters - increments metrics in a side table
  • event-sourcing-projector - builds read models from event records

Each one filters the Stream at the consumer (or via Lambda event source filters) and ignores records it doesn’t care about. Failures in one don’t affect the others. You can deploy and scale them independently.

Stream choices

DynamoDB lets you configure what each record contains:

  • Keys only: just PK and SK. Cheapest, but you can’t see what changed without re-reading the item.
  • New image: full item after the write. Good for CDC and projections.
  • Old image: full item before the write. Useful for diffing, audit logs.
  • New and old images: both. Most expressive, most expensive.

Pick based on what your consumers need. “New and old” is the safe default; “keys only” is the right call when consumers will re-fetch the item anyway.

When to use Kinesis instead

DynamoDB Streams have a 24-hour retention window. Kinesis Data Streams (via the Kinesis Data Streams adapter for DynamoDB) gives you up to a year. You’d want this if:

  • Consumers can fall behind for more than 24 hours and need to catch up
  • You want to replay history into a new projection (rebuild a read model from scratch)
  • You have many parallel consumers and Stream’s per-shard throughput limits become a problem

For most use cases, the standard Streams + Lambda combo is fine. Move to Kinesis only when you’ve hit a real limit.

Common bugs

Streams are not exactly-once. Lambda batches can be retried; the same record can be delivered multiple times. Make all consumer side effects idempotent.

Don’t read the projection in the same request that wrote the source. The downstream lags DynamoDB - don’t write to DynamoDB and then immediately query OpenSearch, you’ll race.

Watch the consumer lag. If your Lambda is slow or failing, records pile up and eventually drop off the 24-hour retention. Monitor the iterator age metric. Anything close to 24h is an outage.

Handle REMOVE events explicitly. A REMOVE event has OldImage only, not NewImage. Code that assumes NewImage exists will throw on TTL deletes (see TTL patterns).

Don’t assume cross-partition ordering. Within one partition key, Stream order matches write order. Across partitions, there’s no ordering guarantee - don’t assume “all events for this user came in this order” if events are partitioned by something other than user.

Mental model

Treat the Stream as the spinal cord of your system. DynamoDB writes are the source of truth - everything else is a derivation that the Stream keeps in sync. Search indexes, analytics, notifications, projections - they’re all just functions of the Stream over time.

Once you’re thinking this way, you stop writing application code that does five things at once and start writing code that does one thing (write to DynamoDB) and trusts the Stream to handle the rest.


Streams are how a single-table DynamoDB design scales to a real system. Patterns at singletable.dev - especially event sourcing, analytics events, and IoT time-series - all assume Streams as the integration point. I’m building singletable.dev to make those integration points part of the schema, not an afterthought.

Tejovanth N

Tejovanth builds on DynamoDB in production: rasika.life, rekha.app, rrmstays. All single-table with ElectroDB.

LinkedIn codeculturecob.com

Related

Schema review

Want a second pair of eyes before you ship?

Async DynamoDB schema review. PK/SK design, GSI strategy, ElectroDB entity code. Fixed price, 5 business days.