The cost of a push
Every Git push to GitHub triggers a series of pre-receive hooks that validate things like object size limits and LFS upload status. These checks protect server health and keep the platform running smoothly. But as the checks accumulated over time, so did the cost of executing them—until a single empty push could take more than two seconds.

The root cause was architectural. The hooks were originally written in Ruby and embedded in the same Rails monolith that powers the rest of the GitHub application. In a normal Rails request, dependencies load once at startup and are reused. But Git runs hooks as subprocesses on every push, meaning all those dependencies had to be loaded from scratch each time. With more than 450 gems and over 1,000 require calls, that startup time dominated the hook's runtime: roughly 880 milliseconds on average, nearly all of it spent booting the environment.
The first cut: fewer dependencies
The simplest fix was to stop loading unnecessary code. The hooks were tangled into the monolith's configuration, but careful profiling with a debugger and strace identified several outliers that could be skipped when running hooks in the subprocess context. That trimmed 350-400 milliseconds from startup.
The problem with this approach is that it fights a losing battle. New dependencies are added to the Rails app continuously, and the hook's startup time would inevitably creep back up even if the hook code itself never changed. A more durable solution was needed.
Moving to Go
The team's longer-term path was already clear: the Git Systems group had spent years extracting service code from the Rails monolith into a dedicated Go service. Moving the hooks there was a natural next step, but it required a way to pass configuration from the Rails app to the new service.
Fortunately, an existing mechanism was already in use. On every push, the monolith sends contextual information—such as repository name and quota status—alongside the data needed to perform updates. Extending that payload with the additional information required by the checks added negligible overhead.
Launching the rewrite behind a feature flag let the team enable it selectively, starting with internal repositories to validate behavior in production. The results were immediate and dramatic.
From 880ms to 10ms
Median hook execution time dropped from roughly 880 milliseconds to 10 milliseconds—a reduction so large that the team had to verify the hooks were even still running. They were. The improvement was noticeable enough that users began asking unprompted whether pushes had gotten faster.
This kind of performance win is easy to underestimate because it applies to every single push. A 95% reduction in the P99 latency of a global hot path translates into a meaningful improvement in daily developer experience across the platform.
Architecture meets user impact
The rewrite solved a long-standing structural problem: the hooks were in the wrong place, coupled to a monolith's configuration and dependency graph. But architectural purity alone rarely justifies the risk of a rewrite. The user-facing latency issue provided the urgency to prioritize the work, and the team got both benefits—better architecture and measurably faster pushes.
The change went live on github.com and shipped in GHES 3.4. It's a reminder that sometimes the biggest performance wins come not from optimizing hot loops, but from eliminating the cold startups that surround them.



