ORMs are excellent defaults. They are a bad excuse on a path that runs thousands of times in ten minutes.
The problem
Dashboard and attendance endpoints looked fine in local profiling and then timed out under school-hour load. The query plans showed sequential scans and chatty relation loading that the ORM hid behind a few lines of TypeScript.
What we changed
- Identified hot endpoints with real p95/p99 from logs — not guesses
- Rewrote those handlers with parameterized raw SQL and explicit select lists
- Added covering indexes for the filters the UI actually uses
- Put Redis in front of read-mostly aggregates with short TTLs
Cold paths kept the ORM. Hot paths earned the extra maintenance cost.
Guardrails
Raw SQL without review is worse than a slow ORM. We kept queries in focused repository modules, added integration tests against a real Postgres, and documented why each hot path opted out.
Caching without invalidation is a different bug. Aggregates used short TTLs; transactional writes never assumed the cache was source of truth.
Results that mattered
p95 dropped enough that tablet kiosks stopped retry-storming. Support tickets about "stuck attendance" fell. The codebase got slightly less uniform and much more honest about where performance lives.
Takeaway
Use the ORM until the metrics say otherwise — then isolate the hot path, measure again, and keep the exception small.