Back to insights

Engineering

AI-Generated Code Needs Requirements It Can Fail

A coding agent can build a feature in minutes. It works in the demo, the tests pass, and the diff looks tidy. A few weeks later it breaks somewhere nobody described: an account with far more data than the fixtures, a slower laptop, a real network, someone using a screen reader. The AI-generated code wasn’t wrong by the standard of the request. The request just never said what had to hold in those conditions. So the agent filled the gaps with assumptions, then wrote tests that confirmed its own code. By the time a code review or a user catches the problem, other work already sits on top of that design.

None of this started with AI-assisted development. People have always written code against requirements nobody stated. Test-driven development was one of the more practical answers: write a failing test, make it pass, then clean up the code while the test keeps you honest. Writing the test first makes you decide what “working” means before any code exists, and the test stays around afterwards to catch regressions. Acceptance tests later took the same idea from single units up to behaviour a user can see.

I still think TDD pays off. It just doesn’t cover the case above by itself. In practice it mostly drives functional behaviour, one unit at a time. It also assumes that whoever writes the test knows what the product needs, and with an agent the same system often writes both the implementation and its tests, so the tests carry the same assumptions. Then there’s the kind of failure frontends are prone to. They break on non-functional things: how much data there is, how fast the device is, when the network answers, what assistive technology receives. Unit tests written first rarely mention any of that.

The part of TDD worth keeping is the idea that a requirement becomes real once something can fail against it. The order in which files get created matters much less. And that idea works far outside unit tests. “Search results always belong to the current query” can be a check. So can “the list stays responsive with 5,000 rows on a throttled CPU”, “a burst of typing keeps at most one request in flight” and “this route’s JavaScript stays within its budget”.

If requirements like these exist as executable checks before design starts, the agent can test its own proposal against them. It learns that a naive list can’t meet the responsiveness budget while it’s still picking an approach, instead of after a reviewer or a user runs into the problem. Quality work moves to the moment the code is designed and written.

Each requirement below states the condition, the observable result, the threshold if there is one, and the environment where it’s measured. Scope, the part that’s easiest to forget, comes up near the end. The examples are constructed for illustration. The numbers are example budgets, not recommendations for every product.

A list that is fast with fifty rows

Ask an agent for a project list with a filter field, and a typical first version filters an array on every keystroke and renders every match. With the fifty fixture rows in the repository it feels instant, and the agent’s own test confirms that typing “billing” leaves the billing projects on screen. Both things are true. Neither tells you anything about an account with 5,000 projects on a mid-range laptop.

A better prompt won’t fix that. What’s missing is a requirement with a size, a threshold and an environment:

With 5,000 projects loaded and the CPU throttled 4×, no keystroke in the filter field takes longer than 200 ms from input to the next paint.

The 200 ms figure comes from the “good” threshold for Interaction to Next Paint (INP), the responsiveness metric in Core Web Vitals. Here it’s an example budget for one interaction in a lab run. The number 5,000 matters as much as the threshold. An agent tests against the data it’s given, and a requirement that doesn’t state a size quietly accepts the fixture size.

In a Chromium-based Playwright setup, the requirement can become a check:

ts
import { expect, test } from '@playwright/test';

declare global {
  interface Window {
    __slowestInteraction: number;
  }
}

test('filtering 5,000 projects stays within the interaction budget', async ({ browserName, page }) => {
  test.skip(browserName !== 'chromium', 'CPU throttling uses the Chrome DevTools Protocol');

  const cdp = await page.context().newCDPSession(page);
  await cdp.send('Emulation.setCPUThrottlingRate', { rate: 4 });

  await page.goto('/projects?fixture=5000');
  await page.evaluate(() => {
    window.__slowestInteraction = 0;
    new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        window.__slowestInteraction = Math.max(window.__slowestInteraction, entry.duration);
      }
    }).observe({ type: 'event', buffered: true }); // reports interactions of 104 ms and longer
  });

  await page
    .getByRole('searchbox', { name: 'Filter projects' })
    .pressSequentially('billing', { delay: 120 });
  await page.waitForTimeout(500); // let the last event-timing entries arrive

  const slowest = await page.evaluate(() => window.__slowestInteraction);
  expect(slowest).toBeLessThanOrEqual(200);
});

The observer reads Event Timing entries. Their duration covers input delay, handler work and the time until the next frame is shown. CPU throttling through the Chrome DevTools Protocol only exists in Chromium, so the test skips itself elsewhere.

That limit applies to the budget, not to the whole requirement. Playwright runs the same test file in Chromium, Firefox and WebKit, so the functional half (the right projects remain after filtering 5,000 of them) can run in all three. Interactive work isn’t tied to Chromium any more either. Safari 27 ships an MCP server, which WebKit introduced in July 2026 and which you switch on in the Developer settings under “Allow remote automation and external agents”. Mozilla publishes Firefox DevTools MCP, which drives Firefox through WebDriver BiDi. Neither documents CPU throttling, so the budget stays a Chromium measurement. The behaviour can still be checked in every engine the product supports.

With the check available while it designs, the agent’s first attempt fails, and that failure is useful. Now it can compare real options: virtualise the rows, paginate, defer the list update behind the input, or move filtering to the server. The check doesn’t pick one for it. It rules out the option that can’t meet the requirement, before anyone builds a screen on top of it.

Each remaining option costs something, and those costs are requirements too. Virtualised rows aren’t in the DOM, so find-in-page can’t see them, and assistive technology has to be told the real row count. If the product depends on either, write that down as a second check. An agent should see requirements that pull in different directions before it commits to a design.

Performance checks are noisier than functional ones. Run them on a consistent machine, keep a margin below the budget, and repeat a failing run before you call it a regression. A passing lab run doesn’t tell you much about field users. It tells you this design fits this budget under these conditions, and that is all the requirement claims.

A search box that multiplies server load

Now move the filter to the server, because the real account holds more projects than the browser should download. A common first version sends a request on every keystroke. Typing “billing” fires seven requests, and six of them answer questions the user hadn’t finished asking. Against a local API on a developer machine, nothing looks wrong. In production the cost grows with every person typing at the same time, and it lands on a service the frontend team may not even own.

The agent can’t see that cost unless the requirement states it:

Typing a seven-character query at 120 ms per keystroke against an API that takes 300 ms sends no more than two search requests, never keeps more than one in flight, and shows results for the final query within 800 ms of the last keystroke.

This requirement squeezes the design from both sides. The request limit rules out sending on every keystroke. The 800 ms limit rules out a long wait before sending: with a 300 ms API, a debounce much longer than a few hundred milliseconds leaves no room. The agent has to find something that fits between the two, and the test tells it whether it did.

Playwright can stand in for the slow API and count what the page sends:

ts
import { expect, test, type Request } from '@playwright/test';

declare global {
  interface Window {
    __searchTiming: { lastInput: number; rendered: number };
  }
}

const isSearch = (url: string) => url.includes('/api/projects?q=');

test('typing a query keeps search load bounded', async ({ page }) => {
  let started = 0;
  let inFlight = 0;
  let maxInFlight = 0;

  await page.route('**/api/projects?q=*', async (route) => {
    await new Promise((resolve) => setTimeout(resolve, 300)); // slow but healthy API
    const query = new URL(route.request().url()).searchParams.get('q') ?? '';
    await route.fulfill({ json: projectsMatching(query) }).catch(() => {}); // may be aborted
  });

  page.on('request', (request) => {
    if (!isSearch(request.url())) return;
    started += 1;
    inFlight += 1;
    maxInFlight = Math.max(maxInFlight, inFlight);
  });
  const settle = (request: Request) => {
    if (isSearch(request.url())) inFlight -= 1;
  };
  page.on('requestfinished', settle);
  page.on('requestfailed', settle);

  await page.goto('/projects');
  await page.evaluate(() => {
    // measure inside the page: the test runner's retries are too coarse for timing
    const timing = { lastInput: 0, rendered: 0 };
    window.__searchTiming = timing;
    document.addEventListener('input', () => (timing.lastInput = performance.now()), true);
    new MutationObserver(() => (timing.rendered = performance.now())).observe(
      document.querySelector('[role="status"]')!,
      { childList: true, characterData: true, subtree: true },
    );
  });

  await page
    .getByRole('searchbox', { name: 'Filter projects' })
    .pressSequentially('billing', { delay: 120 });

  await expect(page.getByRole('status')).toHaveText('12 projects match "billing"');
  const { lastInput, rendered } = await page.evaluate(() => window.__searchTiming);
  expect(rendered - lastInput).toBeLessThanOrEqual(800);
  expect(started).toBeLessThanOrEqual(2);
  expect(maxInFlight).toBe(1);
});

projectsMatching stands for a fixture that returns a known result set, so the status text can be exact. The timing is measured inside the page on purpose. When I measured it from the test process instead, Playwright’s retry intervals made a response that took about 575 ms look like 830 ms, and the test failed a design that met the budget. Request events, unlike CPU throttling, work in all three engines, so this whole check runs in Chromium, Firefox and WebKit.

With the check in place, one design that passes pairs a short debounce with cancelling the previous request through an AbortController. Cancelling cuts load. It doesn’t guarantee correct results, because a response can arrive before the cancellation takes effect. Keeping results tied to the query that produced them is a separate requirement, and it needs its own check. That check stops an agent from treating “requests are aborted” as proof that stale results can’t appear.

This check covers one typing pattern. Someone who pauses halfway through a word is a different case. If the product cares about it, give it its own scenario instead of loosening the threshold on this one.

A button an agent fixes into a worse one

Not every requirement is about speed or load. Some exist to protect behaviour that already works from well-meant changes.

Say you ask an agent to improve the accessibility of a report toolbar. It knows a real rule, that icon-only buttons need an accessible name, and applies it too widely. The text buttons get an aria-label too:

html
<button type="button" aria-label="Submit">Save report</button>

The button already had a name. A native button takes its name from its text, as the APG guidance on accessible names explains. The aria-label replaced “Save report” with “Submit”. A voice-control user who says “click Save report” is now talking to a button that assistive technology knows by a different name. WCAG 2.5.3 Label in Name exists for exactly this mismatch. It’s also an easy change to approve in code review. The diff only adds an attribute, and the attribute looks like an accessibility fix, while the toolbar got worse.

The requirement that prevents it is short:

Every button in the report toolbar has an accessible name that contains its visible text. Icon-only buttons have a name that describes their action.

Playwright computes accessible names, so the requirement can cover the whole toolbar instead of one button:

ts
import { expect, test } from '@playwright/test';

const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');

test('toolbar buttons are named by what people see', async ({ page }) => {
  await page.goto('/reports/demo');
  const toolbar = page.getByRole('toolbar', { name: 'Report actions' });

  for (const button of await toolbar.getByRole('button').all()) {
    const visible = (await button.innerText()).trim();
    if (visible) {
      await expect(button).toHaveAccessibleName(new RegExp(escapeRegExp(visible), 'i'));
    }
  }

  await expect(toolbar.getByRole('button', { name: 'Delete report' })).toBeVisible();
});

The loop applies the rule to every text button, including ones added later. The last assertion checks the icon-only button by its intended name. An agent that adds a mismatched aria-label now fails a check it can run itself, before anyone reviews the change.

The check has limits. Playwright’s name calculation approximates what browsers expose. It isn’t a screen reader session, and a passing test doesn’t make the page WCAG-conformant. It shows one property of one toolbar, which is all the requirement claims.

Requirements need a scope and a home

The three checks above protect three places in the code. The architecture behind them is wider. Six months later someone asks an agent for a second search screen, this time for archived reports, and the agent writes its own request on every keystroke. The old test still passes, because it covers the projects screen, and nothing tells the agent that the earlier decision was meant for every search in the product. Each check covers one screen, so the intent behind it can drift without any test going red. Nobody decides to take on that kind of technical debt. It builds up in AI-generated code one reasonable-looking screen at a time.

Two things slow the drift down. One is a check that covers the whole codebase instead of one screen, for example an import rule that only lets search requests go through the shared search module. Evolutionary architecture calls checks like this fitness functions: automated measures of how close the system stays to an architectural goal. The other is a place where the requirement, its reason and its scope are written down, so an agent building something new can find the rule before any test for it exists.

Spec-driven tools such as OpenSpec and GitHub Spec Kit give that place a structure: requirements with scenarios, kept in the repository and read by the agent when it proposes a change. I care less about the format than about something the earlier examples kept running into. Non-functional requirements are conditional. The 5,000-row budget matters for lists of user data that grow. On a settings page with ten entries it’s wasted effort. Leave the scope out and the requirement gets applied too narrowly, so the new screen ships without it, or too widely, so an agent virtualises every list in the product. The second mistake is the button mistake again: a correct rule in the wrong place.

So put the scope in the requirement itself:

markdown
### Requirement: Responsive filtering of large collections

For any view that filters a user-owned collection which can exceed 1,000 items,
the system SHALL keep each filter keystroke within 200 ms from input to next paint.
Views over fixed or small sets are out of scope.
Reason: accounts grow, and small fixtures hide the cost.

#### Scenario: Filtering 5,000 projects on a slow CPU

- **WHEN** 5,000 projects are loaded, the CPU is throttled 4× and the user types "billing"
- **THEN** no keystroke takes longer than 200 ms from input to next paint

The scenario describes the test from the first example, and the test can point back to the scenario. When the archive search comes along, the agent has a rule that says whether the new screen is in scope, and a reviewer has something concrete to cite if the agent missed it. That’s the fifth part of a checkable requirement: condition, observable result, threshold, environment, and the scope it applies to.

A requirement that only lives in a prompt lasts one conversation. Put it in a specification and a check in the repository, and it applies to every later change, whoever or whatever makes it. That’s what actually makes an agent re-examine its work: a test command it’s expected to run, a hook after each edit, a gate before push that won’t let a red result through. Asking it to be careful does much less. Not every requirement needs a browser, either. A route’s JavaScript budget can be a size check in the build, and a mistake that keeps coming back can become a lint rule.

TDD asked for the same thing, deciding what “working” means before the code exists. What changed is who reads the requirement. Now it’s something that writes code fast and trusts its own tests, so the requirement has to be specific enough to fail. Green tests alone don’t make AI-generated code production ready.

If the product is already running, the hard part is often finding out which requirements it quietly depends on and which of them nothing checks. That’s where a Frontend Risk Audit starts: the tasks that matter, the behaviour they rely on, and a prioritised list of what to verify and fix.

Privacy settings

Choose whether to allow PostHog analytics and session replay. Both remain off until you allow them. You can use the website and contact form without them.

Your saved choice: Not selected

Services and data use

PostHog Cloud EU — optional analytics and session replay

The following functions load only after your permission:

  • Visit and interaction analytics. Page views, selected actions and form submission outcomes help us understand how the website is used and where visitors encounter difficulties.
  • Error and performance monitoring. Browser errors and page performance measurements help us identify technical problems and check fixes.
  • Session replay. Navigation, clicks, scrolling and pointer movements help us investigate interface problems. All page text is masked and the entire contact form is excluded before recording data is sent. Replay does not record your desktop, other applications, camera or microphone.

A random identifier stored in your browser links repeat visits, events and recordings to a pseudonymous profile. These data are not fully anonymous. We do not send your name, email address or form contents to PostHog.

Cloudflare estimates your country from the network connection. We send the country code to PostHog; the original IP address, city and precise location are not stored in analytics events.

We do not sell these data or share them for third parties’ own marketing.

Cloudflare — website operation and security

Cloudflare hosts and delivers the website and protects it from abuse. Turnstile checks browser and connection signals to help prevent automated form submissions. We do not send your form message to the Turnstile widget.

Operational measurements help us monitor technical requests, errors and form processing. We rely on our legitimate interests in keeping the website available, preventing abuse and diagnosing technical faults. We limit this processing to what is necessary for those purposes.

These functions operate independently of your analytics choice. Refusing analytics does not disable them.

Cloudflare also processes and delivers contact-form messages. Permission to use your enquiry to reply is requested separately in the form.

Preferences saved in your browser

We save your selected colour theme and your analytics choice (allow or refuse) in this browser so we can apply them on future visits. These preferences are not used to track visits.

They use local browser storage (localStorage) and work independently of analytics permission.

Your analytics choice is saved for 180 days. This is the duration of your saved choice, not the retention period for analytics data.

Changing your choice and requesting deletion

You can change your choice at any time through Privacy Policy and privacy settings in the footer.

Do not allow stops future analytics and recording. It does not delete data already collected.

To request deletion of analytics linked to this browser, use Request deletion of analytics data on the privacy settings page. The request covers the associated profile, events and session recordings. Deletion is processed asynchronously.

See the Privacy Policy for data retention periods and further information about providers and processing.

Read Privacy Policy ↗

Closing this window with ×, Escape or a click outside saves a refusal and stops analytics and recording.