Found this completely by accident. I wasn't debugging anything, I was just poking around a SigNoz instance I'd self-hosted the night before, ahead of a hackathon build sprint. Opened the Service tab, and there it was.
skillbridge-api — P99: 5449.97ms. Error rate: 0.00%.
Zero errors is the annoying part. Nothing crashed, nothing threw, nothing would ever show up in an alert (not that I have any set up yet, but you get the idea). The app was doing exactly what it was told to do. It just took 5.4 seconds to do it.
Quick context if you're new here: SkillBridge is my side project, ed-tech app, Node/Express backend, Supabase for the DB, React frontend. I wired up SigNoz mostly to see what an actual OpenTelemetry setup looks like on a real app instead of a toy example. Wasn't expecting to find a real bug on day one. Found one anyway.
Trace #1: the login flow
Opened a trace for /api/auth/login:
-
Login— 873.95ms -
Find User— 745.99ms- a
GETout to Supabase — 322.09ms, withtls.connect/tcp.connect/dns.lookupnested inside it
- a
-
Verify Password— 124.42ms
124ms for bcrypt is fine, that's the whole point of bcrypt being slow. 746ms to fetch one user row is not fine.
The gotcha that had nothing to do with the bug but still cost me 10 minutes
Poking through other traces, I found a service literally named this:
unknown_service:C:\Users\Devanshukoli\scoop\apps\nvm\current\nodejs\nodejs\node.exe
That's what SigNoz falls back to when you forget to set OTEL_SERVICE_NAME. It just uses the binary path instead. Not a big deal until you've got two or three scripts running and your service list turns into unreadable garbage. Fix is one line:
OTEL_SERVICE_NAME=skillbridge-worker node script.js
Mentioning it because nobody tells you this until you hit it yourself, and then it's obvious.
The actual bug
That exact "unknown_service" trace is where the real evidence was sitting, which I only noticed because I'd already opened it to laugh at the naming issue. Seven back-to-back GET calls, each 300–600ms, none of them overlapping:
315.03ms
331.15ms
302.51ms
600.00ms
321.15ms
393.40ms
...
total: 2.52s
Seven round trips to the same downstream service, one after another. Not "one query is slow." Just a straight-up queue that nobody asked for.
My first guess (wrong)
Staring at all those tls.connect spans showing up over and over, I figured: we're probably spinning up a new Supabase client on every request, so we keep paying a fresh handshake each time.
Reasonable guess. Took about a minute to check, and it died instantly:
let supabaseInstance: SupabaseClient | null = null;
export function getSupabaseClient(): SupabaseClient {
if (supabaseInstance) return supabaseInstance;
supabaseInstance = createClient(url, anonKey);
return supabaseInstance;
}
Already a singleton. My theory was just wrong, and I'm glad I checked the file instead of writing that guess down as fact.
What was actually going on
Found it in supabaseGetCurriculum:
const { data: tracks } = await supabase.from('skillbridge_tracks').select('*');
const { data: rawModules } = await supabase.from('skillbridge_modules').select('*').order('order', { ascending: true });
const { data: rawLessons } = await supabase.from('skillbridge_lessons').select('*').order('order', { ascending: true });
const { data: rawProjects } = await supabase.from('skillbridge_projects').select('*');
const { data: progressData } = await supabase.from('skillbridge_progress').select('*').eq('user_id', userId);
const { data: submissionsData } = await supabase.from('skillbridge_submissions').select('*').eq('user_id', userId);
Six queries. None of them need each other's result. All six waiting in line anyway, because that's just how it comes out when you're writing a function at 1am and not thinking about what actually depends on what.
The fix
const [
{ data: tracks },
{ data: rawModules },
{ data: rawLessons },
{ data: rawProjects },
{ data: progressData },
{ data: submissionsData },
] = await Promise.all([
supabase.from('skillbridge_tracks').select('*'),
supabase.from('skillbridge_modules').select('*').order('order', { ascending: true }),
supabase.from('skillbridge_lessons').select('*').order('order', { ascending: true }),
supabase.from('skillbridge_projects').select('*'),
supabase.from('skillbridge_progress').select('*').eq('user_id', userId),
supabase.from('skillbridge_submissions').select('*').eq('user_id', userId),
]);
Where I have to be honest with you
Ran it. First trace after the fix: 1.49s total, and two of the calls clearly ran at the same time, no repeated handshake between them. I was pretty happy about that.
Ran the exact same endpoint again a few minutes later. 2.44s. Three separate calls, each one paying its own tls.connect and tcp.connect again, like nothing carried over from the last request at all.
Same code path. Same endpoint. Two completely different results. I wanted to go check the Service tab and see if P99 had actually moved over the last hour, and make sure my dev server had actually restarted with the new code instead of me testing against a stale process. Then my laptop crashed. An hour before this was due.
So that's where it ends for now. Not fixed-and-confirmed. Fixed-and-hopeful. Which, if I'm honest, is closer to how most of my debugging actually goes anyway.
What I'd tell past-me
- Zero error rate doesn't mean zero problems. Nothing pages you for "technically correct but slow."
- Check your theory against the actual code before you say it out loud. My first guess sounded smart and was completely wrong.
-
Promise.allisn't magic, it just relocates the bottleneck. I've now got a single query sitting around 1s that I still need to look at. - One good trace after a fix proves nothing on its own. Two traces disagreeing means you need the aggregate number, not another screenshot.
If you're building something and haven't wired up tracing yet, do it before you think you need it. I self-hosted SigNoz for this, and it found me a bug I wasn't even looking for on day one. Probably will for you too.





Top comments (0)