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 Pattern | Operation | Notes |
|---|---|---|---|
| AP1 | Get 20 most recent notifications for a user | Query (newest first) | Inbox load |
| AP2 | Get unread count for a user | GetItem | Badge counter |
| AP3 | Mark a specific notification as read | UpdateItem | Click to dismiss |
| AP4 | Get only unread notifications | Query (GSI1) | Unread filter view |
| AP5 | Mark all notifications as read | UpdateItem 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
| Entity | PK | SK | GSI1 PK | GSI1 SK |
|---|---|---|---|---|
| Notification | USER#<userId> | NOTIF#<ulid> | UNREAD#<userId> (if unread) | NOTIF#<ulid> |
| InboxMeta | USER#<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
| GSI | PK | SK | Purpose |
|---|---|---|---|
| GSI1 | UNREAD#<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
| pk | sk | gsi1pk | gsi1sk | Attributes |
|---|---|---|---|---|
USER#u_alice | #INBOX | — | — | { unreadCount: 2, lastNotifAt: "2026-05-19T..." } |
USER#u_alice | NOTIF#01HV... | UNREAD#u_alice | NOTIF#01HV... | { type: "comment", actorId: "u_bob", read: false, ... } |
USER#u_alice | NOTIF#01HU... | UNREAD#u_alice | NOTIF#01HU... | { type: "like", actorId: "u_carol", read: false, ... } |
USER#u_alice | NOTIF#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:
UpdateItemon InboxMeta to resetunreadCount = 0(this clears the badge immediately)- 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):
- Event is written to a
PostorEventtable - DynamoDB Streams triggers a Lambda
- Lambda queries the followers list for the user
- 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.
Part of the SingleTable pattern library.