Skip to content
PRS India

Vayu Procure

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

A B2B procurement SaaS was told to rewrite in Go and split its database. We measured 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
Published
Feb 2025
Updated
Oct 2026

640ms

P95 on the bid-comparison screen, down from 11.2 seconds

22%

Database CPU at peak, down from 95% — same instance, same data

9 min

Nightly spend-analytics run, down from 7 hours

41% 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 was just too slow to use.

They called us eleven days before a renewal

Their largest customer, 19% of ARR, had written that the renewal depended on speed. The complaint was clear and fair. The supplier bid-comparison screen, the one screen a buyer uses to actually award business, was taking 11.2 seconds at P95. Their category managers had started copying data to Excel and comparing bids there. That is a sign a SaaS product is dying.

The symptoms

  • Database CPU pinned at 95% through the working day, on an already oversized RDS instance they had made bigger 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 server bill had tripled in eighteen months while their user count had not quite doubled.

What they had been told to do

Two earlier consultants had suggested a rewrite in Go and splitting the database. Both would have taken months. Both would have been the wrong answer, and one of them would probably have finished after the company did.

Here is what an afternoon with a profiler and performance_schema really showed:

  • An N+1 explosion. The bid list showed 200 rows and sent 4,100 queries to do it, through lazily-loaded linked data three levels deep.
  • A JSON full-table scan. The line-item filter ran WHERE JSON_EXTRACT(attributes, '$.category') = ? against a 41-million-row table. No index can help that. Every filter click scanned the whole table.
  • The analytics job used chunk() with an ORDER BY on an unindexed column. Every chunk sorted all 41M rows again. It was not processing data. It was re-sorting the table, 4,100 times, overnight.
  • Sessions were in the database. Every single request wrote a row, even requests for static files that had no business touching the app.
  • No HTTP caching at all on a supplier catalogue that changes about once a week.

Their problem was never that MySQL is slow. 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 measuring: by request, with a profiler in staging using replayed live traffic, and by query, 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 loading 200 full Eloquent models, each dragging three levels of linked data behind it. We replaced the busy list path with a flat query-builder projection selecting exactly the six columns, joined once. Eloquent is a great tool for business rules and the wrong tool for a grid that is mostly read. 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 strongly. A second datastore means writing twice, delays in syncing, a new way to fail, a new thing to run, and a new thing to be on call for. Their queries used 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 servers. The full scan became an index range scan.

If they had needed open-ended text search, the answer would have been different. They did not, so it is not.

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

The dashboard reads the same twelve totals on every load. Working them out again by scanning 41M rows makes no sense. We moved the job to a read replica, replaced chunk() with keyset pagination on an indexed cursor (so there is no re-sorting, and no growing OFFSET cost), 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 main database.

Fix 4 — the unglamorous ones

Sessions moved to Redis. ETag and Cache-Control with a version key to clear the cache on the catalogue endpoints, so a catalogue that changes weekly stops being rebuilt 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 linked data to a list view, unless something stops them. We added a query-budget assertion to the CI suite: any request in the tests that sends more than 25 queries fails the build. It has fired four times since. That is four problems that did not happen.

What we explicitly did not do

We did not rewrite in Go. We did not shard. Both were suggested by people who had not measured the application, and both would have carried the same 4,100 queries into a more costly place to run them.

03

The outcome

The bid-comparison screen was fixed and put live in nine days. The renewal was signed. The rest of the work was released over the next 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 simply stopped asking it to do silly things.
  • Nightly analytics: 7 hours → 9 minutes, and running on a replica, so it can no longer slow the application for anyone in any timezone.
  • Monthly infrastructure spend fell 41%. We made the RDS instance two sizes smaller, from a db.r6g.4xlarge to an xlarge. You can only do that after you no longer need the extra room. They had been paying to escape a code problem for eighteen months.

The part that outlives us

The query-budget check in CI is, honestly, the most valuable thing we left behind. Speed fixes fade over time. Without a guard, the same N+1 grows back, and someone calls another agency in eighteen months. A failing build is that guard. It has caught four problems before they went live, and it will keep catching them long after we are gone.

We spent nine days on the fix and two days on the measuring 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, split 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.