Coreframe Labs
Field Guide/Backend & Data

Where AI coding assistants get backends wrong

Backend code generated by an assistant is usually correct for one request, one user, and a hundred rows. The failures start at the second concurrent request and the millionth row.

Coreframe Labs · · 6 patterns · 7 min read

Correct in isolation, wrong under load

A backend endpoint has two specifications. The first is what it returns — and an assistant will satisfy that one reliably, because it is what the prompt described and what the test asserts. The second is how it behaves when two of them run at once, when the row count is six orders of magnitude larger than the seed data, and when the third-party call it depends on returns a 503 halfway through.

That second specification is almost never in the prompt, rarely in the tests, and invisible in code review unless the reviewer is specifically looking for it. It is also where every production incident comes from.

The patterns below share a shape: the generated code is not wrong, it is under-specified. Each one passes its test suite. Each one has an obvious failure the moment the conditions stop being the demo conditions.

Pattern 01

Read-modify-write without atomicity

What we see

A counter, a balance, a stock level, or a status transition implemented as: read the current value, compute the new one in application code, write it back. Under sequential requests this is correct. Under two concurrent requests, one of the two updates is silently lost — and no error is raised anywhere, because both writes succeeded.

Why an assistant produces it

Read-then-write is how the operation is described in English, and generated code follows the shape of the description. Expressing it as a single atomic statement requires knowing that concurrency is a concern, which the prompt did not say.

The pattern

const account = await db.account.findUnique({ where: { id } });
await db.account.update({
  where: { id },
  data: { credits: account.credits - cost },
});

The fix

// Atomic in the database — the read and the write are one statement.
const updated = await db.account.updateMany({
  where: { id, credits: { gte: cost } },
  data: { credits: { decrement: cost } },
});
if (updated.count === 0) throw new InsufficientCredits();

The correct implementation

Push the arithmetic into the database as a single statement, with the precondition in the WHERE clause so the guard and the write cannot be separated by another transaction. Where the logic is genuinely too complex for that, use an explicit optimistic-concurrency version column and fail the write when the version has moved — but a decrement is not too complex for that.

How to test it

Fire N concurrent requests at the endpoint and assert the final value equals the expected one. Ten parallel requests against a local database will surface it; you do not need a load testing tool. If the final value is non-deterministic across runs, you have found it.

Pattern 02

N+1 queries hidden behind the ORM

What we see

A list endpoint that loads a collection and then, inside the serialisation loop, touches a relation on each item. The ORM makes this look like property access, so it does not read like a query. On the seed dataset of twenty rows it is 21 queries and 40ms. On a real account with 5,000 rows it is 5,001 queries and a timeout.

Why an assistant produces it

Lazy-loading relations is the ergonomic default of every ORM, and the generated code reads more naturally without an explicit include. There is nothing in the source to indicate a query is happening.

The pattern

const orders = await db.order.findMany({ where: { orgId } });
return orders.map((o) => ({
  id: o.id,
  customer: o.customer.name,  // one query per order
  lines: o.lines.length,      // and another
}));

The fix

const orders = await db.order.findMany({
  where: { orgId },
  select: {
    id: true,
    customer: { select: { name: true } },
    _count: { select: { lines: true } },
  },
});

The correct implementation

Select the shape you are going to serialise, explicitly, at the query. Then make the class of bug visible in CI: most ORMs can log query counts per request, and asserting that a list endpoint issues a constant number of queries regardless of result size is a cheap test that catches every future regression of this kind.

How to test it

Seed 1,000 rows instead of 20 and time the endpoint, with query logging on. The tell is a query count that scales with the row count. This is also the single highest-yield thing to check in any AI-assisted codebase — it is the most common performance defect we find, by a wide margin.

Pattern 03

Retries without idempotency

What we see

A retry wrapper around an outbound call — a payment capture, an email send, a webhook delivery — with exponential backoff and a sensible attempt limit. What is missing is an idempotency key. When the first attempt actually succeeded but the response was lost to a timeout, the retry executes the operation a second time. The customer is charged twice.

Why an assistant produces it

"Add retries with backoff" is a complete, well-formed request that the assistant answers completely. Idempotency is a property of the operation being retried, not of the retry logic, so it lives outside the boundary of what was asked.

The pattern

for (let i = 0; i < 3; i++) {
  try {
    return await stripe.paymentIntents.create({ amount, currency });
  } catch {
    await sleep(2 ** i * 200);
  }
}

The fix

// One key per logical operation, stable across every retry.
const key = `capture:${orderId}`;
for (let i = 0; i < 3; i++) {
  try {
    return await stripe.paymentIntents.create(
      { amount, currency },
      { idempotencyKey: key },
    );
  } catch (err) {
    if (!isRetryable(err)) throw err;   // don't retry a 400
    await sleep(2 ** i * 200 + Math.random() * 100); // jitter
  }
}

The correct implementation

Every retried operation with a side effect needs an idempotency key derived from the logical operation, not from the attempt. Two other things belong in the same wrapper: only retry errors that are actually retryable — retrying a 400 just delays the failure — and add jitter, because synchronised backoff across many clients reconstructs the thundering herd the backoff was meant to prevent.

How to test it

Point the client at a proxy that forwards the request and then drops the response. The operation should complete exactly once. Check the downstream system, not your own logs — your logs will say it failed.

Pattern 04

Pagination that is unbounded or offset-based at depth

What we see

Two variants. The first: a limit parameter taken straight from the query string with no ceiling, so a client sending ?limit=1000000 pulls the whole table into memory. The second: OFFSET-based pagination, which is correct but degrades linearly — the database scans and discards every skipped row, so page 5,000 is thousands of times more expensive than page 1.

Why an assistant produces it

LIMIT/OFFSET is the canonical pagination example everywhere. Keyset pagination is correct but wordier, and nothing in "add pagination to this endpoint" suggests the deep-page case matters.

The pattern

const limit = Number(req.query.limit ?? 20);
const offset = Number(req.query.offset ?? 0);
return db.event.findMany({ where: { orgId }, take: limit, skip: offset });

The fix

const limit = Math.min(Number(req.query.limit ?? 20), 100);
// Keyset: seek from the last row seen instead of counting past it.
const cursor = req.query.after as string | undefined;
return db.event.findMany({
  where: { orgId, ...(cursor ? { id: { lt: cursor } } : {}) },
  orderBy: { id: "desc" },
  take: limit,
});

The correct implementation

Clamp the page size server-side, always. Use keyset (cursor) pagination for anything a user can page deeply through or that a client will iterate to exhaustion — a feed, an export, an audit log. Offset is fine for a ten-page admin table and nothing larger.

How to test it

Send limit=1000000 and confirm you get 100 rows back rather than an out-of-memory crash. Then time page 1 against page 5,000 on a table with a million rows; if the latency ratio tracks the page number, it is offset pagination and it will not survive an export.

Pattern 05

Migrations that take a lock the deploy cannot afford

What we see

A generated migration that adds a NOT NULL column with a default, adds an index without CONCURRENTLY, or changes a column type on a large table. Every one of these is correct SQL. On a 200-row development database each runs in milliseconds. On a 50-million-row production table, they take an ACCESS EXCLUSIVE lock and every read of that table blocks until the migration finishes.

Why an assistant produces it

The migration tool generates the straightforward statement, and the assistant has no knowledge of the production row count or the lock semantics of the specific Postgres version in play. It is correct at the level it is operating.

The pattern

ALTER TABLE events ADD COLUMN source text NOT NULL DEFAULT 'api';
CREATE INDEX idx_events_org ON events (org_id);

The fix

-- Separate the lock-free steps; backfill in batches between them.
ALTER TABLE events ADD COLUMN source text;              -- instant
-- (application starts writing `source`; backfill old rows in batches)
ALTER TABLE events ALTER COLUMN source SET NOT NULL;    -- after backfill

CREATE INDEX CONCURRENTLY idx_events_org ON events (org_id);

The correct implementation

Split any migration touching a large table into steps that each take a short lock, and build indexes concurrently. The general rule: the deploy must be safe with the old code and the new code both running, because during a rolling deploy they are. That rules out renaming and dropping columns in the same release as the code change.

How to test it

Run the migration against a restored production-sized snapshot with a concurrent read workload running, and measure how long that workload is blocked. This is the only test that means anything — a migration is fast on every dataset except the one it will actually run on.

Pattern 06

Background jobs with no dead letter and no visibility

What we see

A queue consumer that processes a job, catches errors, logs them, and acknowledges the message. The job is now gone. Nothing retried it, nothing recorded that it failed permanently, and nothing is monitoring the gap between jobs enqueued and jobs completed. The system appears healthy while quietly dropping work.

Why an assistant produces it

Catching the error and acking is what stops the consumer crash-looping, which is the visible problem the code was written to solve. A dead-letter queue is a piece of infrastructure, not a piece of code, so it is outside what a code-generation prompt produces.

The pattern

queue.process(async (job) => {
  try {
    await handle(job.data);
  } catch (err) {
    logger.error("job failed", err);  // and it's gone
  }
});

The fix

queue.process(async (job) => {
  try {
    await handle(job.data);
  } catch (err) {
    if (job.attemptsMade < MAX_ATTEMPTS) throw err;  // let the queue retry
    await deadLetter.add(job.name, { data: job.data, error: String(err) });
    metrics.increment("job.dead_lettered", { job: job.name });
  }
});

The correct implementation

A failed job goes to a dead-letter queue with the payload and the error attached, and dead-lettering increments a metric that someone alerts on. The alert should be on queue depth and on job age, not just on error rate — a consumer that has stopped consuming produces zero errors.

How to test it

Make the handler throw deterministically, enqueue ten jobs, and then go looking for them. They should be in the dead-letter queue, not in the log file. Separately, stop the consumer entirely and confirm something alerts within your stated recovery window.

What to review in AI-assisted backend code

The common thread is that every one of these passes its tests. So the review has to ask questions the tests do not: what happens twice, what happens at scale, and what happens when the thing downstream is broken.

  1. 01For every mutation, ask what two concurrent copies of this request do.
  2. 02For every list endpoint, log the query count and confirm it does not scale with the result size.
  3. 03For every retry, find the idempotency key — or explain why the operation has no side effect.
  4. 04Clamp every client-supplied limit server-side, and use keyset pagination anywhere deep paging is possible.
  5. 05Review every migration against production row counts and lock semantics, not against the dev database.
  6. 06For every background job, name the dead-letter destination and the alert that fires when it fills.
  7. 07Confirm every transaction boundary actually wraps the operations that must succeed or fail together.

None of these are exotic failures. They are the ordinary consequences of code written to satisfy a functional description, by a system that was not told what the production conditions are — because nobody thought to say.

The fix is not to stop generating backend code. It is to review it against the second specification: the one about concurrency, scale, and partial failure that never made it into the prompt.

Preparing a codebase for real user data?

The Technical Audit runs this framework against your system: five business days, a written report separating passed controls from failed and deferred ones, a live findings call, and a costed remediation plan you own. Or start smaller — paste one file into the free lite audit and see how we think before you pay for it.