A small cyan code block buried inside oversized metal frames, cables and duplicate code panels, illustrating AI-generated code bloat.

Your agents are bloating your codebase

AI agents are great at generating code, but they often get carried away. We’re building a white-label ecommerce platform for a client, and on that project I recently deleted nearly a thousand lines of tests that an agent had written alongside a small fix to the shop pages. They re-ran flows that other tests already covered. On the same project, an agent working on the account pages also changed which region paid orders get sent to. Nobody asked for that.

Andrej Karpathy described agents building a bloated 1000-line solution, then happily cutting it to 100 lines the moment you suggest a simpler way. Anthropic’s own prompting docs even warn that Claude tends to overengineer.

Any code added to your project is a liability, not an asset. Software Engineering at Google explains this perfectly: the feature is the asset, the code is the price of having it. And a liability keeps charging you long after the sale, in every read, test, debug and handover. The goal is the most functionality for the least code. Every unused option or fallback bakes in a decision nobody made on purpose. So here are some common culprits to look out for. Most examples use React, TypeScript and Tailwind.

Short on time? Skip to the agent snippet.

Whatever your tech stack

Comments that restate the code. Agents have their own flavour of this: the changelog comment. // Updated to use the shared formatter describes the diff, not the code, so it belongs in the commit message. JSDoc that repeats the types (@param {number} price The price) goes in the same bin. And then there’s the classic, // increment counter above counter++, or {/* Header */} above <header>.

try/catch that doesn’t handle anything. Agents are big fans of wrapping calls in a try/catch that logs the error and then rethrows it. Nothing gets handled. The error still bubbles up to whatever catches it next, which probably just logs it again.

- try {
-   return await api.getUser(id)
- } catch (error) {
-   console.error("Failed to fetch user:", error)
-   throw error
- }
+ return api.getUser(id)

One of these is harmless. The problem is that agents paste it around every call, so you end up with dozens of slightly different log lines. Log errors in one place instead, like an error middleware on the server or an error boundary in React, and let everything else throw. You get one log format, one place to set a breakpoint, and less code for everyone, human or agent, to maintain.

The worse version is the silent fallback chain, where the agent tries the v2 API, then v1, then returns mock data. When the real path breaks you ship fake data instead of an error, and nothing tells you which path ran. Sean Goedecke wrote about why agents love these.

Files nobody asked for. IMPLEMENTATION_SUMMARY.md, a test-api.ts scratch script, and test suites far bigger than the feature. Watch for tests that only test the mock, like mocking getUser to return Ann and then asserting that getUser returns Ann. That test passes no matter what the real getUser does.

Backwards compatibility for code nobody else calls. You ask for a rename and get the new function plus a deprecated alias for the old one. In a library this makes sense. In an app you own every caller, so update them and delete the old name. It’s enough of a Claude habit that people have opened issues about it.

  export function getUserProfile(id: string) { ... }
- /** @deprecated Use getUserProfile instead. */
- export const fetchUser = getUserProfile

Reinventing helpers that already exist. The agent needs to format a price, doesn’t look around, and writes a fresh Intl.NumberFormat call next to the formatCurrency you already have. Now there are two, and they’ll drift apart. Agents also tend to duplicate existing code. GitClear’s 2025 report found that in 2024, copy/pasted lines outnumbered moved lines for the first time.

Re-validating the same input at every layer. The route handler parses the body with Zod and passes it to createUser, which checks the email again just in case.

  export async function createUser(input: NewUser) {
-   if (!input.email || !input.email.includes("@")) {
-     throw new Error("Invalid email")
-   }
    return db.user.create({ data: input })
  }

Parse once at each trust boundary and pass the typed result inward. Alexis King’s Parse, don’t validate explains why. Browser-side validation doesn’t count. Your server still parses.

React and TypeScript

useEffect to derive state. If a value can be computed from props or state, compute it during render. The effect version renders once with a stale value and then renders again.

- const [fullName, setFullName] = useState("")
- useEffect(() => {
-   setFullName(`${first} ${last}`)
- }, [first, last])
+ const fullName = `${first} ${last}`

If an effect watches submitted just to show a toast, put the toast in the submit handler.

Props nobody passes. Ask for a badge and you get a design system. There’s one call site, <Badge label="New" />, and this:

  type BadgeProps = {
    label: string
-   variant?: "default" | "success" | "warning" | "destructive"
-   size?: "sm" | "md" | "lg"
-   icon?: ReactNode
-   onClick?: () => void
  }

Those unused props add branches nobody needs yet. Add them when a second call site needs them.

useMemo and useCallback on everything. Memoising a template string saves nothing, and a stable function reference means nothing to a <button>. Plus if you’re on React Compiler, it memoises for you anyway.

- const label = useMemo(() => `${count} items`, [count])
- const handleOpen = useCallback(() => setOpen(true), [])
+ const label = `${count} items`
+ const handleOpen = () => setOpen(true)

Types that inference already gives you. useState(false) is already boolean. And const count: number = 0 is less precise than const count = 0, because the annotation widens the type from 0 to number. Keep the generic when inference can’t know the type, like useState<User | null>(null). The same goes for return types on small local helpers, where inference already gets it right.

Null checks on things that can’t be null. It looks defensive, but user?.name ?? "" on a required field tells the next reader that null is possible, and they’ll go looking for where it comes from.

- const name = user?.name ?? ""
- {items && items.length > 0 && items.map((item) => <Row key={item.id} {...item} />)}
+ const name = user.name
+ {items.map((item) => <Row key={item.id} {...item} />)}

Mapping an empty array already renders nothing. If the type is lying, like if the data came from await res.json() as User, parse it at the boundary instead of sprinkling ?. everywhere.

Markup and CSS

ARIA that repeats the HTML. <nav> is already navigation, <button> is already a button, a button’s text is already its name, and an empty divider has nothing to read out. Agents restate all of them.

- <nav role="navigation">
+ <nav>
- <button role="button" aria-label="Save" onClick={save}>Save</button>
+ <button onClick={save}>Save</button>
- <div className="border-t" aria-hidden="true" />
+ <div className="border-t" />

That aria-label overrides the button’s text. Rename the button to “Publish”, miss the label, and a voice control user saying “click Publish” gets nothing.

One exception: keep <ul role="list"> in Tailwind projects. Preflight strips list styles, and Safari’s VoiceOver then stops announcing it as a list.

Wrappers that don’t do anything. Agents love adding a container “just in case”, and centring is the usual excuse.

- <div className="flex justify-center">
-   <img className="w-32" src="/logo.svg" alt="Acme" />
- </div>
+ <img className="mx-auto w-32" src="/logo.svg" alt="Acme" />

mx-auto works here because the image has a width and Preflight makes images display: block. Keep the wrapper for when it’s actually useful, like anchoring an absolute child, scrolling or clipping.

Classes that do nothing. Some restate the defaults. flex already means row, no wrap, stretch and start, and a plain div is already full width. Others have no job, like relative with no absolutely positioned child, z-10 on something that overlaps nothing, or transition-all on styles that never change. Then there’s the shadcn special, cn() wrapping a single static string.

- <div className="flex w-full flex-row items-center justify-start">
+ <div className="flex items-center">
- <div className={cn("relative z-10 rounded-lg border p-4 transition-all")}>
+ <div className="rounded-lg border p-4">

Check child components for an absolute before you delete a relative. Inputs, buttons, images and children of a flex row do need w-full, since they don’t fill their container on their own. But on a plain div, w-full mx-4 is worse than nothing. It overflows the parent by the margin.

Keeping it out of the next diff

Put a minimal-diff rule in your instructions file. CLAUDE.md, AGENTS.md, .cursor/rules, or whatever your tool reads. Anthropic’s “avoid over-engineering” prompt under Overeagerness is a good starting point. Then list your known offenders, because a vague “be concise” gives the agent nothing to check against, and “no cn() around a single static string” does. Something like this:

Pick the rules that apply to your project. Anthropic’s best practices warn that the agent starts ignoring parts of a bloated file.

Let a linter do the work where possible. Agents ignore instructions sometimes. A failing lint check gives them something concrete to fix. A few of the culprits above have rules already:

OffenderRule
Redundant rolesjsx-a11y/no-redundant-roles (allows <nav role="navigation"> by default, and pass { ul: ["list"], ol: ["list"] } to keep Tailwind’s role="list")
Derived state in effectsreact-hooks/set-state-in-effect (eslint-plugin-react-hooks v7+)
?. and ?? on non-null values@typescript-eslint/no-unnecessary-condition (needs typed linting)
const count: number = 0@typescript-eslint/no-inferrable-types (won’t catch useState<boolean>)
Duplicate and conflicting Tailwind classesbetter-tailwindcss/no-duplicate-classes, no-conflicting-classes (v4 only)
Unused files, exports and dependencies, including that deprecated alias once nothing imports itknip

Biome has some of these too (noRedundantRoles, noInferrableTypes, noUnnecessaryConditions).

Then hook the linter into the agent’s loop so it fixes errors as it goes. In Claude Code, a PostToolUse hook that runs ESLint and exits 2 sends the errors back to the agent. Cursor’s postToolUse hook can do the same.

Do a review pass that can only delete. Ask for it explicitly: “Remove anything in this diff that isn’t strictly necessary. Don’t add anything.” A general “review this” prompt often ends up suggesting more code, not less. Make sure you run it in a fresh session or subagent, so the reviewer isn’t defending its own code. Claude Code’s built-in /simplify does a similar cleanup pass, though it can rewrite as well as delete, and Cursor has a deslop skill.

Review diffs, not just outcomes. Bloat is easy to miss when the feature works. You ask for something simple, it works in the browser, and the diff is three times the size you expected.

Reward deletions. A diff that fixes or adds something and still leaves the codebase smaller is a good sign. When it doesn’t, read what the agent added to make it work.

Related Articles