11 min read

Chapter 12 — The Gym

Ten practice repos with planted bugs, three review drills, and a way to run them like the real interview

Part 4: Practice. Prerequisites: none to start, but every exercise points back to the chapter that explains its mechanism. The code lives in the repo at js-gym/, next to reader/.


How to Read This Chapter

Reading the other eleven chapters builds the mental model. It does not build the skill the interview tests: opening code you did not write, under a clock, and finding what is wrong before you find what is merely ugly. The gym is where you build that.

Each exercise is a small module from the kind of system you already build: a search box, a credit wallet, a multi-provider LLM client, a job runner, a webhook handler, a cache, a stream parser. Each one ships with:

FileWhat it is
README.mdA ticket written by a support person or a teammate. Symptoms only, never the cause. Acceptance criteria, a time box, and hints hidden behind a click.
src/The code under review. Plain ESM, 60–200 lines per file, written to look like production code, not a puzzle.
test/node:test suites. The ticket tests fail on the code as shipped; a few baseline tests already pass, so you can't "fix" a bug by breaking working behaviour.
SOLUTION.mdEvery bug: file and line, the exact interleaving, the fix, the chapter, how to say it in the interview, and design improvements beyond the bugs.

The tests are deterministic. Timing is controlled with mock.timers, hand-resolved promises and fake I/O that takes one event-loop turn per call, so a race fails the same way on every run. Nothing touches the network and every test has a timeout.


Part 1: Setting Up

You need Node 22 or newer and nothing else: no npm install.

bash
cd engineering-reader/js-gym
npm run test:01

Each exercise has a script, test:01 to test:10. Without npm, quote the glob (Node 22's --test does not accept a bare folder name, but it expands a quoted glob itself):

bash
node --test "01-search-box/**/*.test.js"

npm test runs every suite and fails by design: the bugs are planted.

Don't commit your fixes. The repo keeps the buggy version so you can do each exercise again in a few weeks, when you've forgotten the answer. Work on a throwaway branch (git switch -c gym-attempt-1) and discard it afterwards.


Part 2: The Method — One Exercise, Step by Step

The interview rewards a visible method more than a fast fix. Use the same loop on every exercise, so it is automatic on the day.

  1. Read the ticket, not the code. Write the symptoms down in your own words. "Sometimes shows the wrong list" is a timing bug until proven otherwise.
  2. Run the tests and read the failures. A failing test's name is a second ticket.
  3. Read src/ top to bottom and mark every await, callback and timer. At each mark ask: what else can run here, and what shared state can it change?
  4. Write the interleaving down before you fix anything, as two columns of steps. If you can't write it, you haven't found the bug yet.
  5. Fix it with the smallest correct change. Re-run. Keep the baseline tests green.
  6. Say it out loud: symptom, mechanism, fix, trade-off. Under a minute per bug.
  7. Compare with SOLUTION.md, especially "design improvements". That section is what separates a fix from a review.

Here is the loop on a small example that is not in the gym, so nothing is spoiled.

The ticket: "Some new users see two workspaces in the sidebar after signing up. Support deletes one by hand. Started after we moved workspace creation into the login handler."

The code:

js
export async function ensureWorkspace(db, userId) {
  const existing = await db.findWorkspace(userId)
  if (existing) return existing
  return db.createWorkspace(userId)
}

Step 3: there are two awaits, one in findWorkspace and one inside createWorkspace. The shared state is the workspaces table. The question at the first await: what if a second call for the same user runs here? On first login the web app and the mobile app both call the login handler, so two calls for the same user is normal, not exotic.

Step 4, the interleaving:

#
Login from web
Login from app
workspaces for u1
1
findWorkspace(u1) → null
0 rows
1 / 6

Step 5, the fix. Make the database enforce the rule, and let the insert be the check:

js
// Schema: CREATE UNIQUE INDEX ON workspaces (user_id);
export async function ensureWorkspace(db, userId) {
  // INSERT … ON CONFLICT (user_id) DO NOTHING RETURNING *
  const created = await db.createWorkspace(userId)
  return created ?? db.findWorkspace(userId)
}

A test that proves both the bug and the fix only needs a fake database whose calls each take one turn of I/O, and two calls started together:

js
test('two first-login requests create one workspace', async () => {
  const db = fakeDb({ unique: true })
  const [a, b] = await Promise.all([ensureWorkspace(db, 'u1'), ensureWorkspace(db, 'u1')])
  assert.equal(db.rows.length, 1)
  assert.equal(a.id, b.id)
})

Step 6, saying it:

"Two logins for the same new user both run findWorkspace before either inserts, so both see nothing and both create. It's check-then-act across an await, the same race as SELECT then INSERT in two transactions. An in-process lock wouldn't fix it, because the two requests can land on different instances. So I'd put a unique index on user_id and make the insert the check, with ON CONFLICT DO NOTHING, then read the row if we lost. The trade-off is one extra query for the loser, which only happens on first login."

Why not just wrap it in a mutex or single-flight?

Both work only inside one Node process. With two API instances behind a load balancer, the web login and the app login can hit different processes, and each process's lock is empty. The database is the only thing both requests share, so the database must decide. A mutex is still useful as an optimisation in front of the constraint (fewer wasted queries), never instead of it. That is the argument to make out loud: name the scope of the lock.


Part 3: The Exercises

#ExerciseThemeTime boxTrainsChapters
0101-search-boxsearch-as-you-type in the admin panel30 minstale responses, abort, debounce, error state5, 6, 11
0202-credit-walletcredits ledger over a fake async DB35 mincheck-then-act, double refund from two recovery paths, money as floats5, 7
0303-llm-clientfailover across three LLM providers40 minper-attempt timeouts, retry policy, aborting the loser, circuit breaker states3, 6, 7
0404-job-runnerin-process job queue and poller30 minconcurrency limit, unhandled rejections, shutdown, overlapping intervals3, 4, 8
0505-webhook-handlerpayment-provider webhooks35 minidempotency, out-of-order events, ack before persist, constant-time compare4, 5, 8
0606-cache-layerread-through cache with TTL30 minstampede, caching a rejected promise, TTL units, invalidation during a load3, 5, 7
0707-event-buspub/sub for the admin panel25 minremoving during emit, one throw stopping the rest, leaks2, 6
0808-stream-parseran LLM provider's SSE stream40 minchunk boundaries, split UTF-8, CRLF lines, abort and iterator cleanup6, 9
0909-legacy-callbacksan old callback-style module25 minZalgo, callback called twice, broken promisify, swallowed errors2, 11
1010-polyfill-katabuild, not fix15 min per filePromise.all / allSettled / any / race, debounce, throttle, bind, asyncPool, promisify, retry11
—review-drills/30-minute reviews of larger files30 min eachfinding everything, ranking it, defending the fixes10

Each exercise README has its own time box; the table gives a guide. If you blow through a time box, stop, open one hint, and note which mechanism you missed. That note is more useful than finishing.

Suggested order: 01 → 02 → 04 → 05 → 03 (the biggest), then 06–09. Do 10 in short sets whenever you have fifteen minutes. Save the review drills for last and run them as mock interviews.


Part 4: Review Drills as a Mock Interview

The three review drills are 200–300 line files (a story-generation service, React admin-dashboard hooks, a sync worker) with 10–15 planted problems across correctness, races, error handling, leaks, performance and design. They have no tests, and the real interview usually doesn't either. Each has an -ANSWERS.md file with the ranked findings. Run each one like this.

  1. Set a 30-minute timer. Close the reader.
  2. Minutes 0–5: skim the whole file. Write one line on what it does and where its state lives. Don't fix anything.
  3. Minutes 5–20: go through it with the checklist from Chapter 10. Write every finding as one line: line number, symptom, mechanism.
  4. Minutes 20–25: rank the findings. Money and data loss first, then correctness, reliability, performance, style.
  5. Minutes 25–30: say your top five out loud, as if to the interviewer, with a fix and a trade-off for each. Record yourself if you can stand it.
  6. Then open the answer file and score yourself.
ScoreMeaning
Found it, ranked it right, fix is correctFull marks.
Found it, but the fix has a holeCommon with races: the in-process lock across several instances, the abort without latest-wins. Note the hole.
Found the symptom, not the mechanism"This could be slow" with no reason. Half marks; the mechanism is what they're listening for.
Missed itWrite the mechanism on a card and re-read the chapter section it points to.
Flagged style as a top-five issueCosts you. Seniority shows in what you leave off the list.

Using an AI as the interviewer

The format you're preparing for often includes an AI-assisted round, and an AI also makes a decent sparring partner. Paste a drill file into Claude with something like this:

text
You are interviewing me for a senior full-stack (Node/TypeScript) role.
I will review the file below out loud. Do not tell me the bugs.
After each finding I give, ask one follow-up question an experienced
interviewer would ask: "what if there are two instances?", "how would you
test that?", "what does that cost?", "why not just X?".
After 30 minutes, or when I say "done", list what I missed, ranked by
severity, and grade how well I explained each mechanism.

The follow-up questions are the point. "What if there are two instances of this service?" catches more weak answers than any bug does.


Part 5: A Three-Week Plan

For someone with interviews coming up, one to two hours a day:

WeekReadPractise
1Chapters 5 and 4, with every simExercises 01, 02, 05. Kata: Promise.all, allSettled, debounce
2Chapters 7, 8, 6Exercises 03, 04, 06. Kata: asyncPool, retry, throttle
3Chapters 10, 9, then skim 1–3 and 11Exercises 07, 08, 09. Both review drills as timed mocks. The "defend your design" drill in Chapter 10

In week 3, redo whichever week-1 exercise you found hardest, cold. If you solve it in half the time and can say the mechanism in one breath, the model has stuck.


The Causal Chains

Self-test: predict each consequence before revealing it.
  • If you read the code before the ticket
  • If you fix before writing the interleaving
  • If your lock lives in process memory
  • If you can name the symptom but not the mechanism
  • If you commit your gym fixes
  • If you rank a naming nit above a double charge

Review Checklist: The First Five Minutes of Any Exercise

  1. Write the ticket's symptoms in your own words, one line each.
  2. Run the tests; read each failing test's name as a second ticket.
  3. List the module's shared mutable state: module-level variables, this fields, caches, the database.
  4. Mark every await, callback, timer and event listener in src/.
  5. For every external call, note its timeout, its retry, its cancellation and whether it's idempotent.
  6. Find the gap between every read of shared state and the write that depends on it.
  7. Check how errors leave each async function: awaited, returned, or dropped on the floor.
  8. Only then, start fixing.

What's Next

You've reached the end of the section. Go back to the Course Map and pick the chapter behind the exercise you found hardest. Then run exercise 01 again in three weeks and time yourself.