Engineering

Why we put everything in one Postgres

Chat with unlimited history and search usually means a database plus a search cluster. Why Fluid keeps both in Postgres — and where that breaks.

· 5 min read

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:file and has: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 BY on 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.

ComponentStatusReasoning
PostgresRequiredStorage and search. The one hard dependency.
Socket.IORequired processRealtime, but an accelerator — HTTP writes work without it.
WorkerRequired processScheduled sends, reminders, retention, exports, email.
RedisOptionalOnly when running more than one app process, for pub/sub fan-out.
Object storageRequiredFiles have to live somewhere; MinIO counts.
Search clusterNonePostgres does it.
Message queueNoneThe 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.

FAQ

Questions people actually ask.

Can Postgres handle full-text search for a chat application?

Yes, comfortably at team-chat scale. Postgres provides tsvector, tsquery, GIN indexes and ranking, which supports operators like in:, from:, has:, is:pinned and date ranges over unlimited history. A hundred people writing for five years produces a few million messages, which Postgres handles without difficulty.

Why not use Elasticsearch for chat search?

It's a better search engine, and it's another service to run, tune, upgrade and keep in sync with the database. For software meant to be self-hosted by teams without a platform team, that cost outweighs the benefit until the corpus gets large or the relevance requirements get sophisticated.

What happens when Postgres full-text search isn't enough?

It becomes a ceiling rather than a wall. Because the data is ordinary relational data, adding an external search index later is an addition rather than a rewrite. The limits worth knowing are very large corpora, fuzzy or semantic relevance, some languages, and search-dominant workloads.

Does Fluid Chat need Redis?

Only when running more than one app or realtime process. With a single process, realtime events are delivered directly; once you scale out, Redis pub/sub fans events across processes so a message sent through one reaches clients connected to another.

Can I query my chat data with SQL?

If you self-host, yes — the messages, channels and memberships are ordinary relational tables in your own Postgres, so analytics that would otherwise require an export step or a vendor analytics tier are just queries.

Something not covered? Open an issue.

Boring infrastructure, on purpose.

One hard dependency, three processes, and a schema you can read. Clone it and see, or start on the free hosted version.