The main search in an enterprise asset-management web app averaged 121 seconds, and the worst case took 15.7 minutes. The query was not slow because it was working hard. It was slow because it was waiting.
This is how I found that out, how I made it bearable without touching the application, and how I then removed the cause rather than the symptom.
Users typed a name and waited. Average 121 seconds, worst case 15.7 minutes.
The obvious first move is an index rebuild. It cut logical reads by up to 80%, yet elapsed time did not change. Fewer reads with the same wait means reads were never the bottleneck. A rebuild is a treatment, not a diagnosis.
Query Store and the execution plans showed a parallel plan with three merge joins, each fed by order-preserving exchanges. Worker threads were idle more than 95% of the time. The query used about 5 seconds of CPU over 121 seconds elapsed. The work was not slow, it was stalled: the query was waiting, not working.
I tested options against production using Query Store, then wrote a script, with a rollback, that capped database-scoped MAXDOP at 4. The DBA team applied it. Search dropped to 0.8–1.0 s in production.
I also tried running the whole database at MAXDOP 1, with Query Store hints giving export queries more threads. Search was 0.7–2.5 s, but exports broke, so it was not kept. A faster search that breaks another feature is not a fix.
A cap on parallelism hides the stall; it does not remove it. I rewrote the view's three owner joins as per-row TOP (1) lookups with OUTER APPLY. Each one uses an index seek, so the stalling plan can no longer be chosen. The pattern, with made-up names:
-- before: a join the optimizer may turn into a merge join SELECT i.*, o.owner_name FROM items i LEFT JOIN owners o ON o.item_id = i.id; -- after: one seek per row, no ordered exchange to wait on SELECT i.*, o.owner_name FROM items i OUTER APPLY ( SELECT TOP (1) owner_name FROM owners WHERE item_id = i.id ORDER BY owner_name ) o;
In QA the query went from 30.1 s to 0.48 s. I deployed it to production with parallelism back to the default and every hint removed. A production spot check showed 1.6 s.
| Stage | Environment | Search time |
|---|---|---|
| Before | Production | 121 s average, 15.7 min worst |
| Stopgap: MAXDOP capped at 4 | Production | 0.8–1.0 s |
| Dropped: MAXDOP 1 plus hints | Tested | 0.7–2.5 s, exports broke |
| Rewrite, before | QA | 30.1 s |
| Rewrite, after | QA | 0.48 s |
| Rewrite, default parallelism, no hints | Production spot check | 1.6 s |
A rewrite that is fast but returns different rows is a bug. Before shipping I compared the old and new view output row by row: 26 columns, zero differences in either direction.
A report export produced a 191 MB file because it used an old text-based .xls format. A hotfix that streams a real .xlsx brought it to 20–30 MB.