FealDeal
Vayu Procure
A B2B procurement SaaS was told to rewrite in Go and shard its database. We profiled it instead, found 4,100 queries behind one screen, and took P95 from 11.2 seconds to 640 milliseconds.
0ms
P95 on the bid-comparison screen, down from 11.2 seconds
0%
Database CPU at peak, down from 95% — same instance, same data
0 min
Nightly spend-analytics run, down from 7 hours
0% lower
Lower monthly infrastructure spend after downsizing RDS
01
Vayu Procure sells a B2B procurement portal — 900 enterprise buyers running RFQs against a network of 26,000 suppliers. The product worked. It just could not be used.
Their largest customer — 19% of ARR — had put the renewal in writing as conditional on performance. The complaint was specific and entirely fair: the supplier bid-comparison screen, the single screen a buyer uses to actually award business, was taking 11.2 seconds at P95. Their category managers had started exporting to Excel and comparing bids there, which is the sound of a SaaS product dying.
Two prior consultants had recommended, respectively, a rewrite in Go and sharding the database. Both would have taken quarters. Both would have been the wrong answer, and one of them would probably have finished after the company did.
Because here is what an afternoon with a profiler and performance_schema actually showed:
WHERE JSON_EXTRACT(attributes, '$.category') = ? against a 41-million-row table. No index can help that. Every filter click was a full scan.chunk() with an ORDER BY on an unindexed column. Every chunk was a fresh filesort over 41M rows. It was not processing data; it was re-sorting the table, 4,100 times, overnight.Their problem was never that MySQL is slow. It was that they were asking it the wrong question, 4,100 times a page.
02
Two days of profiling — request-level with a profiler in staging under replayed production traffic, query-level with performance_schema and the slow log on a replica. We did not touch a line of code until we could rank every fix by seconds saved per hour of work. Everything below is in that order.
The bid list showed six columns. It was hydrating 200 full Eloquent models, each dragging three levels of polymorphic relations behind it. We replaced the hot list path with a flat query-builder projection selecting exactly the six columns, joined once. Eloquent is a superb tool for domain logic and the wrong tool for a read-heavy grid. 4,100 queries became 3.
The obvious fix for the JSON scan was "move line items to MongoDB/Elasticsearch". We did not, and we would argue this hard. Introducing a second datastore means dual writes, sync lag, a new failure mode, a new thing to operate, and a new thing to be on call for. Their query pattern was three known attributes. MySQL 8 stored generated columns with secondary indexes over those three attributes gave us 95% of the win for about 2% of the risk, in one migration, with no new infrastructure. The full scan became an index range scan.
If their access pattern had been genuinely open-ended text search, the answer would have been different. It wasn't, so it isn't.
The dashboard reads the same twelve aggregates on every load. Recomputing them by scanning 41M rows is indefensible. We moved the job to a read replica, replaced chunk() with keyset pagination on an indexed cursor (so there is no filesort, and no deepening OFFSET penalty), and materialised the twelve aggregates into a summary table the dashboard reads directly. Seven hours became nine minutes, and it no longer runs anywhere near the primary.
Sessions to Redis. ETag and Cache-Control with a version-keyed cache buster on the catalogue endpoints, so a catalogue that changes weekly stops being reassembled thousands of times an hour. Both took an afternoon. Together they took roughly a third of the load off the database.
The N+1 will come back in six months when a developer adds a relation to a list view, unless something stops them. We added a query-budget assertion to the CI suite: any request in the test suite that issues more than 25 queries fails the build. It has fired four times since. Four times is four incidents that did not happen.
We did not rewrite in Go. We did not shard. Both were proposed to them by people who had not profiled the application, and both would have carried the same 4,100 queries into a more expensive place to run them.
03
The bid-comparison screen was fixed and deployed in nine days. The renewal was signed. The remaining work shipped over the following six weeks.
The query-budget assertion in CI is, honestly, the most valuable artefact of this engagement. Performance rescues have a half-life: without a mechanism, the same N+1 grows back, and someone calls another agency in eighteen months. A failing build is that mechanism. It has caught four regressions before they reached production, and it will keep catching them long after we are gone.
We spent nine days on the fix and two days on the profiling that told us which nine days to spend. The two days were the important ones. Every wrong answer this company had been given — rewrite it, shard it, buy a bigger instance — came from someone who skipped them.
Under the hood
Technology
Services used
Two firms told us to rewrite the product. PRS spent two days with a profiler and then told us the database was fine and our code was asking it 4,100 questions per page. Nine days later we kept the account.