Async and race defects
A handler is atomic only between its awaits: find the gap between a check and its write, and let the shared store close it.
Predict first
Before you read: predict
ExamHall's final submit. MR !3370 shipped this service and migration, and its unit test (one submit, one grading) passes:
-- migrations/002_gradings.sql
CREATE TABLE gradings (
id serial PRIMARY KEY,
attempt_id text NOT NULL REFERENCES attempts (id),
score int NOT NULL
);
CREATE INDEX gradings_attempt_idx ON gradings (attempt_id);
A candidate has attempt a-1 open in two tabs and clicks Submit in both. Both requests reach the same Node process, and the second arrives while the first is waiting for its SELECT … FROM gradings.
Afterwards, what does gradings hold for a-1, and what did the tabs get?
// src/grading/submit.service.tsexport class SubmitService { constructor(private readonly db: Db) {} async submit(attemptId: string): Promise<Grading> { const found = await this.db.query<Grading>('SELECT id, score FROM gradings WHERE attempt_id = $1', [attemptId]); if (found.rows[0]) return found.rows[0]; const answers = await this.db.query<AnswerRow>('SELECT question_id, choice FROM answers WHERE attempt_id = $1', [attemptId]); const score = scoreAnswers(answers.rows); const made = await this.db.query<Grading>( 'INSERT INTO gradings (attempt_id, score) VALUES ($1, $2) RETURNING id, score', [attemptId, score]); return made.rows[0]; }}The 30-second version
Node runs one request's code at a time, but each await lets another in. Find every read whose answer decides a write, and the awaits between them. Ask what two tabs, a retry or a second pod do there. Let the shared store refuse the duplicate, and prove it with a test that overlaps two calls.
A handler is atomic only between its awaits: find the gap between a check and its write, and let the shared store close it.
How it works
Step 1 of 11
await hands the thread on: the js-promises-async-await record, steps 5–6, cited here, not taught.In the kitchen
- one secretary reads and answers every letter herself, one at a time → one thread runs one stretch of code at a time (step 1)
Analogy: An allotment society's plot secretary, answering the post alone
| At the allotment society | In the code |
|---|---|
| one secretary reads and answers every letter herself, one at a time | one thread runs one stretch of code at a time (step 1) |
| a letter that needs the council's reply is set aside, and she opens the next | an await lets another request run (step 2) |
| two letters ask for the last vacant plot; the map shows plot 12 vacant to both, so both are offered it | check, then act, across a gap (steps 3, 5) |
| she pencils the first applicant onto the map before posting her letter | a claim taken before the first await (step 6) |
| the society's second site office keeps its own copy of the map | another instance has its own memory (step 7) |
| the council's tenancy register takes one tenancy per plot and refuses a second | the shared store decides (step 8) |
| telling an applicant "plot 12 is yours" before the council has replied | a missing await (step 9) |
| a quiet month, one letter at a time, never shows the double offer | one call at a time stays green (step 11) |
In real terms: whatever a handler learned before an await may be out of date when it acts, unless the store checks again as it writes.
Breaks down when: a secretary remembers she is half-way through plot 12 without pencilling it in. Code knows only what it wrote down, and a mark in one process's memory is invisible to every other process.
Contrasting case: two letters about different plots, or two letters ending the same tenancy: nothing one decides depends on what the other changes, so their overlap is harmless (step 10).
Examples, run for real
4.1 ExamHall EXH-3070: two tabs, two gradings
EXH-3070: during ECON-201, attempt a-1 was graded twice; the candidate had clicked Submit in two tabs. The script runs MR !3370's submit (the opening question's code) on real PostgreSQL, the second tab arriving while the first waits for its first SELECT. MR !3374 makes the index unique and answers its refusal with the stored grading:
4.1 EXH-3070, MR !3370 as shipped: tab 1 SELECT gradings -> 0 rows · tab 2 SELECT gradings -> 0 rows · tab 1 SELECT answers -> 4 rows · tab 2 SELECT answers -> 4 rows · tab 1 INSERT gradings -> 1 row · tab 2 INSERT gradings -> 1 row
4.1 EXH-3070, MR !3370 as shipped: gradings for a-1: 2 · tab 1 got #1 · tab 2 got #2
4.1 MR !3374: tab 1 SELECT gradings -> 0 rows · tab 2 SELECT gradings -> 0 rows · tab 1 SELECT answers -> 4 rows · tab 2 SELECT answers -> 4 rows · tab 1 INSERT gradings -> 1 row · tab 2 INSERT gradings -> 23505 · tab 2 SELECT gradings -> 1 row
4.1 MR !3374: gradings for a-1: 1 · tab 1 got #1 · tab 2 got #1
Each tab read "no grading" before either inserted (steps 3, 5). With the rule in the table, the second insert is refused whichever process sends it, and the handler answers with the stored grading (step 8).
4.2 Proof domain, media streaming: a promise with no owner
play() starts a session, then calls analytics.track() with no await and no .catch. Each run below is its own Node 24 process with the default unhandled-rejection mode:
4.2 analytics answers: reply s-1 · next task ran · exit 0
4.2 analytics refuses: reply s-1 · next task never ran · exit 1 · stderr: Error: analytics refused
4.2 analytics refuses, with .catch(log): log: analytics refused · reply s-1 · next task ran · exit 0
The reply had already gone, so a test of the reply passes either way; the refusal surfaces afterwards, as the exit (step 9; why the process ends belongs to the js-promises-async-await record). With a .catch, it is one log line.
Try it
Answer first, then check. Nothing is saved.
Question 1 of 7
A streaming service's Play, on PostgreSQL:
Account acc-1 allows two streams and has one playing, on its laptop; its TV and phone are registered devices. They press Play at the same moment, and both requests reach the same Node process.
Which read's answer can already be out of date when the INSERT runs?
// src/streams/play.service.tsexport class PlayService { constructor(private readonly db: Db) {} async play(accountId: string, device: string): Promise<Session> { const plan = await this.db.query<{ max_streams: number }>('SELECT max_streams FROM accounts WHERE id = $1', [accountId]); const open = await this.db.query<{ n: number }>('SELECT count(*)::int AS n FROM sessions WHERE account_id = $1 AND ended_at IS NULL', [accountId]); if (open.rows[0].n >= plan.rows[0].max_streams) throw new StreamLimitError(open.rows[0].n); const known = await this.db.query<{ id: number }>('SELECT id FROM devices WHERE account_id = $1 AND name = $2', [accountId, device]); if (!known.rows[0]) throw new UnknownDeviceError(device); const made = await this.db.query<Session>('INSERT INTO sessions (account_id, device) VALUES ($1, $2) RETURNING id, device', [accountId, device]); return made.rows[0]; }}Question 2 of 7
MR !3371, "stop double submits", adds a guard to ExamHall's final submit, and its unit test (one submit, one grading) passes. The stores are in-memory stand-ins that answer each call one turn later, in call order; gradings.insert also marks the attempt submitted, and findOpen returns only an open attempt:
Support still sees attempts graded twice.
Which test, added to the MR, fails on it and passes once the defect is fixed?
// src/grading/submit.service.tsexport class SubmitService { private readonly submitting = new Set<string>(); constructor(private readonly attempts: AttemptsStore, private readonly gradings: GradingsStore) {} async submit(attemptId: string): Promise<Grading> { if (this.submitting.has(attemptId)) throw new ConflictError('already submitting'); try { const attempt = await this.attempts.findOpen(attemptId); if (!attempt) throw new NotFoundError(attemptId); this.submitting.add(attemptId); const score = scoreAnswers(attempt.answers); return await this.gradings.insert(attemptId, score); } finally { this.submitting.delete(attemptId); } }}Question 3 of 7
MR !3372, "end every stream on logout", adds logout. Its test (logout with two streams open replies { ended: 2 }, and once the DRM licence API answers, none is open) passes:
Which review comment names a real defect in this MR?
// src/streams/streams.service.ts (excerpt)export const MAX_STREAMS = 2; async startStream(accountId: string, device: string) { const active = await this.sessions.countActive(accountId); if (active >= MAX_STREAMS) throw new StreamLimitError(active); return this.sessions.insert(accountId, device); } async logout(accountId: string) { const open = await this.sessions.openFor(accountId); open.forEach(async (s) => { await this.drm.revoke(s.licenseId); await this.sessions.end(s.id); }); this.metrics.increment('logout'); void this.audit.record(accountId, 'logout').catch((err) => this.log.warn('audit failed', err)); return { ended: open.length }; }// src/infra/stores.ts (excerpt) async end(id: string): Promise<void> { this.endCalls++; const s = this.rows.find((r) => r.id === id); if (s && s.endedAt === null) s.endedAt = this.endCalls; } increment(name: string): void { this.counts[name] = (this.counts[name] ?? 0) + 1; }Question 4 of 7
MR !3373 lets an account redeem a gift code. QA found one code credited to two accounts. The suite runs on an in-memory PostgreSQL that answers one statement at a time, in the order sent:
Which test, added to the MR, fails on this code and passes once the defect is fixed?
// src/gifts/redeem.service.tsexport class RedeemService { constructor(private readonly db: Db) {} async redeem(code: string, accountId: string): Promise<{ months: number }> { const r = await this.db.query<GiftCode>('SELECT code, months, redeemed_by FROM gift_codes WHERE code = $1', [code]); const gift = r.rows[0]; if (!gift || gift.redeemed_by) throw new RedeemedError(code); await this.db.query('UPDATE gift_codes SET redeemed_by = $2 WHERE code = $1', [code, accountId]); await this.db.query('INSERT INTO credits (account_id, months) VALUES ($1, $2)', [accountId, gift.months]); return { months: gift.months }; }}Question 5 of 7
MR !3375 adds free trials to the streaming service, one per account. trials has a plain index on account_id, and verifyCard asks the card provider:
Two devices tap "Start free trial" for one account together.
In at most five lines, write the review comment: where the defect is, what the two taps produce, the test, and the fix.
// src/trials/trial.service.ts (excerpt) async startTrial(accountId: string, cardToken: string): Promise<{ trialId: number }> { const used = await this.db.query<{ id: number }>('SELECT id FROM trials WHERE account_id = $1', [accountId]); if (used.rows[0]) throw new TrialUsedError(accountId); await this.billing.verifyCard(cardToken); const made = await this.db.query<{ id: number }>('INSERT INTO trials (account_id) VALUES ($1) RETURNING id', [accountId]); return { trialId: made.rows[0].id }; }Question 6 of 7
A team chat archives a channel, on PostgreSQL:
Ana and Ben, both admins of #ops (c-ops), click Archive at the same moment, and both requests reach the same Node process.
Compared with the same two clicks one after the other, which table ends up different?
// src/channels/archive.service.tsexport class ArchiveService { constructor(private readonly db: Db) {} async archive(channelId: string, by: string): Promise<void> { await this.db.query("INSERT INTO audit_log (user_id, action) VALUES ($1, 'archive clicked')", [by]); const c = await this.db.query<{ archived: boolean }>('SELECT archived FROM channels WHERE id = $1', [channelId]); if (c.rows[0].archived) return; await this.db.query('UPDATE channels SET archived = true WHERE id = $1', [channelId]); await this.db.query("INSERT INTO channel_events (channel_id, kind, by_user) VALUES ($1, 'archived', $2)", [channelId, by]); }}Question 7 of 7
MR !3376 lets a moderator pin a message, at most three per channel. The chat API runs three instances behind a round-robin load balancer, all on one PostgreSQL database:
Which change keeps every channel at three pins or fewer, whichever instances the requests reach?
// src/pins/pins.service.tsexport const MAX_PINS = 3; export class PinsService { constructor(private readonly db: Db) {} async pin(channelId: string, messageId: string): Promise<{ id: number }> { const n = await this.db.query<{ n: number }>('SELECT count(*)::int AS n FROM pins WHERE channel_id = $1', [channelId]); if (n.rows[0].n >= MAX_PINS) throw new PinLimitError(channelId); const made = await this.db.query<{ id: number }>('INSERT INTO pins (channel_id, message_id) VALUES ($1, $2) RETURNING id', [channelId, messageId]); return made.rows[0]; }}Go deeper
Pro tip
- List the awaited reads in a changed function, then ask what each one decides. Read only the diff's async functions: for each
awaitthat reads, note the first later write that uses its answer, and count the awaits in between. Every count above zero is a place to ask step 4's question. When: the first pass over an MR that touches a handler, a job or a consumer (step 3). Verify:
Status: RAN.tip src/grading/submit.service.ts: read gradings (line 10) -> write gradings (line 14): 2 awaits from the read to the write · read answers (line 12): no later write to answers
Source
MDN renders from its mdn/content repository, so the guide was read from its Markdown source; the Node.js pages were read from doc/api/ at the tag v24.21.0, the Node these scripts ran on; the IETF draft was read as the plain-text archive copy of draft-07, the newest revision its datatracker page lists. Every behaviour this lesson grades is executed; the sources support the steps and key nothing. Valid as of 2026-10-06.
- MDN, Using promises (the curriculum row's first source), https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Using_promises (
mdn-using-promises.txt). Quoted: "If the previous handler started a promise but did not return it, there's no way to track its settlement anymore, and the promise is said to be "floating"." Supports step 9. - Node.js v24 documentation, process, Event: 'unhandledRejection' (the row's second source), https://nodejs.org/api/process.html#event-unhandledrejection (
node-process-unhandledrejection.txt). Quoted: "The'unhandledRejection'event is emitted whenever aPromiseis rejected and no error handler is attached to the promise within a turn of the event loop." Supports step 9. - Node.js v24 documentation, Command-line API,
--unhandled-rejections=mode, https://nodejs.org/api/cli.html#--unhandled-rejectionsmode (node-cli-unhandled-rejections.txt). Quoted: "If this hook is not set, raise the unhandled rejection as an uncaught exception. This is the default." - IETF, The Idempotency-Key HTTP Header Field, draft-ietf-httpapi-idempotency-key-header-07 (the row's third source), https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/ (
ietf-idempotency-key-header.txt). Quoted: "Repeating the request multiple times can result in duplication or incorrect updates." Supports steps 4 and 8; the key's mechanism belongs toapi-idempotency-keys.
Related lessons
- Read first: Idempotency keys for unsafe operations
- Read first: Defect taxonomy scan
- Related: Run-to-completion on one thread
- Related: Data first: model the data, the rest follows
- Related: Preventing lost updates
- Related: Isolation levels and anomalies in Postgres
- Related: Stateless tiers behind a load balancer
- Related: Stale closures, refs and useEffectEvent
- Related: Cancellation and out-of-order responses
- Related: UI build: typeahead with debounce and race handling
- Related: Entry-point-first reading
- Related: Explain line N
Checked against the tools and docs as of 2026-10-06.