A misleading New Relic trace, nine goroutines, and a scheduled 9 AM push notification all contributed to a recurring stall on our job detail page. The slow query that everyone suspected turned out to be innocent. The real bottleneck was the database connection pool.
The symptom
Every morning at 9 AM, our marketing team sends a push
notification blast. Every morning at 9:03, database CPU and
I/O spike. And every morning, a handful of requests to the
job detail page (GET /job/:slug, the most
important page in our product) would stall. Not just slow:
stalled. One trace showed a response time of
2 minutes 14 seconds.
The New Relic trace for that request looked like this:
| Segment | Duration | % |
|---|---|---|
GET /job/:slug |
2 min 14 s | 100% |
service.jobPage
|
2 min 14 s | 99.74% |
Postgres select |
219 ms | 0.16% |
repository.FindJob
|
45 ms | 0.03% |
Postgres other |
27 ms | 0.02% |
The transaction spent 99.74% of its life inside
jobPage, and New Relic could account for about
300 milliseconds of it. The remaining 2
minutes and 13 seconds were not attributed to anything. They
sat inside a single parent segment with no children to
explain them.
Why the trace showed nothing
jobPage loads a job, then fetches everything
the page needs (the jobseeker's profile, CV, skills,
preferences, the employer, the application state) as
9 parallel queries using an
errgroup:
g, gctx := errgroup.WithContext(ctx)
g.Go(func() error {
profile, err = repo.FindProfile(gctx, db, userID)
...
})
g.Go(func() error {
employer, err = employerRepo.FindEmployer(gctx, db, employerID)
...
})
// ... seven more
Each repository method starts a New Relic segment from the
context, and our Postgres driver
(nrpq) creates a datastore segment for every
query. In theory, the trace should show all nine.
The catch is that the New Relic Go agent does not allow
concurrent segment creation on a single transaction
reference. All nine goroutines shared one
*newrelic.Transaction through
gctx. The agent keeps one segment stack per
transaction, and nine goroutines pushing and popping on it
concurrently corrupts that stack. The agent then silently
drops the out-of-order segments.
That is why the trace was empty. The segments that would
have shown the slow queries were being created and
immediately discarded, and all the unaccounted time
collapsed into the parent jobPage segment.
The fix is one transaction reference per goroutine:
// inside each g.Go closure:
gctx := newrelic.NewContext(gctx, txn.NewGoroutine())
We wrapped that in a small helper and added it to every closure. This changed nothing about production behavior, but it changed what we could see.
The pool queue
The next morning, the trace told a different story:
repository call #1 7.14 s 99.76%
├─ Application code in (...) 7.14 s 99.65% ← ???
└─ Postgres select 8 ms 0.11%
repository call #2 7.14 s 99.71%
├─ Application code in (...) 7.14 s 99.65% ← ???
└─ Postgres select 4 ms 0.06%
repository call #3 26 ms
repository call #4 24 ms
The repository call took 7.14 seconds. The actual SQL query inside it took 8 milliseconds. New Relic labelled the gap honestly: "Application code", meaning time inside the function that is not any instrumented database call.
Two of the nine parallel queries blocked for an almost identical 7.14 seconds, then finished together. The other seven completed in 16–26 ms.
That gap lives inside Go's database/sql package,
in the connection pool. nrpq instruments the
driver; the pool sits one layer above it, and no APM
instrumentation can see into it. The timeline of those 7.14
seconds was:
[ waiting for a free connection from the pool ] ~7.1 s ← invisible
[ query executes ] 8 ms
The two stalled goroutines were the last two in line when the pool ran dry. Seven of nine got connections instantly; the remaining two queued until connections were available, which is why they woke up at the same moment.
The connection math
Once you see it, the math is straightforward. For example, let each of these be a variable:
C = concurrency per instance (requests)
Q = connections per jobPage request (one per parallel query)
P = pool size (maxOpenConns per instance)
peak demand = C × Q connections
requests that fit = P ÷ Q
Once C × Q exceeds P, requests
queue at the pool. On a normal day, queries are fast and
connections turn over quickly enough that nobody notices.
During the 9
AM burst, the database slows down under load, so every
connection is held longer, which means the queue grows
faster, which means more requests pile up holding
connections while waiting. A congestion collapse, triggered
on schedule every morning by marketing.
RaisingmaxOpenConnsonly moves the ceiling; it does not remove the 9× multiplier. The real ceiling ismaxOpenConns×max instancesversus Postgres'max_connections. We would have been back here within a quarter.
Merging the queries
The fundamental problem was not pool size. It was that one page view needed nine simultaneous connections. So we merged the eight fan-out queries into a single statement: one round trip, one connection.
The merged query returns exactly the same data as the eight separate queries it replaced, just in a single round trip. The approach is simple: fetch every section the page needs in one statement, returning single-row sections as columns and list-like sections as small JSON aggregates that the Go layer decodes into slices. The result preserves the old behavior, including how an empty section maps to a zero value. While merging, we also noticed one query was redundant and removed it.
The risky part of a wide merged query is the positional
Scan() list: one wrong column order and you are
shipping bug. So we handled it with careful testing. We ran
the old queries and the new one against the same schema and
data, then compared the results for every field, including
the empty and missing section cases.
The test immediately caught a real bug: one legacy query scanned a date column into an embedded struct field whose name did not match, a mismatch invisible in the code until the comparison checked the two structs field by field.
Production EXPLAIN ANALYZE on the merged query
reported 6.4 ms execution, with every table
served by an index. We are satisfied with that result.
Results
| Before | After | |
|---|---|---|
| Connections per request | 8–9, concurrent | 1 |
| DB round trips per request | 9 | 1 |
| Peak demand at 80 req/instance | ~720 conns | ~80 conns |
| jobPage p99 during 9 AM burst | up to 2 min 14 s | milliseconds |
| Trace visibility | 99.74% in jobPage, unattributed | every step attributed |
What we learned
-
When your APM shows a large parent segment with no
children, suspect the instrumentation before the
code.
Concurrent segment creation on one New Relic transaction
reference silently drops segments.
txn.NewGoroutine()per goroutine is not optional. - "Application code" around a database call usually means the connection pool. Driver instrumentation cannot see pool wait time; it starts measuring after a connection is acquired. If the query is fast but the span is slow, you were queueing.
-
Do the connection math.
concurrency per instance × connections per requestis your real demand. A pool sized for the average query count collapses the moment queries run in parallel. - Pool size is a ceiling, not a fix. Reducing connections per request, whether through fewer queries or one merged query, removes the multiplier instead of raising the roof.
- Traffic bursts on a schedule are convenient. A predictable 9 AM spike is the easiest kind of incident to reproduce, instrument, and verify a fix against.
- Test the merge carefully. Merging queries is simple, but it is easy to get wrong. Comparing the old results with the new ones on the same data makes the change safe, and it will catch at least one bug you did not expect. It caught ours.