The JavaScript mistakes language models actually make
Async is where most of it lives. The rest is stale idioms from a decade of training data written before the language got good.
JavaScript's problem is the opposite of Go's. There is no compiler to refuse anything, the language has changed enormously over fifteen years, and the training data spans all of it. So generated JavaScript is usually correct and frequently written in the dialect of 2016.
The bugs cluster in three places: asynchrony, mutation, and the gap between what the runtime does and what the code appears to say.
Async#
1. Floating promises#
async function save(user) {
db.write(user); // not awaited. errors vanish, ordering is undefined.
return { ok: true };
}The most common real bug in generated JavaScript. The function returns before the write happens, the error becomes an unhandled rejection, and in Node 15+ an unhandled rejection terminates the process.
Catch it with: @typescript-eslint/no-floating-promises. This rule alone justifies running TypeScript-aware linting even on a JavaScript codebase (via JSDoc types or checkJs).
2. await in a loop where it should be parallel#
for (const id of ids) {
results.push(await fetchUser(id)); // 100 sequential round trips
}Correct, and 100x slower than it needs to be. Generated code reaches for the loop because it reads more clearly.
const results = await Promise.all(ids.map(fetchUser));
// with a concurrency limit, which you usually want
const results = [];
for (const batch of chunk(ids, 10)) {
results.push(...await Promise.all(batch.map(fetchUser)));
}Catch it with: no-await-in-loop, then whitelist the cases where sequencing is deliberate.
3. Promise.all where you meant allSettled#
Promise.all rejects on the first failure and abandons the rest. For "fetch these ten things, show me what you got", that is wrong. Promise.allSettled is almost always what generated batch code should have used.
4. forEach with an async callback#
items.forEach(async (item) => {
await process(item); // forEach ignores the returned promise
});
console.log("done"); // prints immediately. nothing is done.Looks like it awaits. Does not. Catch it with: no-misused-promises.
5. Race conditions in React effects#
useEffect(() => {
fetch(`/api/user/${id}`)
.then(r => r.json())
.then(setUser); // a stale response can arrive after a newer one
}, [id]);Change id twice quickly and the slower first request can resolve last, leaving the wrong user on screen. Generated effect code omits the cleanup almost every time.
useEffect(() => {
const ac = new AbortController();
fetch(`/api/user/${id}`, { signal: ac.signal })
.then(r => r.json())
.then(setUser)
.catch(e => { if (e.name !== "AbortError") throw e; });
return () => ac.abort();
}, [id]);6. try/catch that does not catch#
try {
setTimeout(() => { throw new Error("boom"); }, 0); // uncatchable here
} catch (e) { /* never runs */ }The same applies to any callback invoked later. Errors only propagate up the call stack that exists when they are thrown.
Mutation and equality#
7. Mutating props, state or arguments#
function addTax(order) {
order.total *= 1.2; // mutates the caller's object
return order;
}Generated code mutates freely because most JavaScript in the training data does. In React it produces components that do not re-render; elsewhere it produces action at a distance.
Catch it with: no-param-reassign with props: true, and Object.freeze on shared config in development.
8. Shallow copy assumed to be deep#
const copy = { ...original }; // nested objects are still shared
copy.address.city = "Paris"; // original.address.city is now ParisCorrect: structuredClone(original) — built in, no library needed.
9. == and truthiness on values that can be 0 or ""#
if (!count) { /* also true when count is 0 */ }
const name = input.name || "anonymous"; // "" becomes "anonymous"Correct: ?? for nullish defaults, explicit === undefined checks. eqeqeq in ESLint.
10. sort() on numbers#
[10, 9, 1].sort() // [1, 10, 9] — it sorts as stringsStill generated. Also: sort mutates in place. Use toSorted((a, b) => a - b) in modern runtimes.
Stale idioms#
Not bugs, but a reliable signal that the generated code came from older training data — and worth correcting in AGENTS.md because they accumulate.
| Generated | Current |
|---|---|
var | const / let |
require() in an ESM project | import |
moment | Temporal, or Intl.DateTimeFormat |
lodash.get | optional chaining ?. |
axios for one request | fetch (stable in Node since 18) |
new Promise wrapping a callback | util.promisify, or the promise API that already exists |
Array.prototype.indexOf(x) !== -1 | .includes(x) |
JSON.parse(JSON.stringify(x)) | structuredClone(x) |
__dirname in ESM | import.meta.dirname |
Node specifics#
11. Unhandled rejection kills the process#
Since Node 15 the default is to terminate. Generated background jobs and event handlers frequently have no .catch, which turns a transient failure into a restart.
12. Blocking the event loop#
fs.readFileSync, JSON.parse on a large payload, crypto.pbkdf2Sync, a synchronous loop over a big array in a request handler. One request blocks all of them.
13. Path traversal in file handlers#
app.get("/files/:name", (req, res) => {
res.sendFile(path.join(DIR, req.params.name)); // ../../etc/passwd
});Correct: resolve, then verify the result is still inside DIR.
The config that catches most of it#
import js from "@eslint/js";
import ts from "typescript-eslint";
export default [
js.configs.recommended,
...ts.configs.recommendedTypeChecked, // needs type information
{
languageOptions: { parserOptions: { projectService: true } },
rules: {
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-misused-promises": "error",
"@typescript-eslint/await-thenable": "error",
"no-await-in-loop": "warn",
"require-atomic-updates": "error",
"no-param-reassign": ["error", { props: true }],
"eqeqeq": ["error", "smart"],
},
},
];The type-checked rules are the important part. no-floating-promises cannot work without type information, and it is the highest-value rule in the list. On a plain JavaScript project, enable checkJs in jsconfig.json to get it.
The short version
Turn on type-aware ESLint and the async rules. Most of the top half of this page becomes a build failure, and what is left — effect races, blocking the loop, path traversal — is a short enough list to hold in your head while reviewing.
Common questions#
Do I need TypeScript to get these lint rules?#
No, but you need type information. Adding jsconfig.json with checkJs: true gives ESLint enough to run the type-aware rules on plain JavaScript. It is a much smaller step than a TypeScript migration and it unlocks the rule that matters most.
Why is so much generated JavaScript written in an old style?#
Because the training data is dominated by a decade of pre-2020 JavaScript, and the language changed a great deal in that time. var, moment, callback wrapping and axios are all overwhelmingly represented. It is rarely harmful, but it accumulates, and a few lines in AGENTS.md correct it.
Is no-await-in-loop too aggressive?#
As an error, yes — sequential awaits are correct when each iteration depends on the last, or when you are deliberately rate-limiting. As a warning it is well calibrated: it makes you justify the sequencing, which is exactly the question worth asking.
Get the JavaScript agent pack
A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for JavaScript. One email, then occasional updates when the tooling shifts. No course pitch.
AGENTS.md now — no email needed.
Disclosure: some links on this page are affiliate links. If you buy something through one, we earn a commission at no extra cost to you. We only list tools we would tell a friend to use, and we say so when we have not used something ourselves. This is how the site stays free and ad-light.