Skip to content
PRSINDIA

Vayu Procure

A performance rescue: 11.2s to 640ms, nine days before renewal

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.

Client
Vayu Procure
Industry
B2B SaaS / Procurement
Location
Noida, India
Duration
9 days to the fix, 8 weeks total

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

The challenge

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.

They called us eleven days before a renewal

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.

The symptoms

  • Database CPU pinned at 95% through the working day, on an already oversized RDS instance they had upsized three times.
  • The nightly spend-analytics job ran for seven hours and, while it ran, made the application slow for anyone in a timezone unlucky enough to be awake.
  • Their infrastructure bill had tripled in eighteen months while their user count had not quite doubled.

What they had been told to do

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:

  • An N+1 explosion. The bid list rendered 200 rows and issued 4,100 queries to do it, through lazily-loaded polymorphic relations three levels deep.
  • A JSON full-table scan. The line-item attribute filter ran WHERE JSON_EXTRACT(attributes, '$.category') = ? against a 41-million-row table. No index can help that. Every filter click was a full scan.
  • The analytics job used 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.
  • Sessions were in the database. Every single request — including static asset requests that had no business touching the app — wrote a row.
  • No HTTP caching at all on a supplier catalogue that changes about once a week.

Their problem was never that MySQL is slow. It was that they were asking it the wrong question, 4,100 times a page.

02

What we built

Measure first, and be willing to be boring

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.

Fix 1 — stop hydrating models you are not going to use

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.

Fix 2 — generated columns, not a document store

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.

Fix 3 — the analytics job was answering a question nobody asked twice

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.

Fix 4 — the unglamorous ones

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.

Fix 5 — make the regression impossible, not merely unlikely

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.

What we explicitly did not do

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 outcome

The bid-comparison screen was fixed and deployed in nine days. The renewal was signed. The remaining work shipped over the following six weeks.

Results

  • P95 on the bid-comparison screen: 11.2s → 640ms. A 17× improvement, on the screen that decides whether their product gets used or exported to Excel.
  • Database CPU at peak: 95% → 22%. Same instance, same data, same users, same MySQL. They were simply no longer asking it to do absurd things.
  • Nightly analytics: 7 hours → 9 minutes, and running on a replica, so it can no longer degrade the application for anyone in any timezone.
  • Monthly infrastructure spend fell 41%. We downsized the RDS instance two sizes — from a db.r6g.4xlarge to an xlarge — which is a thing you can only do after you have stopped needing the headroom. They had been buying their way out of a code problem for eighteen months.

The part that outlives us

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

How it's built.

Technology

  • Laravel
  • MySQL 8
  • Redis
  • Blackfire
  • MySQL performance_schema
  • AWS RDS (read replica)
  • CloudFront
  • GitHub Actions CI
  • Docker
  • Terraform

Services used

  • Performance Engineering
  • Technical Audit
  • Cloud & DevOps
  • Custom Software Development
  • Database Optimisation

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.

Sameer Joshi Co-founder & CTO, Vayu Procure

Same problem?

Let's build something that ships.