Open any file an agent writes for a real application and count the keywords: async, await, .map, .filter, import, try. That vocabulary is not advanced JavaScript, it is the JavaScript that actually gets written, and the single biggest wall beginners hit inside it is asynchrony. This lesson takes the wall down deliberately, because you will spend years reviewing code shaped exactly like the broken script at the bottom of this page.
The map: async, deeply
JavaScript runs one line at a time, on one thread. But most interesting operations (an API call, a database query, a file read) take time, and the language refuses to stand still waiting. So slow operations return immediately, handing you not the result but a Promise: an object representing a result that will exist later. A Promise is in one of three states: pending, fulfilled (here is your value), or rejected (here is your error).
async/await is the syntax that makes promises readable. Inside a function marked async, await somePromise pauses that function until the promise settles, then hands you the value. Two rules generate almost everything you need to predict:
- An async function always returns a Promise. Even
async function f() { return 42; }returns a Promise of 42, not 42. The caller must await it to get the number. - await only pauses the function it appears in. The rest of the program keeps going. That is the entire concurrency model in one sentence.
Watch the rules produce a prediction:
async function fetchName() {
return "Dana"; // rule 1: the caller gets a Promise, not "Dana"
}
const result = fetchName();
console.log(result); // Promise { 'Dana' } <- not the string!
const name = await fetchName();
console.log(name); // "Dana"Forgetting an await does not crash on the line where you forgot it. It hands you a Promise where you expected a value, and the crash happens downstream when you treat the Promise like data: report.filter is not a function, [object Promise] in a template string, an if-condition that is always truthy. Burn those three symptoms in; you will see them in agent output, and in your own.
Errors. A rejected promise becomes a thrown error at the await that touches it, which is where try/catch earns its living:
async function safeLookup(id) {
try {
return await lookupContact(id); // the await must be INSIDE the try
} catch (err) {
console.error(`lookup failed: ${err.message}`);
return null;
}
}The subtle version of this bug: return lookupContact(id) inside the try, without await. The function returns the still-pending promise immediately, the try block exits successfully, and when the promise later rejects, your catch is long gone. The error sails past the handler you wrote for it. Same code minus one keyword, completely different failure behavior.
Many at once. To await a whole batch, build an array of promises and await Promise.all(...):
const reports = await Promise.all(contacts.map((c) => lookupContact(c.id)));And the anti-pattern to recognize on sight: array.forEach(async (item) => { ... }). forEach does not understand promises; it fires all the callbacks and returns instantly, awaiting none of them. Anything after the forEach runs before the work is done. When you see it in a review, the fix is Promise.all over map (parallel) or a plain for...of loop with await inside (sequential).
The rest of the pattern vocabulary
Closures, in practice. A function keeps access to the variables that surrounded it when it was created, even after the surrounding function has finished. That is a closure. You use them constantly without ceremony:
function makeCounter() {
let count = 0;
return () => {
count += 1;
return count;
};
}
const next = makeCounter();
next(); // 1
next(); // 2 <- count survives between calls, privatelyEvery event handler, every callback that references an outer variable, is a closure. The practical reading skill is just: when you see a function using a variable it did not declare and was not passed, look one level out; that is where it lives.
Array methods, the working set. map transforms every element. filter keeps some. find returns the first match. reduce boils an array to one value (a sum, usually). some/every answer yes/no questions. They chain, and reading chains is a review skill:
const pipeline = leads
.filter((l) => l.status === "hot")
.map((l) => ({ ...l, priority: l.daysSinceContact > 14 ? "urgent" : "normal" }))
.sort((a, b) => b.daysSinceContact - a.daysSinceContact);Read it top to bottom as a sentence: keep the hot ones, tag each with a priority, order by staleness. The { ...l, priority: ... } shape is the spread idiom: copy the object, add a field, mutate nothing.
Imports across files. import { isStale } from "./stale.js" pulls a named export from a local file; import express from "express" pulls a library's default export from node_modules. When you trace code, imports are your map of where behavior lives.
package.json. Every Node project's manifest. Four fields matter now: name, scripts (command shortcuts: npm run dev runs whatever scripts.dev says), dependencies (libraries the app needs to run), and devDependencies (tools needed only while developing, like TypeScript itself). When you clone any project, npm install reads this file and fills node_modules. When an agent adds a dependency, this file is where you check what it actually added.
Tutor first
You are my async JavaScript tutor. Show me short programs (5 to 12 lines) using async/await, promises, and array methods, and ask me to predict the exact output and its order, one program at a time. I will predict, then run it with node and report back. Include, across the session: a missing await, an async callback inside forEach, a try/catch where the await is outside the try, and one Promise.all. Do not label which bug is which: part of the drill is me recognizing them. After 8, list what I missed.
The lab: fix the follow-up script
Lablab-p-6The broken async script
Goal: Take a script containing three planted async bugs, explain each bug in writing before fixing it, and end with correct output.
Save this exactly as followup.js (or copy it with the button):
// followup.js
// Looks up each lead's activity (a fake CRM call with realistic delays),
// then prints who needs a follow-up today. It is broken in three ways.
const leads = [
{ name: "Dana", daysSinceContact: 12 },
{ name: "Marcus", daysSinceContact: 2 },
{ name: "Priya", daysSinceContact: 30 },
{ name: "Ghost", daysSinceContact: null },
{ name: "Tom", daysSinceContact: 8 },
];
function lookupActivity(lead) {
// Pretend this calls a CRM API. The delay varies per lead.
return new Promise((resolve, reject) => {
setTimeout(() => {
if (lead.daysSinceContact === null) {
reject(new Error(`no activity data for ${lead.name}`));
return;
}
resolve({ ...lead, needsFollowUp: lead.daysSinceContact > 7 });
}, Math.random() * 50);
});
}
async function safeLookup(lead) {
try {
return lookupActivity(lead);
} catch {
return { ...lead, needsFollowUp: false };
}
}
async function buildReport() {
const results = [];
leads.forEach(async (lead) => {
const activity = await safeLookup(lead);
results.push(activity);
});
return results;
}
function main() {
const report = buildReport();
const due = report.filter((r) => r.needsFollowUp);
console.log(`${due.length} leads need follow-up:`);
due.forEach((r) => console.log(` - ${r.name}`));
}
main();The intended behavior: Dana, Priya, and Tom need follow-up (more than 7 days); Marcus does not; Ghost has broken data and should be skipped gracefully, counted as not needing follow-up. Correct output is those three names and the count 3.
The rules: for each bug, you write the explanation in your notes before you change the code. "What is wrong, why does it produce this exact symptom, what is the fix." Then fix, run, and observe the new behavior. The script is built so the bugs reveal themselves one at a time.
- Run it:
node followup.js. Observe:TypeError: report.filter is not a function. Something that should be an array is not an array. Find it, explain it in notes, fix it. (You met this exact symptom in the lesson text.) - Run it again. Observe:
0 leads need follow-up:and then, a beat later, a crash about Ghost. Two separate remaining bugs are now visible at once. Take the count first: why does the report come back empty when five lookups were started? Explain, fix, run. - Run it again. Observe: the Ghost rejection now definitely crashes the program, despite
safeLookupvisibly containing a try/catch built for exactly this. Why does the catch never fire? This is the subtlest of the three. One keyword fixes it. Explain, fix, run. - Final run. Observe:
3 leads need follow-up:with Dana, Priya, Tom listed (order may vary; the delays are random). Ghost was absorbed by the catch and reported as not needing follow-up. Marcus is resting comfortably.
Verify
- The final output shows exactly 3 leads: Dana, Priya, Tom, and exits without error, every time you run it.
- Your notes contain three written explanations, each naming the mechanism (async function returns a promise; forEach does not await; rejection needs an await inside the try to be catchable).
- Explain-back test: paste your three explanations to Claude and ask it to grade them harshly. Survive.
>Troubleshooting
- You fixed the forEach with Promise.all but now nothing prints at all. Check that buildReport returns the awaited array and that main awaits buildReport (and that main itself is async). A missing link anywhere in the await chain reintroduces bug 1 in a new costume.
- The Ghost crash persists after you added await in safeLookup. Make sure the await is inside the try block: return await lookupActivity(lead). An await after the try, or a bare return, both leave the rejection uncaught.
- Different names each run. Only the order may differ (random delays). The set of three names must be stable. If Marcus ever appears, your follow-up threshold drifted; check the comparison.
Where it breaks, in the wild
These are not beginner-only bugs; they are the most common async defects in agent-generated code too, which is the real reason this lesson exists. A model completing "plausible JavaScript" produces forEach(async ...) regularly, because the pattern appears all over its training data. It compiles. It even works, when the list is short and the timing is lucky, and then production has more leads and less luck. When Part 3 teaches you code review, async correctness is one of the first lenses you will apply, and after today you own the three patterns that lens mostly catches.
"The async stuff looks right, it has awaits in it and it ran fine when I tested with two records. Approving."
"forEach with an async callback on line 34: nothing awaits those, so the report can return before the lookups land. Worked in the demo because two records finished inside the timing slack. Swap to Promise.all over map, and move that await inside the try or the catch is decorative."
Knowledge check
Knowledge check
Sources
Promises, async/await, closures, and the array methods are stable ECMAScript semantics: nothing version-volatile to cite. Every behavior claimed here, including the broken script's three-stage failure sequence and its fixed output, was executed and verified with Node during authoring (July 2026). Verify them yourself the same way; that is the lesson.