DynamoDB vs Postgres for Real-Time Chat: An Honest Comparison
Chat applications have specific database requirements: messages must arrive in order, pagination must be cursor-based (not offset), read receipts need to update efficiently without blocking message delivery, and fan-out (delivering one message to many participants) must be fast.
Neither DynamoDB nor Postgres is a complete chat infrastructure. Real-time delivery runs over WebSockets (or SSE), which neither database handles. But for storing and querying message data (the persistent layer behind the real-time layer), both work, with different tradeoffs.
What “chat infrastructure” actually means
Before comparing databases, it helps to separate the layers:
- Message storage: Persist messages, threads, reactions, read receipts. The database problem.
- Real-time delivery: Push messages to connected clients. WebSocket/SSE territory (AWS API Gateway WebSockets, Ably, Pusher, Socket.io).
- Fan-out: Deliver one message to N recipients efficiently. The coordination layer.
- Presence: Track who’s online. Redis territory.
DynamoDB and Postgres both handle layer 1. The comparison is: which stores and queries message data more naturally for chat access patterns?
The core chat access patterns
Chat is unusually access-pattern-stable. Almost every chat application needs the same queries:
- Load the N most recent messages in a conversation, newest-first
- Load messages before a cursor (infinite scroll backward)
- Mark messages as read (update read receipts per user per conversation)
- Fan-out: when a message is created, notify participants
- Get all conversations a user belongs to
- Get unread count per conversation per user
These are DynamoDB-shaped queries. Each one scopes to a small key space (conversation ID or user ID) and requires no cross-partition joins.
Where DynamoDB wins
Message ordering is natural
Messages in a conversation need chronological ordering. DynamoDB sort keys handle this exactly.
PK: CONVO#<convoId>
SK: MSG#<ulid> → message, sorted by creation time
ULIDs are lexicographically sortable by creation time. ScanIndexForward=false gives you the most recent messages first. Cursor pagination is sk < MSG#<cursorUlid>: trivial to implement and guaranteed consistent because ULIDs are monotonically increasing within a conversation.
// Load 50 messages before a cursor
const { data, cursor } = await MessageEntity.query
.byConvo({ convoId: "c_01" })
.lt({ messageId: cursorUlid })
.go({ order: "desc", limit: 50 })
In Postgres, you’d use ORDER BY created_at DESC LIMIT 50 WHERE created_at < $cursor. Both work. But DynamoDB’s sort key approach means the index is the ordering, not an auxiliary B-tree. At scale, that’s fewer I/O operations per message page.
Read receipts without contention
Read receipts in group conversations are a write-contention problem. When a message is sent to a 200-person conversation, each participant potentially updates their read state. Storing read state per user per conversation as separate DynamoDB items avoids the lock contention you’d get updating a single shared row in Postgres.
PK: USER#<userId>
SK: CONVO#<convoId>
Attributes: { lastReadMessageId, lastReadAt }
Each user’s read state is an independent item. No locking, no contention between users marking messages read simultaneously. In Postgres, concurrent updates to the same conversation’s read receipt aggregate can serialize in high-concurrency scenarios.
DynamoDB Streams for fan-out
When a message is written to DynamoDB, a Streams event fires. A Lambda function reads the event, looks up conversation participants (one Query on the participants index), and pushes the message to each participant’s WebSocket connection.
Message write → DynamoDB Streams → Lambda fan-out function
↓
API Gateway WebSocket connections
(one per active participant)
This architecture decouples message storage from message delivery. The Lambda handles the fan-out asynchronously; the write latency doesn’t include delivery time. At scale, fan-out to thousands of participants per message is handled by Lambda concurrency.
The equivalent in Postgres requires a pub/sub mechanism (LISTEN/NOTIFY, or a separate queue). Postgres NOTIFY is limited to small payloads and connection-count ceilings. For high-participant-count conversations, the Lambda approach is more reliable.
Unread counts per conversation
PK: USER#<userId>
SK: CONVO#<convoId>
Attributes: { unreadCount, lastReadMessageId }
Unread count is an attribute on the user-conversation join record. Increment it with a DynamoDB UpdateItem conditional expression when a message arrives (fan-out Lambda), decrement (or reset to 0) when the user opens the conversation. One atomic operation per participant per message, no reads required.
In Postgres, you’d either store the count (same approach, works fine) or compute it from the messages table with a COUNT(*) WHERE id > lastReadId, which is a full index scan per conversation per load if not materialized.
Where Postgres wins
Complex queries across messages
“Show me all messages that contain the word ‘urgent’ sent in the last 7 days by users in team X.” In Postgres: full-text search with tsvector, a WHERE clause, and a JOIN. In DynamoDB: impossible without full table scan unless you’ve pre-built that exact query into an index.
Chat search almost always delegates to a dedicated search layer (Elasticsearch, Algolia, Typesense) regardless of the storage database. But if you need ad-hoc analytical queries about message patterns (most active conversations, message volume by user, response time distributions), Postgres handles them directly.
Thread replies and complex reply trees
Flat message sequences (one conversation, chronological messages) are DynamoDB-shaped. Thread replies that can themselves have replies (Reddit-style nested threads) need hierarchical querying that gets awkward with DynamoDB’s key structure.
Postgres’s recursive CTEs (WITH RECURSIVE) handle threaded conversations naturally:
WITH RECURSIVE thread AS (
SELECT * FROM messages WHERE id = $rootMessageId
UNION ALL
SELECT m.* FROM messages m
JOIN thread t ON m.parent_id = t.id
)
SELECT * FROM thread ORDER BY depth, created_at;
DynamoDB can handle this with an adjacency list pattern and careful sort key design, but it requires more planning and doesn’t handle unlimited nesting depth elegantly. For simple two-level threads (message + replies), DynamoDB works fine. For deep trees, Postgres is cleaner.
Reactions and message metadata
Modern chat has reactions (emoji responses), edits, deletions, pinned messages, attachments. Each of these is a row in Postgres (reactions table, message_edits table). In DynamoDB, each is either a separate record in the partition or a nested attribute on the message item.
Reactions in DynamoDB as nested maps work until a message goes viral. The 400KB item size limit becomes a constraint sooner than you’d think, since each reaction adds a user ID and timestamp per occurrence. Separate reaction records in the message’s partition work better but require careful key design. Postgres handles reactions as a conventional table with no size concerns.
Real moderation tooling
Content moderation requires querying patterns: “show me all messages from user X across all conversations in the last 24 hours,” “find all messages that match this regular expression across all conversations.” These are full-table analytical queries. In Postgres: SELECT * FROM messages WHERE user_id = $userId AND created_at > $yesterday. In DynamoDB: GSI on user ID with sort key on timestamp, or a full scan.
If you’re building a platform with moderation requirements (user-generated content, community features, compliance needs), Postgres gives you the query flexibility to build the moderation tooling. DynamoDB requires you to anticipate which moderation queries you’ll need and build GSIs for each.
The scorecard
| Chat concern | DynamoDB | Postgres |
|---|---|---|
| Message ordering within a conversation | ✅ | ✅ |
| Cursor-based pagination | ✅ | ✅ |
| Read receipts without contention | ✅ | ⚠️ |
| Fan-out via Streams + Lambda | ✅ | ⚠️ (needs pub/sub) |
| Unread count per user per conversation | ✅ | ✅ |
| Full-text search across messages | ❌ (delegate to search) | ✅ (tsvector) |
| Nested thread replies | ⚠️ | ✅ (recursive CTE) |
| Reactions at scale (no size limits) | ⚠️ | ✅ |
| Cross-conversation analytics / moderation | ❌ | ✅ |
| Real-time delivery (WebSockets) | ❌ (not a DB concern) | ❌ (not a DB concern) |
| Serverless / Lambda architecture | ✅ | ⚠️ |
What I’d actually recommend
Use DynamoDB for chat if:
- You’re on a serverless/Lambda stack and want DynamoDB Streams for fan-out
- Your chat model is flat (conversations + messages, no deep threads)
- You can delegate search to a dedicated search layer
- Your access patterns are well-defined: recent messages per conversation, read state per user, unread counts
- You’re building embedded chat (messages within a SaaS app, support widget, etc.) rather than a standalone chat platform
Use Postgres for chat if:
- Your chat has complex reply trees, rich reactions, or editorial features
- You need ad-hoc moderation queries and content analysis
- Your team knows SQL and you don’t want to manage a separate search layer
- You’re building a chat platform where message metadata complexity will grow over time
Neither is a complete solution. Real-time delivery (WebSockets, presence, typing indicators) runs over a separate layer regardless of your database choice. Choose the database for what it actually handles (persistent message storage and query) and choose the real-time layer (API Gateway WebSockets, Ably, Pusher) separately.
For production chat at scale, the common architecture is: DynamoDB for message persistence + Lambda fan-out from Streams + a WebSocket layer (API Gateway or a dedicated service) + Elasticsearch for search. Postgres fits in a simpler setup where you’re not on Lambda and you want to keep the stack uniform.
The Chat/Messaging pattern shows the full DynamoDB schema with access patterns, read receipt design, and ElectroDB entity definitions.
If you’re building a SaaS application with embedded chat, the DynamoDB vs Postgres for SaaS comparison covers the broader multi-tenant tradeoffs. The Chat/Messaging pattern shows the complete DynamoDB schema for the access patterns covered here.