Skip to content

DynamoDB Single-Table Pattern: Notifications and Inbox

Every user-facing product eventually needs a notification inbox: “Alice commented on your post,” “Your order has shipped,” “Bob mentioned you in a thread.” The schema looks simple until you need unread counts without full scans, efficient “mark all read,” and fan-out delivery to thousands of users from a single event.

Access patterns

#Access PatternOperationNotes
AP1Get 20 most recent notifications for a userQuery (newest first)Inbox load
AP2Get unread count for a userGetItemBadge counter
AP3Mark a specific notification as readUpdateItemClick to dismiss
AP4Get only unread notificationsQuery (GSI1)Unread filter view
AP5Mark all notifications as readUpdateItem on InboxMeta + background job”Mark all read” button

Five access patterns. Two entity types: Notification and InboxMeta. One GSI (sparse).

Entities

Notification stores each notification event. Lives under USER#<userId> so all of a user’s notifications are in the same partition. One Query returns the inbox.

InboxMeta is a single item per user that stores the unread count. It’s a counter maintained via atomic UpdateItem increments. Reading the badge count is a GetItem, not a count query across all notification items.

Why separate InboxMeta? Without it, getting the unread count requires either counting all items in the user’s partition with read=false (a full partition scan) or a GSI query. A dedicated counter item is O(1) and costs one read unit.

Table design

Primary key structure

EntityPKSKGSI1 PKGSI1 SK
NotificationUSER#<userId>NOTIF#<ulid>UNREAD#<userId> (if unread)NOTIF#<ulid>
InboxMetaUSER#<userId>#INBOX

Notification and InboxMeta share the USER#<userId> partition. An ElectroDB collection can fetch both in one Query (the user’s recent notifications + their unread count meta) for the initial inbox load.

GSI design: sparse index for unread filtering

GSIPKSKPurpose
GSI1UNREAD#<userId>NOTIF#<ulid>Unread notifications only (AP4)

The GSI1 partition key attribute (gsi1pk = UNREAD#<userId>) is only set when read=false. When a notification is marked as read, UpdateItem removes the gsi1pk and gsi1sk attributes, and the item automatically falls out of GSI1. This is the sparse index pattern.

Result: GSI1 contains only unread notifications. Querying GSI1(pk=UNREAD#<userId>) returns exactly the user’s unread items without a filter expression. The GSI shrinks as notifications are read.

Sample data

pkskgsi1pkgsi1skAttributes
USER#u_alice#INBOX{ unreadCount: 2, lastNotifAt: "2026-05-19T..." }
USER#u_aliceNOTIF#01HV...UNREAD#u_aliceNOTIF#01HV...{ type: "comment", actorId: "u_bob", read: false, ... }
USER#u_aliceNOTIF#01HU...UNREAD#u_aliceNOTIF#01HU...{ type: "like", actorId: "u_carol", read: false, ... }
USER#u_aliceNOTIF#01HT...{ type: "follow", actorId: "u_dave", read: true, readAt: "...", ... }

Alice has 3 notifications total, 2 unread. The read notification (NOTIF#01HT...) has no GSI1 attributes, so it’s absent from the unread index.

Resolving each access pattern

AP1 — Get 20 most recent notifications:

Query(pk=USER#u_alice, sk begins_with NOTIF#, ScanIndexForward=false, limit=20)

ULID sort keys are chronologically sortable. Newest-first with ScanIndexForward=false.

AP2 — Get unread count:

GetItem(pk=USER#u_alice, sk=#INBOX)

Returns { unreadCount: 2 }. One read unit. O(1).

AP3 — Mark a notification as read:

// Two operations:
// 1. Update the notification item
await NotificationEntity.update({
  userId: "u_alice",
  notifId: "01HV...",
}).set({
  read: true,
  readAt: new Date().toISOString(),
  unreadUserId: undefined,  // removes the GSI attribute → falls out of GSI1
}).go()

// 2. Decrement the unread counter
await dynamodb.updateItem({
  Key: { pk: "USER#u_alice", sk: "#INBOX" },
  UpdateExpression: "ADD unreadCount :dec",
  ExpressionAttributeValues: { ":dec": { N: "-1" } },
  ConditionExpression: "unreadCount > :zero",
  ExpressionAttributeValues: { ":dec": { N: "-1" }, ":zero": { N: "0" } },
})

Use a ConditionExpression on the counter decrement to prevent it going negative (race condition protection).

AP4 — Get unread notifications only:

Query(GSI1, gsi1pk=UNREAD#u_alice, ScanIndexForward=false)

Returns only items in the sparse GSI, guaranteed to all be unread.

AP5 — Mark all as read:

This is the hard one. “Mark all read” on a large inbox requires touching every unread notification individually. The right approach:

  1. UpdateItem on InboxMeta to reset unreadCount = 0 (this clears the badge immediately)
  2. Background job (Lambda or SQS consumer) scans GSI1 for the user’s unread items and marks each as read

The UX sees the badge clear immediately (step 1). The cleanup runs asynchronously. For most inboxes (< 500 unread), step 2 completes in under a second. For power users with thousands of unread notifications, the cleanup can take a few seconds. That’s fine, since the badge is already cleared.

// Step 1: clear the counter immediately
await InboxMetaEntity.update({ userId: "u_alice" })
  .set({ unreadCount: 0 })
  .go()

// Step 2 (async): page through unread items and clear GSI attributes
let cursor: string | undefined = undefined
do {
  const { data, cursor: next } = await NotificationEntity.query
    .unread({ unreadUserId: "u_alice" })
    .go({ cursor, limit: 25 })

  await Promise.all(data.map(n =>
    NotificationEntity.update({ userId: n.userId, notifId: n.notifId })
      .set({ read: true, readAt: now, unreadUserId: undefined })
      .go()
  ))

  cursor = next
} while (cursor)

ElectroDB entity definitions

export const NotificationEntity = new Entity({
  model: { entity: "notification", version: "1", service: "app" },
  attributes: {
    userId:    { type: "string", required: true },
    notifId:   { type: "string", required: true }, // ULID
    type: {
      type: "string",
      required: true,
      enum: ["comment", "like", "follow", "mention", "system"],
    },
    actorId:   { type: "string" },       // who triggered the notification
    targetId:  { type: "string" },       // the entity (post, comment, etc.)
    targetType: { type: "string" },      // "post" | "comment" | etc.
    read:      { type: "boolean", required: true, default: false },
    readAt:    { type: "string" },
    payload:   { type: "map" },          // type-specific data
    createdAt: {
      type: "string",
      required: true,
      default: () => new Date().toISOString(),
      readOnly: true,
    },
    // Sparse GSI attributes — only written when read=false
    unreadUserId: {
      type: "string",
      // Set on create, removed on read via watch + computed attribute
      get: (_, item) => !item.read ? item.userId : undefined,
      watch: ["read"],
    },
  },
  indexes: {
    // AP1 + AP5: Get all/recent notifications for a user
    byUser: {
      pk: { field: "pk",     composite: ["userId"],   template: "USER#${userId}" },
      sk: { field: "sk",     composite: ["notifId"],  template: "NOTIF#${notifId}" },
    },
    // AP4: Get only unread notifications (sparse GSI)
    unread: {
      index: "GSI1",
      pk: { field: "gsi1pk", composite: ["unreadUserId"], template: "UNREAD#${unreadUserId}" },
      sk: { field: "gsi1sk", composite: ["notifId"],      template: "NOTIF#${notifId}" },
    },
  },
}, { client, table })

Fan-out: delivering one event to many users

When a user publishes a post, all followers should receive a notification. This is the fan-out problem.

DynamoDB Streams approach (recommended):

  1. Event is written to a Post or Event table
  2. DynamoDB Streams triggers a Lambda
  3. Lambda queries the followers list for the user
  4. Lambda writes one Notification item per follower + increments each follower’s InboxMeta counter
// Fan-out Lambda
const followers = await getFollowers(authorId)  // Query followers GSI

await Promise.all(followers.map(followerId =>
  Promise.all([
    // Create notification
    NotificationEntity.put({
      userId: followerId,
      notifId: ulid(),
      type: "new_post",
      actorId: authorId,
      targetId: postId,
      targetType: "post",
      read: false,
      payload: { postTitle },
    }).go(),
    // Increment counter
    dynamodb.updateItem({
      Key: { pk: `USER#${followerId}`, sk: "#INBOX" },
      UpdateExpression: "ADD unreadCount :inc SET lastNotifAt = :now",
      ExpressionAttributeValues: {
        ":inc": { N: "1" },
        ":now": { S: new Date().toISOString() },
      },
    }),
  ])
))

For large follower counts (>1,000), use SQS + Lambda concurrency to fan out in parallel batches rather than a single Lambda execution. At Slack/Twitter scale, fan-out is a dedicated infrastructure problem. For most applications, a Lambda writing to DynamoDB directly handles thousands of recipients without issue.

Why this design

The sparse GSI eliminates unread-count queries. The alternative is storing read on each notification and counting read=false items on every inbox load. At 500 notifications per user, that’s a full partition scan with a filter expression. Expensive and slow. The sparse GSI + atomic counter approach is O(1) for both the badge read and the unread list query.

InboxMeta as a dedicated counter. Storing unreadCount on a User entity is tempting but wrong. The User entity is written frequently (profile updates, last-active timestamps) and reading it to get the badge count would return far more data than needed. InboxMeta is a small, focused item with one job: the counter.

ULID for notification IDs. ULID sort keys give chronological ordering within the user’s partition for free (ScanIndexForward=false returns newest first). UUID or random IDs would require a separate createdAt sort key or a GSI to order by time.

Payload as a map attribute. Each notification type has different metadata (a comment notification needs the comment text; a follow notification just needs the actor). Storing type-specific data in a payload map attribute avoids needing different columns per notification type. Your application code knows which fields to read based on the type attribute.

Design this visually → coming soon

The sparse GSI pattern (some items write to the index, others don’t) is one of the harder things to explain in text. A diagram showing which items appear in GSI1 and which don’t would make this immediate. That’s what singletable.dev is building.

Join the waitlist →


Part of the SingleTable pattern library.

Tejovanth N

These patterns come from real apps - rasika.life, rekha.app, rrmstays - all running single-table DynamoDB 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.