- Book: Kotlin and Java to TypeScript
- Also by me: The TypeScript Library — the 5-book collection
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
An account holds 100. Two requests arrive at the same moment, each asking to withdraw 80. Your TypeScript service approves both, pays out 160, and records a final balance of 20.
If you came from a JVM codebase, you would have wrapped that method in synchronized without thinking. Then someone told you Node runs your JavaScript on a single thread, and the lock instinct went quiet. The single thread is real. The safety you inferred from it is not, and the gap sits at every await in your code.
Where "single-threaded" stops protecting you
The Node.js docs say it plainly: the event loop gives Node non-blocking I/O "despite the fact that a single JavaScript thread is used by default". That thread runs one job at a time, and MDN's execution model page spells out what you get from it: "whenever a function runs, it cannot be preempted and will run entirely before any other code runs."
The catch is what counts as one run. An async function does not run in one piece. MDN's await reference describes what happens at each await: "Control exits the function and returns to the caller. When the awaited expression's value is resolved, another microtask that continues the paused code gets scheduled."
So an async function with two awaits is three jobs, and any other job can run between them. The same page adds the detail that catches people: "Even when the used promise is already fulfilled, the async function's execution still pauses until the next tick." Awaiting a cached value, or await null, still hands the thread to whoever is queued next.

One thread, many turns: every await is a door another task can walk through.
Reproduce it: two withdrawals, one balance
Save this as race.ts. Node 22.18+ and 23.6+ strip types by default, so node race.ts runs it; on older versions use tsx: npx tsx race.ts.
let balance = 100; const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)); async function withdraw(id: string, amount: number) { const current = balance; // check... if (current < amount) { console.log(`${id}: declined`); return; } await sleep(10); // fraud check, DB call, anything balance = current - amount; // ...then act console.log(`${id}: approved, balance ${balance}`); } async function main() { await Promise.all([withdraw("A", 80), withdraw("B", 80)]); console.log(`final balance ${balance}`); } main(); Trace it by hand. withdraw("A", 80) runs synchronously until its await: it reads 100, passes the check, starts a 10 ms timer and returns a pending promise. withdraw("B", 80) runs next, before A resumes, because A is parked on that timer. B also reads 100 and passes. When the timers fire, A writes 100 - 80 and B writes its own stale current minus 80. Both log "approved", both write 20, and main reports a final balance of 20.
That is two bugs in one function. The check-then-act lets both requests through, and the read-modify-write turns two debits into one. Swap sleep(10) for await Promise.resolve() and the trace does not change, because the continuation still goes through the microtask queue.
What synchronized would have done
Here is the same method in Java, guarded the way a JVM developer guards it by reflex:
class Account { private long balance = 100; synchronized boolean withdraw(long amount) throws InterruptedException { long current = balance; if (current < amount) return false; Thread.sleep(10); // the same fraud check balance = current - amount; return true; } } Oracle's tutorial on intrinsic locks gives the rule: "As long as a thread owns an intrinsic lock, no other thread can acquire the same lock. The other thread will block when it attempts to acquire the lock." The sleep does not loosen that, since per the Thread.sleep Javadoc "the thread does not lose ownership of any monitors." Thread B waits at the method entry, reads 20 after A returns, and gets false. A ReentrantLock with the documented lock(); try { ... } finally { unlock(); } idiom gives you the same outcome.
The reason this works in Java is that the thread itself waits, holding the lock while it waits. In TypeScript nothing waits. The await gives the only thread away, so a lock has to be something that outlives the await: a promise the next caller is forced to wait on.
Two notes for the JVM side. First, Java's own async code has the same hole: a synchronized method releases its lock "when the method returns", per the same tutorial, so one that returns a CompletableFuture is unlocked before the future completes. Second, TypeScript has an advantage here. In Java, a thread can be preempted between any two statements. In JavaScript, only the awaits are interleaving points, so you can find every one of them by reading the function.
Same race, cache edition
The pattern shows up anywhere you read shared state, await, then write it. A lazy cache fill is the usual one (loadUser is your database call):
const cache = new Map<string, User>(); async function getUser(id: string): Promise<User> { const hit = cache.get(id); if (hit) return hit; const user = await loadUser(id); // every miss lands here cache.set(id, user); return user; } Fifty concurrent requests for a cold key all miss before the first load returns, so you get fifty database queries instead of one. The quieter bug is the stale write. Request one misses and starts loading the old row. An update lands, writes the database and calls cache.delete(id). Then request one's load resolves and cache.set puts the old row back, where it stays until the next invalidation.
Four fixes that fit TypeScript
1. Keep the critical section free of awaits. Do the slow part first, then check and write in one synchronous block. Run-to-completion guarantees nothing interleaves inside it.
async function withdraw(id: string, amount: number) { await sleep(10); // slow part first if (balance < amount) return false; balance -= amount; // check and write, no await between return true; } Both calls finish their sleep, then A takes the balance to 20 and B sees 20 and declines. The rule to carry around: read shared state after your last await, and write it before your next one.
2. A promise-chain mutex, about 15 lines. When the await has to sit inside the critical section, make each caller wait on the promise of the caller before it.
class Mutex { #tail: Promise<void> = Promise.resolve(); async run<T>(fn: () => Promise<T>): Promise<T> { const prev = this.#tail; let release = () => {}; this.#tail = new Promise<void>((resolve) => { release = () => resolve(); }); await prev; try { return await fn(); } finally { release(); } } } const lock = new Mutex(); const withdrawSafe = (id: string, amount: number) => lock.run(() => withdraw(id, amount)); Wrap the racy withdraw from race.ts and A runs to the end while B waits on A's promise, then B reads 20 and declines. The finally releases the lock even when fn throws, the same job finally { unlock(); } does in Java.
Two differences from ReentrantLock matter. This mutex is not reentrant: calling lock.run from inside a function already holding lock waits on itself forever. And there is no timeout. If you want timeouts and semaphores, async-mutex ships them, and its README makes the same point this post does: JavaScript is single-threaded, but its async execution model "allows for race conditions." For per-account locking, keep a Map<string, Mutex> keyed by account id.

A promise-chain mutex: each caller holds the next one's key until it finishes.
3. Single-flight for cache fills. Cache the in-flight promise, not only the finished value, so concurrent misses share one load. Go developers know this as singleflight, "a duplicate function call suppression mechanism."
type User = { id: string; name: string }; declare function loadUser(id: string): Promise<User>; const cache = new Map<string, User>(); const inflight = new Map<string, Promise<User>>(); function getUser(id: string): Promise<User> { const hit = cache.get(id); if (hit) return Promise.resolve(hit); const running = inflight.get(id); if (running) return running; const p: Promise<User> = loadUser(id) .then((user) => { if (inflight.get(id) === p) cache.set(id, user); return user; }) .finally(() => { if (inflight.get(id) === p) inflight.delete(id); }); inflight.set(id, p); return p; } function invalidate(id: string) { cache.delete(id); inflight.delete(id); // a late fill can no longer write back } getUser is not async on purpose: the cache lookup, the in-flight lookup and inflight.set all happen in one synchronous run, so no second caller can slip between them. The inflight.get(id) === p checks fix the stale write. Once invalidate drops the entry, the old load still resolves for whoever awaited it, but it no longer writes to the cache. Failed loads are not cached, because finally clears the entry either way.
4. A database row lock when the state lives outside the process. Everything above guards one Node process. Run three replicas and each has its own Mutex, so the balance needs a lock where it lives. In Postgres, the shortest version is a conditional update:
import pg from "pg"; const pool = new pg.Pool(); async function withdrawDb(id: string, amount: number) { const { rowCount } = await pool.query( `UPDATE accounts SET balance = balance - $1 WHERE id = $2 AND balance >= $1`, [amount, id], ); return rowCount === 1; } Under Read Committed, Postgres's default, a second concurrent update on the same row waits for the first to commit, then "the search condition of the command (the WHERE clause) is re-evaluated to see if the updated version of the row still matches." The second withdrawal sees 20, fails balance >= 80, and rowCount comes back 0.
When the decision needs application logic between the read and the write, take the lock explicitly with SELECT ... FOR UPDATE inside a transaction. The Postgres docs say it blocks other transactions' UPDATE, DELETE and SELECT FOR UPDATE on those rows "until the current transaction ends."
async function withdrawLocked(id: string, amount: number) { const client = await pool.connect(); try { await client.query("BEGIN"); const { rows } = await client.query( "SELECT balance FROM accounts WHERE id = $1 FOR UPDATE", [id], ); const balance = Number(rows[0].balance); if (balance < amount) { await client.query("ROLLBACK"); return false; } await fraudCheck(id, amount); // awaits are safe here await client.query( "UPDATE accounts SET balance = $1 WHERE id = $2", [balance - amount, id], ); await client.query("COMMIT"); return true; } catch (err) { await client.query("ROLLBACK"); throw err; } finally { client.release(); } } node-postgres is explicit that you "must use the same client instance for all statements within a transaction", which is why this checks out one client instead of calling pool.query. Here fraudCheck stands in for your own slow step. This is the closest thing TypeScript has to the Java version: the lock survives the await because it lives in the database, and the database does not care what your thread is doing. Keep that window short, since every other withdrawal on the row queues behind it.
If this was useful
Moving from the JVM to TypeScript, the syntax comes quickly. The habits take longer: some quietly stop applying, like reaching for a lock, and some still apply in a new shape. Kotlin and Java to TypeScript is written for that move, including the jump from coroutines and threads to async/await.
If you are coming from Java or Kotlin, start with Kotlin and Java to TypeScript and pick up The TypeScript Type System when the types get interesting. If you already write TypeScript daily, TypeScript Essentials and The TypeScript Type System are the core path, and TypeScript in Production is for anyone shipping it at work.
- TypeScript Essentials — types, narrowing, modules, async, daily-driver tooling
- The TypeScript Type System — generics, mapped/conditional types, infer, template literals, branded types
- Kotlin and Java to TypeScript — JVM developers moving to TypeScript
- PHP to TypeScript — PHP 8+ developers moving to TypeScript
- TypeScript in Production — tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR
All five books ship in ebook, paperback, and hardcover.

