How Smuves made search 200 times faster with Typesense
Searching across 70,000 HubSpot records inside Smuves used to take 20-30 seconds. Sometimes it timed out entirely. Today, the same search returns in under 40 milliseconds. That is roughly 200 times faster, and it was not the result of better indexes or a faster server. It was the result of accepting that Postgres, as much as we love it, is not the right tool for live search over large datasets.
This post is the story of that rebuild. What broke, what we tried before giving up on the old approach, why we ended up putting a search engine in front of Postgres even though every instinct said do not add more infrastructure, and what it took to make the whole thing work in production.
What was actually broken
For most of the last year, Smuves ran entirely on Supabase. Postgres stored every HubSpot page, blog post, landing page, and HubDB row we fetched from our users portals. It also handled every search, every filter, every sort. One database, one source of truth, no moving parts. For a product at our stage, that simplicity was a feature.
Then users started connecting real portals. Not test portals with fifty pages. Real agency portals with 30,000 pages, 70,000 blog posts, HubDB tables with a hundred thousand rows each. The kind of content volume that sounds abstract until someone opens the Smuves dashboard and clicks a filter and the page just sits there. Spinning.
Searches and filters across a large content type took anywhere from 20 to 30 seconds. Sorting by date on a 70,000 post collection was particularly painful. We had indexes. We had generated columns for the most common filter fields. We had done the Postgres homework. The queries were optimized. And still, the experience was unacceptable.
When we dug into why, the problem was not any one bad query. It was the nature of what users do inside a bulk editor. They do not run a single search and stop. They type a few characters, see results, adjust a filter, toggle sort order, scroll, refine, and repeat. Every one of those actions hits the database. Postgres, no matter how well indexed, reads data from disk, and disk reads have a floor on how fast they can go.
We had hit that floor.
What we tried before the rewrite
Before we even considered a search engine, we spent a couple of weeks tuning Postgres harder. We added more generated columns. We tried GIN indexes with jsonb_path_ops on the raw HubSpot payload. We partitioned one of the larger tables by portal ID. We set up read replicas so search queries would not compete with writes.
All of it helped. None of it was enough.
The issue was not Postgres being slow at any single thing. Postgres is excellent at what it does. The issue was that a bulk editor is essentially a live query interface over a huge dataset, and a live query interface wants data in memory, not on disk. We were asking Postgres to do a job it was not designed for, and no amount of index tuning was going to close the gap between disk reads and RAM reads.
That is the core insight we wish we had accepted two months earlier than we did. If you are building something where users are constantly searching, filtering, and sorting over large data, you are going to need something that keeps the searchable part in memory. Not eventually. From the start.
Why Typesense
The shortlist was Typesense, Meilisearch, and Elasticsearch. All three are open source search engines that hold data in memory instead of on disk, which is why they return results in single-digit milliseconds even at scale.
Elasticsearch is the most powerful of the three and the one most teams reach for out of habit. We ruled it out because it is operationally heavy. Running a production Elasticsearch cluster is a full-time job for someone.
Meilisearch and Typesense are both lighter. Single binary, easy to run, reasonable defaults, designed for small teams. We went back and forth for a while. In the end Typesense won for three specific reasons. First, its query syntax maps cleanly to what we already had in Postgres, which meant our frontend code barely had to change. Second, Typesense Cloud is a real, managed offering that we trusted, whereas Meilisearch Cloud felt newer and less battle-tested. Third, Typesense has better support for faceted search, which we knew we would want later for multi-select filters on content status, author, and language.
How the data actually flows now
The architecture is simpler than it sounds.
Postgres is still the source of truth. Every HubSpot record we fetch lands in Postgres first, with the full raw payload stored as JSONB and a few high-use fields pulled into real columns. Nothing about that changed.
What changed is what happens next. After a record is inserted or updated in Postgres, we push a copy of the searchable fields into Typesense. Title, slug, status, language, publish date, author, tags, a few other things users actually filter on. Not the full HTML body, not the module tree, just what is needed for search and filter.
When a user types in the search bar or clicks a filter in Smuves, the frontend no longer calls Postgres. It calls Typesense. Typesense returns matching record IDs in single-digit milliseconds. The frontend then either renders straight from the Typesense result, for things like the content list view, or fetches the full record from Postgres by ID, for things like the edit view that needs the complete payload.
That split matters. Typesense is fast because it only holds what it needs for search. Postgres stays lean because it is no longer the thing being hammered by live queries. Each system does the job it is good at.
The part that was harder than expected
Syncing data between Postgres and Typesense looks easy on paper. Write to Postgres, then write to Typesense. Done.
In practice, it is never that clean. What happens when the Postgres write succeeds and the Typesense write fails? Now the index is out of sync with the source of truth. What happens when a user updates a record twice in a second? You can get writes to Typesense out of order if you are not careful. What happens when you need to re-sync an entire content type because the schema changed?
We ended up building a small sync layer on top of pgmq, Supabase's native queue extension. Every write to Postgres enqueues a sync job. A background worker dequeues, pushes to Typesense, and retries on failure with exponential backoff. If a write fails enough times, it goes to a dead letter queue and we get alerted. Re-syncing a full content type is a matter of enqueuing every record ID in that content type and letting the workers grind through.
None of this is novel. It is a textbook event-driven sync pattern. But it took a week to get right, and the edge cases kept surfacing for another two weeks after that. If you are planning a similar migration, budget for this. The search engine itself is the easy part. Keeping it in sync with your source of truth is where the real work lives.
The numbers
Before the rewrite, a search across 70,000 records took 20 to 30 seconds on a good day, and timed out on a bad one. After the rewrite, that same search consistently returns in under 40 milliseconds. Filters and sorts have the same profile. Keystroke-level search, the kind where results update as you type, is finally possible without feeling guilty about what it costs the database.
200x faster is the round number. For the queries where Postgres was timing out entirely, it is not really a comparison at all, it just works now.
What we would do differently
One thing.
We spent too long trying to make Postgres do everything. There is a version of this story where we hit the limits two or three months earlier than we did, accepted that search needed its own home, and skipped the long middle phase where we were adding indexes and hoping the next one would fix it.
Postgres is a fantastic database, and for most products at most scales it is absolutely enough. But when your product is specifically a live query interface over large data, search is not a feature you can bolt onto a relational database. It wants its own infrastructure.
If you are building something that is mostly writes and occasional reads, stay on Postgres. If you are building something where users are constantly searching, filtering, and sorting over a lot of data, budget for a search engine earlier than you think you will need it. The real cost is not the infrastructure. It is the months you spend trying to make the database do a job it was not built for.