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:
| File | What it is |
|---|---|
README.md | A 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.md | Every 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.
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):
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.
- 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.
- Run the tests and read the failures. A failing test's name is a second ticket.
- Read
src/top to bottom and mark everyawait, callback and timer. At each mark ask: what else can run here, and what shared state can it change? - 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.
- Fix it with the smallest correct change. Re-run. Keep the baseline tests green.
- Say it out loud: symptom, mechanism, fix, trade-off. Under a minute per bug.
- 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:
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:
Step 5, the fix. Make the database enforce the rule, and let the insert be the check:
// 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:
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
findWorkspacebefore 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 onuser_idand make the insert the check, withON 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
| # | Exercise | Theme | Time box | Trains | Chapters |
|---|---|---|---|---|---|
| 01 | 01-search-box | search-as-you-type in the admin panel | 30 min | stale responses, abort, debounce, error state | 5, 6, 11 |
| 02 | 02-credit-wallet | credits ledger over a fake async DB | 35 min | check-then-act, double refund from two recovery paths, money as floats | 5, 7 |
| 03 | 03-llm-client | failover across three LLM providers | 40 min | per-attempt timeouts, retry policy, aborting the loser, circuit breaker states | 3, 6, 7 |
| 04 | 04-job-runner | in-process job queue and poller | 30 min | concurrency limit, unhandled rejections, shutdown, overlapping intervals | 3, 4, 8 |
| 05 | 05-webhook-handler | payment-provider webhooks | 35 min | idempotency, out-of-order events, ack before persist, constant-time compare | 4, 5, 8 |
| 06 | 06-cache-layer | read-through cache with TTL | 30 min | stampede, caching a rejected promise, TTL units, invalidation during a load | 3, 5, 7 |
| 07 | 07-event-bus | pub/sub for the admin panel | 25 min | removing during emit, one throw stopping the rest, leaks | 2, 6 |
| 08 | 08-stream-parser | an LLM provider's SSE stream | 40 min | chunk boundaries, split UTF-8, CRLF lines, abort and iterator cleanup | 6, 9 |
| 09 | 09-legacy-callbacks | an old callback-style module | 25 min | Zalgo, callback called twice, broken promisify, swallowed errors | 2, 11 |
| 10 | 10-polyfill-kata | build, not fix | 15 min per file | Promise.all / allSettled / any / race, debounce, throttle, bind, asyncPool, promisify, retry | 11 |
| — | review-drills/ | 30-minute reviews of larger files | 30 min each | finding everything, ranking it, defending the fixes | 10 |
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.
- Set a 30-minute timer. Close the reader.
- Minutes 0–5: skim the whole file. Write one line on what it does and where its state lives. Don't fix anything.
- Minutes 5–20: go through it with the checklist from Chapter 10. Write every finding as one line: line number, symptom, mechanism.
- Minutes 20–25: rank the findings. Money and data loss first, then correctness, reliability, performance, style.
- 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.
- Then open the answer file and score yourself.
| Score | Meaning |
|---|---|
| Found it, ranked it right, fix is correct | Full marks. |
| Found it, but the fix has a hole | Common 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 it | Write the mechanism on a card and re-read the chapter section it points to. |
| Flagged style as a top-five issue | Costs 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:
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:
| Week | Read | Practise |
|---|---|---|
| 1 | Chapters 5 and 4, with every sim | Exercises 01, 02, 05. Kata: Promise.all, allSettled, debounce |
| 2 | Chapters 7, 8, 6 | Exercises 03, 04, 06. Kata: asyncPool, retry, throttle |
| 3 | Chapters 10, 9, then skim 1–3 and 11 | Exercises 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
- 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
- Write the ticket's symptoms in your own words, one line each.
- Run the tests; read each failing test's name as a second ticket.
- List the module's shared mutable state: module-level variables,
thisfields, caches, the database. - Mark every
await, callback, timer and event listener insrc/. - For every external call, note its timeout, its retry, its cancellation and whether it's idempotent.
- Find the gap between every read of shared state and the write that depends on it.
- Check how errors leave each async function: awaited, returned, or dropped on the floor.
- 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.