Build a Race-Safe Inventory Service
Build a Mongoose inventory service that prevents lost updates with atomic writes
Introduction
30 Second Summary
A stock count can look safe until two shoppers reserve the last items at the same moment. Both can receive a success message even when the shelf never held enough for both orders.
In this project, you will build a local Express inventory service that stores a keyboard product in MongoDB through Mongoose. The lab traces the full journey from a reproducible lost update to an atomic compare-and-swap fix.
What You'll Build
Your finished lab turns a five-unit keyboard stock into a live concurrency demo where the terminal exposes the oversell before the race-safe run rejects the stale reservation.
By the end of this project, you'll have:
- A browser-based inventory viewer that shows the persisted keyboard document with its current stock.
- A deterministic race runner that makes two four-unit reservations report success against only five units.
- A race-safe reservation path that leaves stock at 1 while returning an explicit conflict for the stale request.
- Secret Mission: Add request-ID idempotency so retrying one reservation cannot decrement stock twice.
Are there any prerequisites?
This project assumes senior-level JavaScript and MERN experience. You need macOS with Node.js, Docker Desktop, and Visual Studio Code available.
Before We Start
Two reservation requests can read the same five units before either one writes. That concurrency window lets both requests approve four units from one stale stock value.
This lab needs a clear inventory rule before you create its race. You will also verify the local tools that make every later result reproducible.
In this step, get ready to:
- Define the inventory rule that every simultaneous reservation must preserve.
- Verify Node.js v24.22.0 with npm 11.19.1.
- Confirm Docker Desktop, Docker Compose, and Visual Studio Code are available.
Understand the inventory invariant
The inventory rule is simple. Successful reservations across all simultaneous requests can consume no more stock than the product document contains.
How Can Five Units Become Eight Reservations?
Each request reads a stock value of 5. Each request independently decides that a four-unit reservation is valid.
A lost update occurs when one saved result overwrites the other saved result. Both callers can report success even though the service accepted eight units of reservations from five units of stock.
Verify Node.js and npm
Node.js runs every script in this lab. The pinned release keeps the runtime consistent with the tested dependency versions.
- Press Cmd+Space to open Spotlight.
- Type Terminal into Spotlight.
- Press Enter to open Terminal.
- Check the installed Node.js release by running this command:
node --version
What Does This Command Check?
The command asks the active Node.js executable to print its version. The result reveals whether Terminal is using the runtime pinned for this lab.
✔️ I see v24.22.0
Good. Your Node.js runtime matches the required v24.22.0 release.
ⓧ I see an older version
The installed runtime is older than the pinned release. The official macOS installer upgrades the active Node.js runtime.
The installer presents standard macOS package prompts. Keep the default installation destination.
- Visit the official Node.js download page.
- Download the Node.js 24.22.0 macOS installer.
- Run the downloaded installer.
- Complete the installer prompts with the default destination.
- Return to Terminal.
- Rerun the Node.js version check shown above.
ⓧ Command not found
Terminal cannot find a Node.js executable. Install the pinned runtime before continuing.
The installer presents standard macOS package prompts. Keep the default installation destination.
- Visit the official Node.js download page.
- Download the Node.js 24.22.0 macOS installer.
- Run the downloaded installer.
- Complete the installer prompts with the default destination.
- Return to Terminal.
- Rerun the Node.js version check shown above.
Node.js 24.22.0 includes npm 11.19.1. The next check confirms that Terminal can reach the bundled package manager.
- Check the installed npm release by running this command:
npm version
What Does This Command Check?
The command prints npm version information for the active Node.js installation. This confirms that the package manager is available before the lab installs dependencies.
Find the npm entry in the output. It should show 11.19.1.
Seeing a Different npm Version?
- Confirm that the Node.js check above reports v24.22.0.
- Rerun the official Node.js installer if the active runtime is different.
Ask for targeted help with diagnosing the Node.js and npm version mismatch.
Verify Docker Compose and Visual Studio Code
Docker Desktop provides the local container engine for this lab. Docker Compose controls the MongoDB service that you start in the next step.
- Press Cmd+Space to open Spotlight.
- Type Docker Desktop into Spotlight.
- Press Enter to start Docker Desktop.
- Wait for Docker Desktop to finish starting.
- Return to Terminal.
- Check the Docker client and local engine by running this command:
docker version
What Does This Command Check?
The command requests version details from the Docker client. It also contacts the local Docker engine.
A successful result includes information for both parts. This proves Docker Desktop is running.
You should see Docker client details followed by server details. The command should finish without a connection error.
Cannot Reach the Docker Engine?
- Return to Docker Desktop.
- Wait for its startup process to finish.
- Rerun the Docker version check in Terminal.
Ask for help with diagnosing the local Docker connection.
Before you run the final terminal check, do you expect Docker Compose to report its own version through the same Docker installation?
- Verify that Docker Compose is available by running this command:
docker compose version
What Does This Command Check?
The command asks Docker Compose to print its version information. A successful response confirms that later Compose commands can manage the local database service.
You should see Docker Compose version information. Terminal should return to its prompt without an error.
- Press Cmd+Space to open Spotlight.
- Type Visual Studio Code into Spotlight.
- Press Enter to open Visual Studio Code.
You should see a Visual Studio Code editor window. This confirms that your code editor is available for the lab.
Your preflight is complete. Next, you will pin the lab dependencies before starting its local MongoDB service.
Set Up the Local Concurrency Lab
A race demonstration only teaches the right lesson when every run starts from the same runtime, package set, and database process. Pinning the lab removes environmental drift from the result.
Your earlier check confirmed Node.js v24.22.0 with npm 11.19.1. Docker Desktop plus Docker Compose are ready too.
This step pins Express 5.3.0 plus Mongoose 9.11.2. Docker Compose then starts MongoDB 8.3.13 from the same definition every time.
In this step, get ready to:
- Use Visual Studio Code in the local project folder.
- Install the exact dependency pins for Express plus Mongoose.
- Start the local MongoDB service with persistent storage.
Open the verified project workspace
The verified tools are ready for use. Visual Studio Code keeps the files and terminal in one workspace.
- Press Cmd+Space to open Spotlight.
You should see the Spotlight search field.
- Type Visual Studio Code into the search field.
- Press Enter to open Visual Studio Code.
You should see Visual Studio Code open on your Mac.
- Drag the local project folder from earlier into Visual Studio Code.
The Explorer sidebar should show the empty local project folder. Your editor now points at the exact location this lab uses.
Pin the Node.js project dependencies
A package manifest records the tested runtime plus dependency versions. Exact pins keep later race results tied to the same application stack.
- Create package.json inside the local project folder using the Explorer sidebar.
- Copy the project manifest below into package.json:
{
"name": "race-safe-inventory-service",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node app.js",
"race:naive": "node race.js naive"
},
"engines": {
"node": "24.22.0"
},
"dependencies": {
"express": "5.3.0",
"mongoose": "9.11.2"
}
}
What Does This Manifest Define?
- The type setting enables JavaScript modules for the application files created later.
- The start plus race:naive scripts provide repeatable entry points for the viewer and race runner.
- The dependency values pin Express to 5.3.0 plus Mongoose to 9.11.2.
- Save package.json.
- Open the integrated terminal panel in Visual Studio Code.
- Install the pinned project dependencies by running this command:
npm install
What Does This Command Do?
npm reads the exact dependency versions from package.json. It installs those packages and generates package-lock.json for repeatable future installs.
The installation should finish with a dependency summary. The Explorer sidebar should now list package-lock.json beside package.json.
That is the first repeatability layer locked in.
Dependency Installation Failed?
- Confirm that package.json is saved in the local project folder.
- Check that the integrated terminal points to the folder containing package.json.
Ask for targeted help with diagnosing the npm installation.
✔️ Awesome, I've got everything!
Your manifest is saved with the tested runtime, scripts, and dependency pins.
ⓧ I'd like to double check the full code
{
"name": "race-safe-inventory-service",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node app.js",
"race:naive": "node race.js naive"
},
"engines": {
"node": "24.22.0"
},
"dependencies": {
"express": "5.3.0",
"mongoose": "9.11.2"
}
}
Define and start local MongoDB
Docker Compose turns one configuration file into a repeatable local container. A named volume preserves the database files when the container stops.
- Create compose.yaml inside the local project folder using the Explorer sidebar.
- Copy the MongoDB service definition below into compose.yaml:
services:
mongo:
image: mongo:8.3.13
ports:
- "27017:27017"
volumes:
- inventory-data:/data/db
volumes:
inventory-data:
What Does This Service Definition Do?
- The mongo service uses the pinned mongo:8.3.13 image.
- The port mapping makes MongoDB available to the Mac on port 27017.
- The inventory-data volume stores database files at /data/db inside the container.
- Save compose.yaml.
✔️ Awesome, I've got everything!
Your Compose definition now pins the local database image, port, and persistent volume.
ⓧ I'd like to double check the full code
services:
mongo:
image: mongo:8.3.13
ports:
- "27017:27017"
volumes:
- inventory-data:/data/db
volumes:
inventory-data:
Keep MongoDB Local
This lab runs an unauthenticated MongoDB container for local development. Keep it on your Mac without exposing port 27017 to another network.
Before you run this command, make a quick mental prediction about whether Docker accepts the Compose definition.
- Start the local MongoDB service by running this command in the integrated terminal:
docker compose up --detach
What Does This Command Do?
Docker Compose creates the mongo container plus the inventory-data volume. Detached mode leaves the database running while returning control of the terminal.
Docker should report that the mongo service started without an error. This confirms the pinned database process is ready for the application.
MongoDB Did Not Start?
- Confirm that Docker Desktop is running before retrying the command.
- Stop another local database if it already uses port 27017.
- Compare the spacing in compose.yaml with the full-code tab above.
Ask for targeted help with diagnosing the Compose startup.
You now have the lab's repeatable foundation. Next, you will connect Express and Mongoose to show the seeded inventory in your browser.
See Inventory Through Express
Your local MongoDB container is running with the pinned packages ready. The next goal is a visible inventory baseline.
A browser request will reach Express. Mongoose will read the product from MongoDB.
Seeing one document with five units proves that every layer agrees before concurrency enters the picture.
In this step, get ready to:
- Define the product schema with a unique required SKU and non-negative stock.
- Build the startup path for a seeded keyboard document.
- Serve the inventory through a read-only browser endpoint.
Define the product model
A schema defines the shape that every product document must follow. The stock rule prevents a saved value from falling below zero.
- Use the file-creation control in the VS Code Explorer sidebar to create product.js inside your local project folder.
- Add the product model by pasting this code into product.js:
import mongoose from 'mongoose';
const productSchema = new mongoose.Schema({
sku: {
type: String,
required: true,
unique: true
},
stock: {
type: Number,
required: true,
min: 0
}
});
export const Product = mongoose.model('Product', productSchema);
What Does This Code Do?
- The sku field gives each product a required string identifier.
- The unique option creates a unique index for the SKU.
- The stock field requires a number with a minimum value of 0.
- The exported Product model provides the database operations used by the server.
- Save product.js.
- Confirm product.js is listed in the VS Code Explorer sidebar.
You should see product.js beside package.json in your local project folder.
Product File Missing?
Confirm that product.js was created in the same folder as package.json.
Ask for help with locating the product model.
✔️ Awesome, I've got everything!
Your product.js file now contains the complete product model.
ⓧ I'd like to double check the full code
import mongoose from 'mongoose';
const productSchema = new mongoose.Schema({
sku: {
type: String,
required: true,
unique: true
},
stock: {
type: Number,
required: true,
min: 0
}
});
export const Product = mongoose.model('Product', productSchema);
Connect and seed the inventory
The server needs one predictable product before it can answer browser requests. Its startup path connects to the local database before preparing the model index.
The seed check creates the keyboard only when it is absent. Existing stock survives later server restarts.
- Use the file-creation control in the VS Code Explorer sidebar to create app.js inside your local project folder.
- Add the connection and seed path by pasting this code into app.js:
import express from 'express';
import mongoose from 'mongoose';
import { Product } from './product.js';
const MONGODB_URI = 'mongodb://127.0.0.1:27017/inventory_lab';
const SKU = 'keyboard';
const PORT = 3000;
async function main() {
await mongoose.connect(MONGODB_URI);
await Product.init();
const existingProduct = await Product.findOne({ sku: SKU });
if (!existingProduct) {
await Product.create({ sku: SKU, stock: 5 });
}
const app = express();
app.listen(PORT, () => {
console.log(`Inventory viewer: http://localhost:${PORT}/inventory`);
});
}
main().catch(error => console.error(error));
How Does Startup Work?
- The imports load Express, Mongoose, and the exported Product model.
- The MONGODB_URI value targets the local inventory_lab database.
- The Product.init() call waits until the model index is ready.
- The seed check searches for keyboard before creating a product with five units.
- The app.listen() call starts the server on port 3000.
- Save app.js.
- Confirm app.js is listed in the VS Code Explorer sidebar.
You should now see both app.js and product.js in your local project folder.
Server File Missing?
Confirm that app.js is saved beside product.js. Check that the filename uses lowercase letters.
Ask for help with checking the server file location.
Expose the read-only inventory route
A read-only route turns the seeded document into a visible baseline. Each request reads the current product without changing its stock.
- In app.js, find the gap between these lines:
const app = express();
app.listen(PORT, () => {
Where Does the Route Go?
The route belongs after the Express application is created. It must appear before the server begins listening for requests.
- Insert the inventory route into that gap by pasting this code:
app.get('/inventory', async (request, response) => {
try {
const product = await Product.findOne({ sku: SKU });
response.send(JSON.stringify({ method: request.method, product }, null, 2));
} catch (error) {
response.send(`Database error: ${error.message}`);
}
});
What Does the Route Do?
- The /inventory route handles a browser read request.
- The Product.findOne() call reads the document identified by SKU.
- The response formats the request method and product as JSON.
- The catch block returns a database problem through the same browser response.
- Save app.js.
Before you run the server, which terminal signal do you expect when the database connection succeeds?
- Switch back to the first terminal from earlier.
- Start the inventory server by running this command:
npm start
What Does This Command Do?
The command executes the start script from package.json. That script launches app.js with Node.js.
The terminal should print http://localhost:3000/inventory. This confirms the server reached its listening state.
Before you load the endpoint, what stock value do you expect the seeded product to contain?
- Open the inventory endpoint in your browser by selecting http://localhost:3000/inventory.
You should see formatted JSON with method set to GET. The product should show sku as keyboard.
The stock value should be 5. You now have a visible baseline across the browser, server, model, and database.
Inventory Page Not Loading?
Confirm that the first terminal still has the server process running. Check that the browser address ends with /inventory.
If the browser reports a database problem, confirm that the Compose service from the previous step remains active.
Ask for help with diagnosing the inventory endpoint.
✔️ Awesome, I've got everything!
Your app.js file now contains the complete inventory viewer.
ⓧ I'd like to double check the full code
import express from 'express';
import mongoose from 'mongoose';
import { Product } from './product.js';
const MONGODB_URI = 'mongodb://127.0.0.1:27017/inventory_lab';
const SKU = 'keyboard';
const PORT = 3000;
async function main() {
await mongoose.connect(MONGODB_URI);
await Product.init();
const existingProduct = await Product.findOne({ sku: SKU });
if (!existingProduct) {
await Product.create({ sku: SKU, stock: 5 });
}
const app = express();
app.get('/inventory', async (request, response) => {
try {
const product = await Product.findOne({ sku: SKU });
response.send(JSON.stringify({ method: request.method, product }, null, 2));
} catch (error) {
response.send(`Database error: ${error.message}`);
}
});
app.listen(PORT, () => {
console.log(`Inventory viewer: http://localhost:${PORT}/inventory`);
});
}
main().catch(error => console.error(error));
- Leave npm start running in the first terminal for the next step.
The single-request baseline is locked in. Next, you will force two reservations to read the same five units before either one saves.
Reproduce the Lost-Update Race
Your Express inventory viewer now shows the keyboard product with five units. That baseline gives the race runner a known starting point.
The naive read-modify-save flow checks stock before saving. Its weakness only becomes visible when both callers read the same value before either caller writes.
In this step, get ready to:
- Coordinate two readers with a deterministic promise barrier.
- Implement a naive reservation with independently loaded product documents.
- Run two four-unit reservations against five units of stock.
Build the two-party barrier
A two-party promise barrier holds each caller after its database read. The second arrival releases both callers at the same moment.
- Use the file icon with a plus sign in the Explorer sidebar to create race.js inside the local project folder.
- Build the shared race configuration in race.js by pasting this code:
import mongoose from 'mongoose';
import { Product } from './product.js';
const MONGODB_URI = 'mongodb://127.0.0.1:27017/inventory_lab';
const SKU = 'keyboard';
const STARTING_STOCK = 5;
const QUANTITY = 4;
function createTwoPartyBarrier() {
let arrivals = 0;
let release;
const bothReady = new Promise(resolve => {
release = resolve;
});
return async function waitForBothReaders() {
arrivals += 1;
if (arrivals === 2) {
release();
}
await bothReady;
};
}
What Does This Code Do?
- The Mongoose import provides the database connection used by the runner.
- The Product import gives each caller access to the existing inventory model.
- STARTING_STOCK gives every race the same five-unit baseline.
- QUANTITY makes each caller request four units.
- arrivals counts callers that reach the barrier.
- bothReady remains pending until the second caller runs release().
- Save race.js.
- Confirm race.js appears in the Explorer sidebar.
- Confirm createTwoPartyBarrier appears near the top of the file.
Barrier Code Looks Incomplete?
Check that waitForBothReaders sits inside createTwoPartyBarrier. Confirm that the closing braces match the reference above.
Ask for targeted help with checking the two-party barrier structure.
Add the naive reservation flow
Every run needs the same starting inventory. A single-document update resets the product before the competing reads begin.
- Add the inventory reset below createTwoPartyBarrier by pasting this code:
async function resetInventory() {
return Product.findOneAndUpdate(
{ sku: SKU },
{ $set: { stock: STARTING_STOCK } },
{ upsert: true, returnDocument: 'after', runValidators: true }
);
}
How Does the Reset Work?
- findOneAndUpdate() restores the stock value in one MongoDB operation.
- upsert creates the product if it is missing.
- runValidators applies the non-negative stock rule from the schema.
- returnDocument requests the product state after the reset.
- Save race.js.
- Confirm resetInventory appears below the barrier function.
Reset Function in the Wrong Place?
Confirm that resetInventory starts after the closing brace for createTwoPartyBarrier. It must remain a separate top-level function.
Ask for help with checking the reset function placement.
Each reservation loads its own product document into memory. Each caller performs the stock check against that independent snapshot.
- Add the naive reservation below resetInventory by pasting this code:
async function naiveReserve(label, waitForBothReaders) {
const product = await Product.findOne({ sku: SKU });
if (!product || product.stock < QUANTITY) {
return { label, outcome: 'insufficient stock' };
}
await waitForBothReaders();
product.stock -= QUANTITY;
await product.save();
return { label, outcome: 'reserved', observedStock: product.stock };
}
How Does a Naive Reservation Work?
- Product.findOne() loads a separate product document for each caller.
- The stock check rejects a request when fewer than four units are available.
- waitForBothReaders() pauses the caller after its stock check.
- product.stock is reduced in the caller's in-memory document.
- product.save() persists that caller's modified stock value.
- The returned object records the caller label plus its reported outcome.
- Save race.js.
- Confirm naiveReserve appears below resetInventory.
Reservation Function Looks Misaligned?
Check that the stock subtraction occurs after waitForBothReaders(). Confirm that product.save() follows the subtraction.
Ask for targeted help with checking the naive reservation sequence.
Run both reservations together
The runner resets the product before creating one shared barrier. Promise.all() starts both reservation calls against that barrier.
- Complete race.js by adding this code below naiveReserve:
async function main() {
try {
await mongoose.connect(MONGODB_URI);
await Product.init();
await resetInventory();
const waitForBothReaders = createTwoPartyBarrier();
const results = await Promise.all([
naiveReserve('request-a', waitForBothReaders),
naiveReserve('request-b', waitForBothReaders)
]);
const finalProduct = await Product.findOne({ sku: SKU });
console.log(JSON.stringify({
mode: 'naive',
startingStock: STARTING_STOCK,
quantityPerRequest: QUANTITY,
results,
finalStock: finalProduct.stock
}, null, 2));
} finally {
await mongoose.disconnect();
}
}
main().catch(error => console.error(error));
What Does the Runner Control?
- mongoose.connect() connects the runner to the local inventory database.
- Product.init() waits for the product model's unique index.
- resetInventory() restores the five-unit baseline.
- Promise.all() waits for both reservation calls to finish.
- JSON.stringify() formats the race details for the terminal.
- mongoose.disconnect() closes the runner's database connection after the test.
- Save race.js.
- Create a second terminal session with the plus control in the terminal panel from earlier.
Before you run the race, do you think both callers can report success after reading the same five units?
- Launch the naive race from the second terminal by running:
npm run race:naive
What Does This Command Do?
The npm script launches race.js in naive mode. The first terminal keeps the inventory viewer running.
You'll see both result objects report reserved. You'll also see finalStock equal 1.
Why Does One Unit Remain?
Both callers read five units before either save begins. Each caller subtracts four from its own snapshot.
The later save writes the same value of one over the earlier result. This lost update hides the fact that the service accepted eight units of reservations from five units of stock.
Race Runner Hangs or Fails?
A hanging process means the barrier received fewer than two callers. Confirm that Promise.all() contains both naiveReserve calls.
A database connection failure means the local Compose service may have stopped. Confirm that the MongoDB service from the setup step remains running.
Ask for help with diagnosing the deterministic race runner.
- Switch back to the browser tab from earlier.
- Refresh the /inventory endpoint.
You'll see the keyboard product with stock equal to 1. The browser confirms that the misleading final value was persisted.
Your completed race runner should match the checks below.
✔️ Awesome, I've got everything!
Your race.js now reproduces the same lost update on every run.
ⓧ I'd like to double check the full code
import mongoose from 'mongoose';
import { Product } from './product.js';
const MONGODB_URI = 'mongodb://127.0.0.1:27017/inventory_lab';
const SKU = 'keyboard';
const STARTING_STOCK = 5;
const QUANTITY = 4;
function createTwoPartyBarrier() {
let arrivals = 0;
let release;
const bothReady = new Promise(resolve => {
release = resolve;
});
return async function waitForBothReaders() {
arrivals += 1;
if (arrivals === 2) {
release();
}
await bothReady;
};
}
async function resetInventory() {
return Product.findOneAndUpdate(
{ sku: SKU },
{ $set: { stock: STARTING_STOCK } },
{ upsert: true, returnDocument: 'after', runValidators: true }
);
}
async function naiveReserve(label, waitForBothReaders) {
const product = await Product.findOne({ sku: SKU });
if (!product || product.stock < QUANTITY) {
return { label, outcome: 'insufficient stock' };
}
await waitForBothReaders();
product.stock -= QUANTITY;
await product.save();
return { label, outcome: 'reserved', observedStock: product.stock };
}
async function main() {
try {
await mongoose.connect(MONGODB_URI);
await Product.init();
await resetInventory();
const waitForBothReaders = createTwoPartyBarrier();
const results = await Promise.all([
naiveReserve('request-a', waitForBothReaders),
naiveReserve('request-b', waitForBothReaders)
]);
const finalProduct = await Product.findOne({ sku: SKU });
console.log(JSON.stringify({
mode: 'naive',
startingStock: STARTING_STOCK,
quantityPerRequest: QUANTITY,
results,
finalStock: finalProduct.stock
}, null, 2));
} finally {
await mongoose.disconnect();
}
}
main().catch(error => console.error(error));
What Should Match?
This reference combines the shared constants plus the barrier. It also includes the reset function plus the naive reservation plus the race runner.
That is the bug caught in the act. Your runner now reproduces the lost update without timing luck.
Next, you'll make the stale writer fail with an atomic compare-and-swap.
Replace the Race with an Atomic Compare-and-Swap
Your deterministic race proved that two callers can reserve eight units from a product holding five. This step turns that visible failure into a race-safe write.
The earlier save() path silently overwrites a concurrent change. A compare-and-swap filter makes a stale writer fail to match before MongoDB applies $inc.
In this step, get ready to:
- Add the atomic reservation path.
- Wire the atomic command-line mode.
- Verify that persisted stock remains at one.
Add the atomic reservation path
A single-document update can test the stock value that each caller observed. The first successful write changes that value before the competing update evaluates its filter.
Why Compare-and-Swap?
MongoDB rechecks a single-document update filter when the write reaches the document. Including the expected stock value makes stale state visible as a failed match.
The inventory invariant lives in one product document. A multi-document transaction would add setup without improving this rule.
- Select race.js from the Explorer sidebar in VS Code.
- Find the closing brace of naiveReserve().
- Paste the following function immediately below that brace:
async function atomicReserve(label, waitForBothReaders) {
const current = await Product.findOne({ sku: SKU });
if (!current || current.stock < QUANTITY) {
return { label, outcome: 'insufficient stock' };
}
await waitForBothReaders();
const updated = await Product.findOneAndUpdate(
{ sku: SKU, stock: current.stock },
{ $inc: { stock: -QUANTITY } },
{ returnDocument: 'after' }
);
if (!updated) {
return { label, outcome: 'conflict' };
}
return { label, outcome: 'reserved', observedStock: updated.stock };
}
What Does This Code Do?
- The initial read captures the stock value observed by this caller.
- The shared barrier keeps both callers aligned after their reads.
- The filter { sku: SKU, stock: current.stock } allows the update only while the stored stock still matches the observed value.
- The $inc update subtracts the reservation quantity inside the same atomic write.
- A null result from Mongoose becomes an explicit concurrency conflict.
- Save race.js.
- Run the existing naive race again in the second terminal by using:
npm run race:naive
What Does This Regression Check Prove?
The existing script still selects naiveReserve() in the current main(). A successful repeat proves the new function parses without changing the original demonstration.
You should still see two reserved outcomes. You should also see finalStock equal to 1.
Good progress. Your atomic function now loads while the deterministic lost-update baseline still behaves exactly as before.
Did the Naive Race Stop Running?
Check that atomicReserve() sits after the complete closing brace of naiveReserve(). A misplaced brace can prevent the file from loading.
Compare the function with the snippet above for missing parentheses or braces.
Ask for targeted help with diagnosing the race runner.
Wire the atomic race mode
The runner currently calls the naive function directly. Selecting a strategy from the command-line mode lets the same deterministic barrier test both implementations.
- Find this block inside main() in race.js:
const waitForBothReaders = createTwoPartyBarrier();
const results = await Promise.all([
naiveReserve('request-a', waitForBothReaders),
naiveReserve('request-b', waitForBothReaders)
]);
What Does the Current Block Show?
Both concurrent calls currently invoke naiveReserve() directly. The command-line mode cannot change the reservation strategy yet.
- Replace that block with the mode-aware version below:
const mode = process.argv[2] === 'atomic' ? 'atomic' : 'naive';
const reserve = mode === 'atomic' ? atomicReserve : naiveReserve;
const waitForBothReaders = createTwoPartyBarrier();
const results = await Promise.all([
reserve('request-a', waitForBothReaders),
reserve('request-b', waitForBothReaders)
]);
What Changes in This Block?
- The process.argv[2] value reads the mode supplied after the entry file.
- The reserve reference selects atomicReserve for atomic mode.
- Both concurrent calls use the selected strategy through the same barrier.
- Find the existing scripts inside package.json:
"start": "node app.js",
"race:naive": "node race.js naive"
What Does the Current Script List Show?
The current scripts expose the inventory viewer plus the naive race. The atomic mode needs its own script entry.
- Replace those script lines with the following version:
"start": "node app.js",
"race:naive": "node race.js naive",
"race:atomic": "node race.js atomic"
What Does the New Script Do?
The race:atomic script passes atomic as the command-line mode. The runner uses that value to select atomicReserve.
- Save race.js.
- Save package.json.
Before you run the atomic mode, predict how many requests can still match the expected stock after the first update lands.
- Run the atomic race in the second terminal by using:
npm run race:atomic
What Does This Command Test?
The script resets stock to 5 before launching both callers. Each caller tries to reserve 4 units through the atomic strategy.
The barrier guarantees that both callers observe the same starting stock. The expected-value filter decides which update still matches at write time.
One result should show reserved. The other result should show conflict.
You should also see finalStock equal to 1. The service has accepted only one four-unit reservation.
Still Seeing Two Reserved Outcomes?
Check that the reserve reference selects atomicReserve when mode equals atomic.
Confirm that the update filter includes both sku: SKU plus stock: current.stock.
Ask for help with tracing the atomic race.
✔️ Awesome, I've got everything!
Your changed files are saved. The atomic race now reports one successful reservation plus one concurrency conflict.
ⓧ I'd like to double check the full code
Compare your two changed files with these cumulative versions.
{
"name": "race-safe-inventory-service",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node app.js",
"race:naive": "node race.js naive",
"race:atomic": "node race.js atomic"
},
"engines": {
"node": "24.22.0"
},
"dependencies": {
"express": "5.3.0",
"mongoose": "9.11.2"
}
}
What Should Match in Package.json?
The scripts object contains start, race:naive, plus race:atomic. Every dependency pin remains unchanged.
import mongoose from 'mongoose';
import { Product } from './product.js';
const MONGODB_URI = 'mongodb://127.0.0.1:27017/inventory_lab';
const SKU = 'keyboard';
const STARTING_STOCK = 5;
const QUANTITY = 4;
function createTwoPartyBarrier() {
let arrivals = 0;
let release;
const bothReady = new Promise(resolve => {
release = resolve;
});
return async function waitForBothReaders() {
arrivals += 1;
if (arrivals === 2) {
release();
}
await bothReady;
};
}
async function resetInventory() {
return Product.findOneAndUpdate(
{ sku: SKU },
{ $set: { stock: STARTING_STOCK } },
{ upsert: true, returnDocument: 'after', runValidators: true }
);
}
async function naiveReserve(label, waitForBothReaders) {
const product = await Product.findOne({ sku: SKU });
if (!product || product.stock < QUANTITY) {
return { label, outcome: 'insufficient stock' };
}
await waitForBothReaders();
product.stock -= QUANTITY;
await product.save();
return { label, outcome: 'reserved', observedStock: product.stock };
}
async function atomicReserve(label, waitForBothReaders) {
const current = await Product.findOne({ sku: SKU });
if (!current || current.stock < QUANTITY) {
return { label, outcome: 'insufficient stock' };
}
await waitForBothReaders();
const updated = await Product.findOneAndUpdate(
{ sku: SKU, stock: current.stock },
{ $inc: { stock: -QUANTITY } },
{ returnDocument: 'after' }
);
if (!updated) {
return { label, outcome: 'conflict' };
}
return { label, outcome: 'reserved', observedStock: updated.stock };
}
async function main() {
try {
await mongoose.connect(MONGODB_URI);
await Product.init();
await resetInventory();
const mode = process.argv[2] === 'atomic' ? 'atomic' : 'naive';
const reserve = mode === 'atomic' ? atomicReserve : naiveReserve;
const waitForBothReaders = createTwoPartyBarrier();
const results = await Promise.all([
reserve('request-a', waitForBothReaders),
reserve('request-b', waitForBothReaders)
]);
const finalProduct = await Product.findOne({ sku: SKU });
console.log(JSON.stringify({
mode,
startingStock: STARTING_STOCK,
quantityPerRequest: QUANTITY,
results,
finalStock: finalProduct.stock
}, null, 2));
} finally {
await mongoose.disconnect();
}
}
main().catch(error => console.error(error));
What Should Match in Race.js?
The full file keeps the naive strategy for comparison. It adds atomicReserve() plus mode-based strategy selection inside main().
Verify the persisted result
The terminal output proves the competing writes were classified correctly. The read-only inventory viewer confirms that the stored document carries the same safe result.
- Switch back to the browser tab showing http://localhost:3000/inventory.
Before you refresh, predict the stock value the browser will display.
- Refresh the inventory endpoint.
You should see the keyboard product with stock equal to 1.
That closes the concurrency gap. Your service now leaves one unit in stock after rejecting the stale reservation.
Secret mission
Make Retries Idempotent
Add a request ID to the atomic reservation. Prove that a repeated checkout request changes stock only once.
Clean Up Your Resources
Clean Up Your Resources
This lab runs locally on your Mac through Docker Compose. Keeping it costs $0.
Resources you used:
- A local MongoDB 8.3.13 container for the mongo service.
- The inventory-data named volume containing the inventory database.
- The local project folder containing app.js, race.js, product.js, compose.yaml, package.json, package-lock.json, plus the installed dependencies.
Keep everything running
No action needed. Choose this option if you want to keep experimenting with the inventory races.
- Leave the inventory viewer process running.
- Keep the mongo service active.
- Keep the inventory-data volume to preserve the latest inventory state.
- Keep the local project folder for more concurrency experiments.
The inventory viewer remains available at http://localhost:3000/inventory. Your next race can reuse the saved database.
Pause - I'll come back to this later
Shut down the running processes to free up memory. Your local files plus database data remain available for later.
- Switch back to the first terminal where the inventory viewer is running.
- Press Control-C to stop the Node.js process.
- Stop the Compose service from that terminal by running this command:
docker compose down
What Does This Command Keep?
Compose removes the mongo container. It also removes the service network.
The named inventory-data volume stays on your Mac. A later Compose startup can reuse its database data.
- Confirm the command completes without an error.
You are safely paused. The inventory-data volume still holds your inventory state.
Delete - I don't want to use this again
This option permanently removes the lab's local database data plus project files. Your Node.js installation and Docker Desktop installation stay available for other work.
- Switch back to the first terminal where the inventory viewer is running.
- Press Control-C to stop the Node.js process.
- Remove the Compose service and its named volume by running this command:
docker compose down -v
What Does the Volume Flag Remove?
The -v flag removes the named inventory-data volume with the Compose resources. The saved product document disappears with that volume.
Your local project folder remains on disk for the next cleanup action.
- Confirm the command completes without an error.
- In the Explorer sidebar in Visual Studio Code, open the context menu for the local project folder.
- Select Reveal in Finder.
- Close Visual Studio Code.
- Move the revealed local project folder to Trash.
- Open Trash.
- Permanently delete only the local project folder.
- Return to its former parent directory in Finder.
You have a clean slate now. The local project folder no longer appears on your Mac.
Nice Work!
Nice Work!
You did it! Your inventory lab now reproduces an oversell on demand before proving the race-safe fix under the same conditions.
You've learned how to:
- Run a local inventory viewer that serves the keyboard stock through Express over a Mongoose connection to MongoDB in Docker Compose.
- Expose a deterministic lost update with a two-party promise barrier that makes both reservations read five units before either writes.
- Protect stock with an atomic compare-and-swap that accepts one reservation while classifying the stale writer as a conflict.
- Secret Mission: Add request-ID idempotency to the same atomic update so the duplicate is ignored without a second decrement.
Ready to quiz yourself? Test your concurrency instincts now.