The bugs that don't crash
Notes from the Refactor, #2
There is a comfortable kind of bug and an uncomfortable kind of bug. The comfortable kind crashes. The server throws a 500, the logs light up, the error reaches someone who can fix it. You find out fast, you fix it fast, everyone moves on. Crashes are loud, and loud bugs are cheap.
The uncomfortable kind of bug does not crash. It returns 200. The UI shows the success toast. The user clicks the button and gets the friendly green checkmark and goes about their day. And somewhere underneath, the data quietly fails to do what it was supposed to do.
This post is about one of those bugs, and about why AI-assisted codebases produce them at a rate that would embarrass a human team.
The four missing characters
The codebase is the same attendance system I wrote about in the last post — a working product, real users, a university depending on it. This time the story is not in the auth layer. It's in the admin panel, in a function that moves a student from one group to another.
The function looked roughly like this:
async function updateStudentGroup(studentId, newGroupId) {
const student = await prisma.student.findUnique({
where: { id: studentId }
})
if (!student) {
throw new CustomError('Student not found', 404)
}
prisma.student.update({
where: { id: studentId },
data: { groupId: newGroupId }
})
return { success: true, message: 'Student group updated' }
}
Read it slowly. If you're a backend developer, you've already seen it. The first Prisma call — findUnique — is awaited. The second call — update — is not. The function returns { success: true } before the database has even been asked to perform the update. The promise returned by prisma.student.update() is created and then immediately discarded.
What happens next depends on timing, load, and luck. Sometimes Node.js's event loop happens to resolve the promise before the process moves on, and the update goes through. Sometimes it doesn't, and the update is dropped. Sometimes the connection pool is full, the query is queued, the function returns, the HTTP response is sent, the connection is reused for the next request, and the original update never executes at all.
From the outside, every one of these cases looks identical. The API returns success. The UI displays "Student group updated." The admin clicks away and assumes the work is done.
Why the admins didn't complain
This is the part that fascinated me most when I found it.
The bug had apparently been in production for months. The feature was in active use — admins were moving students between groups regularly, especially at the start of each semester when class rosters were being reorganized. If the bug dropped even a quarter of the updates, you would expect a flood of complaints: "I moved Fatima to group B three times and she's still showing up in group A."
There were no complaints. I asked why.
The answer, when I reconstructed it, was that the admins had learned to work around a problem they didn't know existed. When they moved a student and the student later turned up in the wrong attendance list, the admins assumed they had made a mistake — that they had clicked the wrong group, or updated the wrong student, or forgotten to save. So they did the move again. Sometimes the second attempt stuck (because the connection pool was less loaded, or the event loop was more forgiving). Sometimes it took three tries. Eventually it worked.
The bug had trained the humans around it to be uncertain about their own actions. They were compensating for it without ever suspecting that the compensation was needed.
This is what silent failure does. It doesn't just corrupt your data. It corrupts your team's trust in the software and, eventually, in themselves.
Why this bug specifically loves AI-assisted code
I want to be careful here. Missing await statements are a human bug too. Every backend developer has forgotten an await at some point. This is not a bug that AI invented.
But AI-assisted code produces this class of bug at a much higher rate, and the reason is worth naming, because it points at something larger.
When a human writes prisma.student.update(...), they are usually thinking about the operation — "I need to update this student." They know they are about to write a database write. They have a mental model that says database writes take time, and I need to wait for them to finish before I say I'm done. The await keyword is a reflex attached to that mental model.
When an AI assistant writes prisma.student.update(...), it is doing something subtly different. It is producing a sequence of tokens that resembles code it has seen before. Most of the time, the code it has seen before has await in front of the Prisma call, so most of the time it puts await there. But when the pattern is broken — when the function is large, when there's unusual nesting, when the assistant is in the middle of a refactor and pulled the update out of another function — the await can go missing, and nothing in the generation process flags the omission.
The assistant doesn't know that a Prisma update is a database write that must be awaited. It knows what Prisma update calls typically look like. Most of the time those are the same thing. Sometimes they aren't.
This is the fundamental property of AI-assisted code that founders need to internalize: the assistant optimizes for producing code that looks correct, not code that is correct. A human developer who forgets an await usually notices within seconds because the code feels wrong to them. An AI assistant that omits an await produces code that feels perfectly normal, because the code is, in every visible way, perfectly normal. The bug is in the absence of a character, and absences are not what pattern-matching is good at.
The wider category
Once I started looking for this class of bug in the codebase, I found others. Not the same bug, but the same shape:
A $transaction that destructured six variables from a call that returned four. The extra two were silently undefined. The code later treated them as if they had values. The function didn't crash because JavaScript is remarkably patient with undefined; it just produced subtly wrong results.
A catch block that swallowed errors and returned undefined from a function that was supposed to return a QR code. Callers received undefined, didn't check, and rendered a broken image tag.
A controller that threw CustomError() without the new keyword, which in modern JavaScript doesn't throw at all — it just silently evaluates the expression and moves on. The error path had never actually fired in production, because the error path was unreachable.
A login response that returned the user object including fields that weren't supposed to leave the server, because nobody had thought to say which fields should be returned — the code just returned the whole object. The bug wasn't that something broke. The bug was that too much worked.
Every one of these is an error of omission. The code didn't do something wrong, it failed to do something right, and the system kept running as if nothing had happened.
You can audit for errors of commission — the wrong function called, the wrong operator used, the wrong variable passed. Linters will find most of them. Type checkers will find more. Code review will catch the rest. Errors of commission are visible: there is a thing in the code you can point at and say "that is wrong."
Errors of omission are invisible. You cannot see what isn't there. The await that should exist, the field that should be excluded, the new keyword that should precede the constructor, the check that should happen before the update — none of these leave a mark on the page. You find them by running a different mental pass over the code entirely, one that asks at every line "what should be here that isn't?"
This is the pass that AI assistants are worst at. It's the pass that exhausted founders are also worst at, and for the same reason: it requires you to constantly imagine the absence of things, which is cognitively far more expensive than reacting to what's present.
What to do about it
I want to give you something concrete, not a lecture.
First: find your un-awaited promises. If you're on Node and using TypeScript, enable the no-floating-promises rule from @typescript-eslint. It flags every promise you create and don't await or explicitly discard. It will, on a codebase like the one I audited, immediately surface dozens of warnings, and you will be glad to see them. If you're on plain JavaScript, the rule exists in a weaker form but still helps. Turn it on. Fix what it finds. Make the build fail when it's violated.
Second: treat silent success as the enemy. When you write a function that performs a database write, ask one question before you move on: "If this write silently fails, how would I know?" If the answer is "I wouldn't," the function is wrong — not in its logic, in its structure. Add a return value that confirms the write. Check the affected row count. Log the operation. Write a test that verifies the row actually changed. The goal is to make the success path prove itself, not announce itself.
Third: stop trusting 200 responses. The green checkmark in the UI is not evidence that anything happened. It's evidence that a function returned without throwing. These are not the same thing. For any operation that matters — payment, account change, data migration, access control — the UI should confirm the state change by re-reading the data, not by trusting the write endpoint. This is slower. It's also correct.
Fourth: ask the admins. This is the one I learned from this audit. When you inherit a codebase with unknown users, talk to the people who use it every day. Ask them which actions they don't quite trust. Ask them which buttons they click twice "just to be sure." Ask them what they assume is their fault when something weird happens. The workarounds they've developed are a map of the bugs you haven't found yet. The admins in this case knew, at some pre-verbal level, that the group-change function was flaky. They had simply stopped expecting the software to be reliable, which is the saddest version of success software can achieve.
The hard truth
If you shipped a product with AI assistance — and most founders now do — you have bugs of this shape in your codebase. Not maybe. Definitely. The question is not whether they exist but how many, and which ones, and what they're quietly doing to your data while you read this post.
This is not a reason to panic. It's a reason to plan. Silent failures compound. The longer they run, the more data they corrupt, and the more your users adjust to treating your software as unreliable. The cost of finding them next month is materially higher than the cost of finding them this week.
The fix starts with the same move as every other fix in this series: open the code, and look for what isn't there.
Next in the series: the 127-file codebase with 165 stray console.log statements, and why observability is not the same as logging.

Ahmed essyad
the owner of this space
A nerd? Yeah, the typical kind—nah, not really.
View all articles by Ahmed essyad→Comments
If this resonated
I write essays like this monthly.