AI code generation

Why AI-Generated Code Breaks in Production (With Real Failure Examples)

A catalogue of real failure modes in AI-generated code: component duplication, schema drift, missing failure paths, and more, with code examples and what actually prevents each one.

Ash Metwalli
April 20, 2026
9 min read
AI code generationvibe codingtechnical debtAI developmentcode quality
Why AI-Generated Code Breaks in Production (With Real Failure Examples) — cover image
Share:

A solo developer prompts Cursor: "build me a project management app with teams, tasks, comments, and notifications." Twenty files arrive in ninety seconds. The app runs locally. It genuinely feels like a miracle, and I do not want to be cynical about that, because it is.

Two weeks later:

  • The team-invite flow silently drops invites from disabled accounts
  • The task-edit page renders a different button than the task-create page, both generated from the same prompt minutes apart
  • The notification query takes down the database on any account with 10,000 notifications
  • Rate limiting does not exist anywhere in the codebase
  • The admin page has no auth check, because "admin" never came up in the prompt

None of this is the model being bad at code. Every one of these traces back to context the prompt never carried. Here is the catalogue, with what each failure actually looks like.

Failure 1: component duplication

Symptom. The same UI element has three to five different implementations across the codebase, each with different styling, different accessibility behaviour, and different props.

Why it happens. The model generates each page in isolation. When page A needs a button and page B needs a button, it writes a button twice. It has no memory of what it produced for page A while it is working on page B, and no concept that "button" is a shared thing in your project.

// Output on page A (projects list)
<button className="bg-blue-600 text-white px-4 py-2 rounded" onClick={handleCreate}>
  Create project
</button>

// Output on page B (task list), generated two minutes later
<button className="bg-indigo-500 text-white px-5 py-2 rounded-md hover:bg-indigo-600" onClick={handleAdd}>
  Add task
</button>

// Output on page C (settings), generated later that day
<button type="button" className="rounded-lg bg-primary px-3.5 py-2.5 text-sm font-semibold text-white">
  Save
</button>

Three buttons, three styling systems, three sets of default behaviour, and no shared component anywhere.

This one is measurable rather than anecdotal. GitClear's analysis of over 150 million changed lines found duplicated blocks rising while refactoring activity fell, which is precisely this pattern at scale.

What prevents it. Generate a page inventory that names the shared components before any code gets written, then reference that list in every generation prompt. The manual version is one line you paste every time: "shared components are Button, FormField, Modal, DataTable, imported from /components/ui/. Never inline an equivalent."

Failure 2: schema drift

Symptom. The database defines fields the UI never reads, and the UI references fields that do not exist. Works in development, where the mismatch never surfaces. Fails in production, where it does.

Why it happens. Schema and UI usually get generated in separate prompts, and nothing carries the decisions from the first into the second.

-- Generated schema
CREATE TABLE projects (
  id uuid PRIMARY KEY,
  name text NOT NULL,
  owner_id uuid REFERENCES users(id),
  status text DEFAULT 'active',
  archived boolean DEFAULT false,
  created_at timestamptz DEFAULT now()
);
// Generated UI, later in the same session
function ProjectCard({ project }: { project: Project }) {
  return (
    <div>
      <h3>{project.name}</h3>
      <p>Created: {project.createdAt}</p>
      <p>Team size: {project.teamSize}</p>  {/* not in the schema */}
      <span>{project.priority}</span>        {/* not in the schema */}
    </div>
  );
}

The UI wants teamSize and priority, neither of which exists. TypeScript catches this if your types are generated from the schema. It does not catch it if the model wrote loose types alongside the UI, which is the common case.

What prevents it. Generate the schema first, then paste it into every UI-generation prompt as context. Add one instruction: "only reference fields listed in the schema above, and flag any field you need but cannot find." That last clause is the useful half, because it converts a silent invention into a visible question.

Failure 3: missing failure paths

Symptom. The happy path is flawless. Error states, empty states, and rate limits are unhandled or handled inconsistently.

Why it happens. Models default to the positive case unless failure is explicitly demanded, and the training data reinforces it, because most tutorials skip error handling too.

// Generated: happy-path-only login
async function handleLogin(email: string, password: string) {
  const { session } = await supabase.auth.signInWithPassword({ email, password });
  router.push('/dashboard');
}

What is missing: invalid credentials show the user nothing, a network failure produces an unhandled rejection, a rate limit produces no message, an unverified email fails silently, and a disabled account also fails silently. The user sees the same nothing in all five cases.

What prevents it. Acceptance criteria that require a happy path, edge cases, and failure states per story, so the code has something to implement against. What LLMs skip in acceptance criteria covers the twelve categories that go missing by default.

The manual version, appended to any prompt generating user-facing code: "handle invalid input, network failure, rate limit, auth failure, and unexpected server error. Each shows the user a specific message and logs the failure."

The Product Spec Checklist — free PDF

Free resource

Free: the Notion spec kit + printable checklist AI coding tools can actually follow.

Get the Spec Kit

Failure 4: missing auth and authorization

Symptom. Admin pages exist and non-admins can reach them. API routes accept requests without checking who is asking.

Why it happens. Auth is not the interesting part of the feature. It is boilerplate wrapped around the thing the prompt was actually about, so without an explicit demand it gets skipped.

// Generated /api/admin/users/route.ts
export async function DELETE(request: Request) {
  const { userId } = await request.json();
  await supabase.from('users').delete().eq('id', userId);
  return Response.json({ success: true });
}

Anyone who knows the URL can delete any user. No session check, no role check, no audit log.

I would rank this the most dangerous item on the list, because unlike the others it does not degrade the experience in any visible way. Everything works. It works for attackers too.

What prevents it. Every page in your inventory declares a required role, public or authenticated or admin, and every API route inherits the role of the story it belongs to. The manual version, in every API route prompt: "verify the session, verify the user's role permits this action, log to an audit table, return 401 without a session and 403 without the role."

Failure 5: performance cliffs

Symptom. Fast in development with ten rows. Unusable in production with ten thousand.

Why it happens. Generated queries are correct, in that they return the right rows. They just do not include indexes or pagination unless asked.

// Generated dashboard query
export async function getRecentNotifications(userId: string) {
  return supabase
    .from('notifications')
    .select('*')
    .eq('user_id', userId)
    .order('created_at', { ascending: false });
}

Three problems. No .limit(), so it fetches every notification the user has ever received. No index on (user_id, created_at DESC), so it is a full table scan. And select('*') pulls every column across the wire whether the UI uses it or not.

What prevents it. Schema generation that adds an index for every column a story filters, joins, or sorts on, and query generation with pagination defaults. Manually, review every generated query against four questions: is it paginated, are the filtered columns indexed, is it selecting only the fields actually used, and should the response be cached.

Failure 6: inconsistent naming

Symptom. The codebase mixes camelCase, snake_case, and kebab-case unpredictably. Some files are ProjectCard.tsx, others project-card.tsx. The projects entity is variously project, projects, and Project.

Why it happens. With no convention declared, the model defaults to whatever pattern dominated the last thing it saw, and that varies between prompts.

/pages/projects.tsx
/pages/Tasks.tsx              PascalCase file name
/pages/team-members.tsx       kebab-case file name
/components/UserAvatar.tsx
/components/project_card.tsx  snake_case file name
/lib/api/getProjects.ts
/lib/api/tasks_api.ts         mixed

Six schemes in one project. Individually harmless, collectively the reason nobody can guess where anything lives.

What prevents it. Generate the file tree first, with every path explicit and conforming to one convention, then generate code into that tree. Manually, state it every time: "files kebab-case, component exports PascalCase, variables camelCase, database columns snake_case, no exceptions."

The thread connecting all six

The model is not missing intelligence. It is missing shared context across its own generations.

Every prompt is a fresh conversation. Without persistent structural context, no model can make decisions that stay coherent across twenty files, because it cannot see the other nineteen. That is not a reasoning failure, it is an information one, and it is the reason "use a smarter model" does not fix any of the six failures above.

Which suggests the fix is not better prompts in isolation but a persistent artifact the prompts can read from. Personas, stories, criteria, schema, page inventory, generated once and then referenced. The code generation itself does not need to change at all. The context it runs against does.

Two honest caveats before you take that as gospel. First, all six failures also happen in human-written codebases, and always have. AI makes them faster and more frequent, not novel. Second, this is a real cost, not a free lunch: the planning layer is work, and on a throwaway project it is work you should skip. What vibe coding actually is covers where that line sits.

Related reading

If you want to close the schema-drift gap first, the Database Schema Generator turns user stories into an ERD and PostgreSQL DDL. Free, no signup.


Sources and further reading

The Product Spec Checklist — free PDF

Free resource

Free: the Notion spec kit + printable checklist AI coding tools can actually follow.

Get the Spec Kit

Related Topics

Related Articles

View all posts