DynamoDB Single-Table Pattern: Chat/Messaging
Chat looks simple until you’re designing the schema. Messages need pagination. Conversation lists need sorting by most recent activity. Unread counts need to be fast. And sending a message is actually a multi-item write - the message record plus an update to the conversation’s last message metadata.
This pattern handles 1:1 direct messages and group conversations in a single-table design. The same schema works for Slack-style channels, customer support chat, or in-app messaging.
Access patterns
| # | Access Pattern | Operation | Notes |
|---|---|---|---|
| AP1 | Get conversation metadata | GetItem | Conversation name, type, last message preview |
| AP2 | List conversations for a user (most recent first) | Query (GSI1) | Inbox/sidebar view |
| AP3 | List participants in a conversation | Query | Group member list |
| AP4 | Get paginated messages in a conversation (newest first) | Query | Chat window - load 50 at a time |
| AP5 | Get a specific message | GetItem | Deep link to a message |
| AP6 | Get unread count for a user in a conversation | Derived | Compare lastReadAt vs message timestamps |
| AP7 | Send a message | TransactWriteItems | Write message + update conversation lastMessage |
Seven access patterns. Three entity types. One table, one GSI.
Entities
- Conversation: the chat room or DM thread
- Participant: a user’s membership in a conversation, including their last-read timestamp
- Message: an individual message within a conversation

Table design
Primary key structure
| Entity | PK | SK | Purpose |
|---|---|---|---|
| Conversation | CONVO#<convoId> | #METADATA | Conversation details + last message preview |
| Participant | CONVO#<convoId> | PARTICIPANT#<userId> | User membership + last-read timestamp |
| Message | CONVO#<convoId> | MSG#<messageId> | Individual messages, sorted by ULID |
The conversation metadata, all participants, and all messages share the CONVO#<convoId> partition key. Opening a chat window is a single Query with different SK prefixes.
The MSG#<ulid> sort key gives chronological ordering and direct lookup - same principle as the E-Commerce Orders pattern. Paginate with ScanIndexForward: false and Limit: 50 to get the newest messages first. More on ULID vs UUID for sort keys here.
lastMessageBody, lastMessageSender, and lastMessageAt are stored directly on the conversation record. This means AP2 (list conversations for a user) can show a message preview without querying the messages for each conversation. The tradeoff: every new message triggers an update to the conversation record.
GSI design
| GSI | PK | SK | Purpose |
|---|---|---|---|
| GSI1 | USER#<userId> | CONVO#<convoId> | List all conversations for a user |
GSI1 indexes the Participant entity. When a user opens their inbox, Query GSI1 with PK = USER#<userId> to get all their conversations. The conversation metadata (including last message preview) is projected into the GSI.
Sorting conversations by recent activity is the one limitation here. GSI1’s SK is the convoId, not the lastMessageAt timestamp. To sort by recent activity, you’d need to either update the GSI SK on every message (expensive) or sort client-side after the query. For most apps with < 100 conversations per user, client-side sorting is fine. At scale, add a GSI2 with SK = lastMessageAt on the Participant record, updated on each new message.

Sample data
| pk | sk | gsi1pk | gsi1sk | Entity Data |
|---|---|---|---|---|
CONVO#c_01 | #METADATA | - | - | { name: "Project Chat", type: "group", lastMessageBody: "Sounds good!", lastMessageSender: "u_02", lastMessageAt: "2026-04-15T10:30:00Z" } |
CONVO#c_01 | PARTICIPANT#u_01 | USER#u_01 | CONVO#c_01 | { role: "admin", joinedAt: "2026-04-01T...", lastReadAt: "2026-04-15T10:25:00Z" } |
CONVO#c_01 | PARTICIPANT#u_02 | USER#u_02 | CONVO#c_01 | { role: "member", joinedAt: "2026-04-01T...", lastReadAt: "2026-04-15T10:30:00Z" } |
CONVO#c_01 | MSG#01HW1A2B3C... | - | - | { senderId: "u_01", body: "Can we ship by Friday?", type: "text" } |
CONVO#c_01 | MSG#01HW1D4E5F... | - | - | { senderId: "u_02", body: "Sounds good!", type: "text" } |
CONVO#c_02 | #METADATA | - | - | { name: null, type: "direct", lastMessageBody: "See you tomorrow", lastMessageSender: "u_01" } |
CONVO#c_02 | PARTICIPANT#u_01 | USER#u_01 | CONVO#c_02 | { role: "member", lastReadAt: "2026-04-15T09:00:00Z" } |
CONVO#c_02 | PARTICIPANT#u_03 | USER#u_03 | CONVO#c_02 | { role: "member", lastReadAt: "2026-04-15T09:00:00Z" } |
Resolving each access pattern
AP1 - Get conversation metadata:
GetItem(pk=CONVO#c_01, sk=#METADATA)
One read. Returns name, type, and last message preview.
AP2 - List conversations for a user:
Query(GSI1, gsi1pk=USER#u_01)
One query on GSI1. Returns all conversations the user participates in. Sort client-side by lastMessageAt for “most recent first” ordering.
AP3 - List participants in a conversation:
Query(pk=CONVO#c_01, sk begins_with PARTICIPANT#)
One query. Returns all members.
AP4 - Get paginated messages (newest first):
Query(pk=CONVO#c_01, sk begins_with MSG#, ScanIndexForward=false, Limit=50)
One query. Returns the 50 newest messages. For the next page, use the last message’s SK as ExclusiveStartKey. ULIDs make this pagination natural - they’re chronologically sorted, so “older messages” means “lower ULID values.”
AP5 - Get a specific message:
GetItem(pk=CONVO#c_01, sk=MSG#01HW1A2B3C...)
One read. The ULID is the message ID.
AP6 - Unread count:
This is derived, not stored. Compare the participant’s lastReadAt with message timestamps:
Query(pk=CONVO#c_01, sk begins_with MSG#,
FilterExpression: createdAt > :lastReadAt)
Or more efficiently: since ULIDs encode timestamps, you can compute the ULID prefix for lastReadAt and use a range query:
Query(pk=CONVO#c_01, sk > MSG#<ulidForLastReadAt>)
Count the results. This is the unread count.
AP7 - Send a message:
await client.transactWrite({
TransactItems: [
{ Put: { /* new Message item */ } },
{ Update: {
/* update Conversation #METADATA: lastMessageBody, lastMessageSender, lastMessageAt */
}},
{ Update: {
/* update sender's Participant record: lastReadAt = now */
}},
]
});
Three operations in one transaction. The message is created, the conversation preview is updated, and the sender’s read receipt is advanced - all atomically.
ElectroDB entity definitions
export const ConversationEntity = new Entity({
model: { entity: "conversation", version: "1", service: "chat" },
attributes: {
convoId: { type: "string", required: true },
name: { type: "string" },
type: { type: "string", required: true, enum: ["direct", "group"] },
lastMessageBody: { type: "string" },
lastMessageSender: { type: "string" },
lastMessageAt: { type: "string" },
createdAt: {
type: "string", required: true,
default: () => new Date().toISOString(), readOnly: true,
},
updatedAt: {
type: "string", required: true,
default: () => new Date().toISOString(),
set: () => new Date().toISOString(), watch: "*",
},
},
indexes: {
primary: {
pk: { field: "pk", composite: ["convoId"], template: "CONVO#${convoId}" },
sk: { field: "sk", composite: [], template: "#METADATA" },
},
},
}, { client, table }); Why this design
The last-message denormalization is the key decision here. Without it, showing a conversation list with message previews requires one query per conversation - an N+1 problem. With it, the conversation list is a single GSI query with all preview data included. The cost is one extra UpdateItem per message sent. This is the same tradeoff that drives FeedItem denormalization in the Social Media Feed pattern.
Messages are not nested on the conversation record. A chat can have thousands of messages. Nesting them would blow past the 400KB item limit immediately. Separate items with ULID sort keys give unlimited message history with efficient pagination.
Read receipts are per-participant, not per-message. Storing a “read” flag on each message would require updating every unread message when a user opens the chat. Instead, lastReadAt on the Participant record is a single timestamp - compare it to message timestamps to derive unread state.
DynamoDB doesn’t support full-text search. If you need “search messages containing X,” stream messages to OpenSearch via DynamoDB Streams. The DynamoDB schema handles all the real-time access patterns; search is a separate concern.
Design this visually → coming soon
The unread count derivation and the send-message transaction are the two decisions that trip up most implementations. Seeing them laid out visually - with the partition structure clear and the GSI connections explicit - makes the tradeoffs obvious before you write any code. That’s what I’m building at singletable.dev.
Pattern #4 of 10 in the SingleTable pattern library.
This is pattern #4 in the singletable.dev pattern library. Previously: Social Media Feed - adjacency lists, fan-out writes, and timeline pagination. Next up: IoT Time-Series - high-volume device readings, TTL-based data lifecycle, and pre-computed aggregations.