Skip to content

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 PatternOperationNotes
AP1Get conversation metadataGetItemConversation name, type, last message preview
AP2List conversations for a user (most recent first)Query (GSI1)Inbox/sidebar view
AP3List participants in a conversationQueryGroup member list
AP4Get paginated messages in a conversation (newest first)QueryChat window - load 50 at a time
AP5Get a specific messageGetItemDeep link to a message
AP6Get unread count for a user in a conversationDerivedCompare lastReadAt vs message timestamps
AP7Send a messageTransactWriteItemsWrite 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
Chat/Messaging entity diagram showing Conversation, Participant, Message relationships
3 entities · 1 GSI · messages paginated by ULID, conversations sorted by activity

Table design

Primary key structure

EntityPKSKPurpose
ConversationCONVO#<convoId>#METADATAConversation details + last message preview
ParticipantCONVO#<convoId>PARTICIPANT#<userId>User membership + last-read timestamp
MessageCONVO#<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

GSIPKSKPurpose
GSI1USER#<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.

Chat/Messaging DynamoDB schema: Entity, PK, SK, GSI columns for Conversation, Participant, Message
PK/SK structure for Conversation, Participant, and Message — all three share the conversation partition

Sample data

pkskgsi1pkgsi1skEntity Data
CONVO#c_01#METADATA--{ name: "Project Chat", type: "group", lastMessageBody: "Sounds good!", lastMessageSender: "u_02", lastMessageAt: "2026-04-15T10:30:00Z" }
CONVO#c_01PARTICIPANT#u_01USER#u_01CONVO#c_01{ role: "admin", joinedAt: "2026-04-01T...", lastReadAt: "2026-04-15T10:25:00Z" }
CONVO#c_01PARTICIPANT#u_02USER#u_02CONVO#c_01{ role: "member", joinedAt: "2026-04-01T...", lastReadAt: "2026-04-15T10:30:00Z" }
CONVO#c_01MSG#01HW1A2B3C...--{ senderId: "u_01", body: "Can we ship by Friday?", type: "text" }
CONVO#c_01MSG#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_02PARTICIPANT#u_01USER#u_01CONVO#c_02{ role: "member", lastReadAt: "2026-04-15T09:00:00Z" }
CONVO#c_02PARTICIPANT#u_03USER#u_03CONVO#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.

Join the waitlist →


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.

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.