Scaling Pegaso to 6,200 users without a budget
A course scheduling platform for my university went from a weekend project to something 6,200 students depended on. The traffic found problems I would not have predicted.
Pegaso started as a personal annoyance. Every semester, thousands of students at Universidad Nacional de Colombia opened a dozen browser tabs to reconcile course times by hand. I wrote a scheduler that did it in one pass, deployed it on the cheapest infrastructure I could find, and shared the link in a couple of group chats.
Within three weeks it had 6,200 active users, all organic. No marketing, no institutional endorsement, just a link that kept getting forwarded.
The traffic shape
Academic tooling has a brutal traffic curve. For most of the semester the service sits nearly idle. Then registration opens and every student in the faculty arrives inside the same 90 minutes. Average load tells you nothing; the peak is the only number worth designing for.
That drove most of the architecture. Autoscaling reacts too slowly to help at that granularity, so I optimized for making the peak cheap to serve.
Precomputing the expensive path
The course catalog changes a handful of times per semester and is identical for every user. Schedule permutation is per-user and combinatorial. Splitting those two lifetimes was the whole optimization: the catalog is materialized into an immutable snapshot on ingest, and the solver runs against an in-memory view of it.
// The catalog is rebuilt on ingest, never per request.
// Readers get a consistent snapshot without touching the database.
type Catalog struct {
snapshot atomic.Pointer[Snapshot]
}
func (c *Catalog) Load() *Snapshot {
return c.snapshot.Load()
}
func (c *Catalog) Rebuild(ctx context.Context, db *pgxpool.Pool) error {
next, err := buildSnapshot(ctx, db)
if err != nil {
return fmt.Errorf("build snapshot: %w", err)
}
c.snapshot.Store(next)
return nil
}During the registration spike, requests never hit Postgres for catalog reads. The database handles writes and the occasional saved schedule. That turns a scaling problem into a memory allocation problem, and memory is cheaper than connections.
What actually broke
- Connection pooling, first. The default pool size was tuned for a service that never sees 400 concurrent users, and it queued requests until they timed out.
- JSON serialization of the full catalog on every response, which I had assumed was negligible and turned out to be the top entry in the CPU profile.
- My own assumption that users would explore one schedule at a time. They opened five tabs and compared, which tripled the request count per session.
None of that was hard to fix once I could see it. I just could not have found any of it without real traffic on the thing.
Afterwards
The university's own platform, released the following year, adopted the core interaction model Pegaso had converged on. That eventually led to formal acquisition discussions. I had not planned for any of it. I wanted my own schedule to stop taking an afternoon.