Interactive Learning with Claude

A while back I wrote about learning Postgres search by building a playground with Claude. The thesis there was that scaffolding is basically free now, so the time you used to spend setting up a learning project goes into asking better questions instead.

This is a follow-up, but the thing I want to write about is different. I had a handful of Node.js topics I wanted to refresh: promises, event emitters, concurrency, and streams. Stuff I have used for years and never sat down to actually re-examine. Instead of reading docs, I asked Claude to scaffold an interactive, Python-notebook-style set of lessons I could run cell by cell in my editor, modify, and poke at.

What ended up mattering was not the lessons themselves. It was how I used them. Three habits did most of the work.

Learning with examples

The lessons were not prose to skim. Each one was a runnable file split into cells, and every cell printed output. So the loop was: read the small example, run it, look at what actually came back, and use that concrete result to understand the concept.

The output is the teacher. One cell in the concurrency lesson runs the same heavy computation two ways: once inline on the main thread, once offloaded to worker threads. Both versions also start a timer that should tick every 10ms:

function heavy(n: number) {
  let sum = 0;
  for (let i = 0; i < n; i++) sum += Math.sqrt(i) * Math.sin(i);
  return sum;
}

// A probe: this SHOULD tick every 10ms. Count how many times it does.
let ticks = 0;
const probe = setInterval(() => ticks++, 10);

const t0 = performance.now();
const results = [heavy(2e7), heavy(2e7), heavy(2e7)]; // all on the main thread
const elapsed = Math.round(performance.now() - t0);

clearInterval(probe);
`inline: ~${elapsed}ms, results ${results.length}, timer ticked ${ticks}x while blocked`;

The full cell, with the worker version next to it, is in notebooks/3-concurrency.ts. Running both, the output told the whole story:

inline: ~540ms, results 3, timer ticked 0x while blocked
workers: ~259ms, results 3, timer ticked 19x while working

I have read the sentence “CPU work blocks the event loop” a hundred times. Watching the timer tick zero times while the inline version ran, and nineteen times while the workers ran, landed in a way the sentence never did. The number is the lesson.

The other thing examples do is hand you your next question. When the output is interesting or surprising, that is the thread to pull. I was not following a syllabus top to bottom. I was running a cell, noticing something I did not expect, and chasing it. A surprising result is a better prompt than any plan I could have written up front.

Being curious

Most of these topics I had worked with before. That is exactly the trap. It is easy to look at a promise example and think “I know this,” skim it, and learn nothing. The only way the refresh was worth anything was to keep the glass half empty and treat familiar material like I did not fully understand it.

When I did that, real questions showed up. Looking at a for await loop over an event emitter, I stopped and asked whether calling on() in the loop registered a new listener on every iteration, or whether it worked differently from the usual emitter.on(...). Rather than take the answer as prose, I had it proved in a cell:

const e = new EventEmitterBuf();
const iter = on(e, "pulse");
const listeners = e.listenerCount("pulse"); // 1, not growing per iteration

let n = 0;
const timer = setInterval(() => e.emit("pulse", ++n), 10);
const slowDelay = (ms: number) => new Promise((r) => setTimeout(r, ms));
const seen: number[] = [];
for await (const [v] of iter) {
  await slowDelay(40); // body is SLOWER than the producer (10ms)
  seen.push(v);        // yet we still see every value, in order
  if (v >= 5) { clearInterval(timer); break; }
}
// listeners === 1; seen === [1, 2, 3, 4, 5]

It turned out to be a single buffering listener, not one per turn, which is a genuinely different model with real consequences for dropped events. The listenerCount stays at 1 across the whole loop, and a consumer running slower than the producer still sees every value in order. That cell is in notebooks/2-event-emitters.ts. I would have skated right past all of it if I had let myself assume I knew how it worked.

Same with the bounded-concurrency example. It used a shared cursor++ across several workers with no lock:

async function worker() {
  while (cursor < items.length) {
    const i = cursor++;                 // claim next index
    active++; maxActive = Math.max(maxActive, active);
    results[i] = await fn(items[i], i);
    active--;
  }
}

Instead of nodding along I asked why that was safe. The answer, that the increment is synchronous and nothing else can run between the read and the write on a single thread, is the kind of thing you only really absorb when you stop to ask. The full mapLimit is in notebooks/3-concurrency.ts. I also got curious about whether the worker-pool pattern in the example had a proper name, which sent me reading about thread pools and semaphores well outside the chat.

That is the part curiosity unlocks. A conversation with Claude about one example is the starting point, not the whole thing. Half-formed questions sent me off to the docs, to YouTube, to other write-ups, to reinforce a point from a second angle. The chat is good at the specific thing in front of me. The wider reading is what makes it stick.

Challenging the content

The whole exercise started from my prompt. I picked the topics. Claude scaffolded the notebook, proposed a learning plan, and wrote working examples I could modify. That division of labour is the right one: I set the direction, it accelerated the boring parts. But the plan and the explanations are the AI’s, and the moment you forget that is the moment you start absorbing its mistakes as facts.

So I pushed back. The clearest case was a retry example meant to show that a function gets called three times before it succeeds:

let calls = 0;
const recovered = await retry((i) =>
  i < 2 ? Promise.reject(new Error("flaky")) : Promise.resolve("ok@" + i), 5);
({ timedOut, recovered, calls });

It ended by reporting a calls counter, except the counter was declared and never incremented inside the callback, so it always read zero. The demo ran without error and silently did not prove the thing it claimed to prove. If I had taken it at face value I would have walked away thinking the retry never fired.

What makes this a clean example of the whole problem is that the cell passes its verification. It executes, it returns a value, nothing throws. The harness checks “does this run,” not “does the output mean what it says.” So I fixed it: incrementing the counter inside the callback (commit 02f985f) makes the cell actually report calls === 3, which is the function failing twice and succeeding on the third attempt. That gap between “runs” and “is correct” is exactly why none of this works without someone questioning the output.

So more than once I asked Claude to justify a claim or prove it with a runnable cell rather than a paragraph. Asking for proof instead of explanation is a good default when the AI is the one teaching, and it is what surfaced the counter bug in the first place: I wanted to see the retry count, not be told it happened.

There is a failure mode in the other direction too. When something felt thin and I asked for “more detail,” the next version was usually worse: more words, more abstraction, same information. The useful move was not more, it was more concrete. Ask for a smaller example that runs, not a longer paragraph that does not.

What I take from this

The three habits are really one stance. I drove, and Claude scaffolded. My topics, my curiosity about things I supposedly already knew, my skepticism about the explanations. Claude’s job was to make the examples cheap to produce and instant to run, so I could spend my attention on poking at output and chasing the surprising bits.

The honest edge is the same one I landed on in the Postgres post. Running the code tells you whether something works. It does not tell you whether it is worth learning, or whether an explanation is any good. Those still need your own judgement, and they are easier to apply on topics where you already have some taste. For a refresh of things I half-knew, that was fine. I could smell when a claim deserved a second look. For a subject I knew nothing about, I am less sure the same workflow would protect me from a confident wrong answer.

The lessons and exercises are at github.com/gayanhewa/nodejs-learnings if you want to clone them and run the cells yourself. Pick a topic you think you already know and try keeping the glass half empty.