The standard architecture for a chat product with real search is a database for the messages and a search cluster for finding them. It's a sensible design, it's what most of the category does, and Fluid Chat deliberately doesn't do it. Everything — messages, channels, memberships, and the full-text search index — lives in one Postgres.
This post is the reasoning, including the part where that choice eventually stops being the right one.
The constraint that drove it
Fluid is meant to be self-hosted by teams without a platform team. That single requirement does more architectural work than any performance target, because it turns every dependency into a question: is this worth someone's Sunday?
A search cluster is a real commitment. It needs memory, it needs tuning, it has its own upgrade path, it fails in ways that are unrelated to your application, and it introduces a consistency problem — the index and the database can disagree, and reconciling them is a job someone has to own.
What Postgres full-text search actually gives you
The reason this works at all is that Postgres's full-text search is genuinely good — tsvector, tsquery, GIN indexes, ranking. It is not a toy LIKE query wearing a hat.
Everything Fluid's search does is expressible as SQL against indexed columns:
in:#channel— a join and a filter on channel.from:@person— a filter on author.has:fileandhas:link— flags computed at write time, indexed.is:pinned— a boolean.before:,after:,during:— range predicates on a timestamp.- Sort by recency or relevance —
ORDER BYon a timestamp or a rank function.
Combine those with a text match and you have the search UX people expect from a chat product, over unlimited history, with no second system involved. The operators aren't a reduced set — they're the ones that actually get used.
The four things you get for free
1. The index cannot drift
When the search index lives in the same transaction as the write, there's no window where a message exists but isn't findable. No reindex jobs, no “search is a few minutes behind,” no queue to monitor. This is a category of bug that simply doesn't exist here.
2. Backup is one dump
pg_dump plus your object store is the entire backup strategy, and a restore brings back a fully searchable workspace. With a separate search cluster you either back up two systems consistently or plan to rebuild the index on restore — and that rebuild happens on the worst day you're having.
3. You can ask questions with SQL
Self-hosted, your chat history is a queryable relational database. Which channels went quiet last quarter, who's actually in every conversation, how message volume tracks against releases — all of it is a query away, without an export step or a vendor analytics tier.
4. Managed hosting is everywhere
Every cloud provider offers managed Postgres at every price point, with automated backups and point-in-time recovery. The moment your architecture requires a search cluster too, that convenient $15/month managed database becomes a $15/month database plus a cluster you're operating yourself.
Where this choice stops working
An architecture post without a limits section is marketing. Here are the real ones.
- Very large corpora. At tens of millions of messages, a dedicated search engine will outperform Postgres full-text search on complex queries. Postgres holds up much further than people expect, but it doesn't hold up forever.
- Sophisticated relevance. Fuzzy matching, typo tolerance, synonym expansion, semantic search — these are where dedicated engines genuinely win, and Postgres's ranking is comparatively blunt.
- Language coverage. Postgres text search configurations are good for many languages and weaker for some, particularly ones where tokenisation is hard.
- Search-heavy workloads. If search is the dominant query pattern rather than an occasional one, giving it its own system starts to earn its keep.
For a team chat workload, the corpus is smaller than intuition suggests. A hundred people writing steadily for five years produces a few million messages. Postgres is entirely comfortable there. And crucially, this is a ceiling, not a wall — the schema is normal relational data, so bolting on an external index later is an addition rather than a rewrite.
The rest of the stack, by the same rule
Every other dependency got the same question, and most of them lost.
| Component | Status | Reasoning |
|---|---|---|
| Postgres | Required | Storage and search. The one hard dependency. |
| Socket.IO | Required process | Realtime, but an accelerator — HTTP writes work without it. |
| Worker | Required process | Scheduled sends, reminders, retention, exports, email. |
| Redis | Optional | Only when running more than one app process, for pub/sub fan-out. |
| Object storage | Required | Files have to live somewhere; MinIO counts. |
| Search cluster | None | Postgres does it. |
| Message queue | None | The worker polls. It's fine. |
That Redis row is the pattern in miniature. Redis is genuinely necessary the moment you run two app processes — otherwise a message sent through process A never reaches a client connected to process B. So it's there, and it's optional, and single-process deployments never think about it.
One database. Read the schema if you don't believe us.
The whole thing is open source. Clone it, run docker compose up, and go look at what search actually does.
The general principle
Boring infrastructure is a feature when your users are the people operating it. Every service you don't require is a category of failure your users never debug, a line of documentation nobody has to read, and a reason someone finishes their self-hosted install instead of abandoning it halfway.
Postgres does more than most architectures give it credit for. Full-text search is the obvious case, but it's also perfectly good at job queues, JSON documents, and half the things people reach for a specialised system to do. Start with one database. Add the second thing when it's genuinely earned, and not a moment earlier.