Skip to main content

Command Palette

Search for a command to run...

The Agent Added an Await to a Synchronous Function. The Caller Didn't Notice.

An agent 'modernized' a sync function by marking it async. TypeScript was happy. The production caller that destructured the return value synchronously started crashing at runtime.

Updated
•3 min read•View as Markdown

A teammate pasted a stack trace into our channel last week. It was a one-liner: Cannot read properties of undefined (reading 'id'), inside a request handler that had been stable for months.

The diff that introduced it was tiny. The agent had 'cleaned up' a utility by marking it async and slapping an await on its internal call. The function still returned the same shape. TypeScript was green. CI was green. The only thing that wasn't green was the live endpoint, which started returning 500s on roughly 1 in 20 requests.

What actually broke

Here's the function before:

function getUserContext(req) {
  return {
    userId: req.session.id,
    roles: req.session.roles || [],
  };
}

And after the agent's pass:

async function getUserContext(req) {
  const session = await getSession(req);
  return {
    userId: session.id,
    roles: session.roles || [],
  };
}

On its own, that's fine. The bug was the caller that had been written when getUserContext was synchronous:

app.get('/dashboard', (req, res) => {
  const ctx = getUserContext(req);
  res.render('dashboard', { userId: ctx.userId });
});

ctx is now a Promise. ctx.userId is undefined. The template renders undefined, and the downstream lookup blows up. TypeScript didn't flag this because the caller was a .js file with loose config, and the few call sites that were typed had been touched by the same agent and were already await-ing.

Why the agent did it

Asked, the agent said it was "aligning the function with the async session API." That's a defensible refactor on paper. The problem is that refactoring one function to async is a cross-cutting change: every caller has to be updated in the same diff, or the sync callers break at runtime. The agent updated the call sites it could see in the same file. It did not grep the whole repo for getUserContext( and verify each one was awaited.

This is the quiet failure mode of "small, clean" AI refactors. The change looks like a strict improvement. The type checker is happy. The tests that exist are the ones the agent itself wrote or touched, and they all await. The call sites nobody wrote a test for are the ones that break in production.

The cheap guard

We started doing two things after this incident.

First, when a function flips from sync to async, treat it as a public-API change. Grep the whole repo for call sites, list them in the PR description, and explicitly mark which ones were updated and which ones were already async. If a caller can't be updated in the same PR, don't flip the function yet — keep the sync version and add a separate getUserContextAsync until the callers migrate.

Second, add a smoke test that exercises the real request path, not just the unit-tested function. The unit test for getUserContext was passing because it awaited. The integration test that actually booted the server and hit /dashboard didn't exist. That test would have caught the Promise-returning caller on the first run.

The deeper pattern

Async/sync is one of those seams that type systems only partially protect. A function that returns T and a function that returns Promise<T> look almost identical in a diff. The call sites that break are the ones that were written when the contract was the other shape, and they tend to live in the files the agent didn't open.

When you review an agent's PR that touches a function signature — even a signature as small as adding async — don't review the function. Review the callers. That's where the bug lives.