Introduction
You have probably heard of mysql, postgresql, or sqlite. But have you heard of MongoDB? It is an open-source, non-relational database management system (DBMS) that uses flexible documents instead of tables and rows to process and store various forms of data. In this blog post, we will be looking at a situation called a Race Condition in MongoDB.
Now, what is a Race Condition? "A race condition arises in software when a computer program, to operate properly, depends on the sequence or timing of the program's processes or threads (Wikipedia)." Basically, a race condition is an event that locks software from working as intended due to a corruption of a saved state or memory.
Creating a Race Condition in MongoDB
Race conditions can occur in MongoDB because of bad queries. We will dig into that with an elaborate example. Let's say we are creating a game-server project that does the following.
1. Connects to MongoDB
2. Provides an API for players to join a room with a maximum capacity of 4 players
Step 1: Setting up the Project
I am using nodejs for this test. You can install nodejs and npm on your OS using any method you like. Set up a basic nodejs project with npm init
and execute the code below under the projects directory.
npm install typescript mongoose express @types/node @types/express
Create a typescript config file tsconfig.json
as,
{
"compilerOptions": {
"module": "commonjs",
"target": "es6",
"noImplicitAny": true,
"moduleResolution": "node",
"esModuleInterop": true,
"sourceMap": true,
"outDir": "dist",
"baseUrl": ".",
"paths": {
"*": [
"node_modules/*"
]
}
},
"include": [
"src/**/*"
]
}
Then, create a directory called src. Under it, create a mongoose schema called room
and create a typescript file room.schema.ts
. It should contain a schema with players
in it. To update the room's value to full if it reaches max players, we create another field named isFull
.
We can also create another field named count
which we will use later.
import { Document, model, Schema } from "mongoose";
export interface IRoom extends Document {
players: string[];
isFull: boolean;
count: number;
}
const RoomSchema = new Schema<IRoom>({
players: { type: [String], default: [] },
isFull: { type: Boolean, default: false },
count: { type: Number, default: 1 },
});
export const Room = model<IRoom>("Room", RoomSchema);
Step 2: Coding the API
Next, create an index.ts
file using express. Then, establish a connection to mongoose with an API that searches for a room that is not full. If found, it should add a player to it; otherwise, it should create a new room to host that new player. Think of it as a room of Ludo, where a maximum of 4 players can join, and new rooms need to be created if more players want to join.
import express from "express";
import { connect as MongooseConnect } from "mongoose";
import { IRoom, Room } from "./room.schema";
const app: express.Application = express();
const port: number = 3000;
MongooseConnect("mongodb://127.0.0.1", {
dbName: "swat",
});
function generateRandomNumber() {
return Math.random().toString().slice(2, 18);
}
app.get("/", async (req, res) => {
res.send("api works fine");
});
app.get("/race", async (req, res) => {
const userId = generateRandomNumber();
const filter = { isFull: false };
const update = { $addToSet: { players: userId } };
const options = {
new: true,
upsert: true,
setDefaultsOnInsert: true,
};
await Room.findOneAndUpdate(filter, update, options).then((room: IRoom) => {
if (room) {
if (room.players.length === 4) {
room.isFull = true;
room.save();
}
}
});
res.send();
});
app.listen(port, () => {
console.log("app running on http://localhost:" + port);
});
Notice that I created an API on the route /race
. The code looks like it should work. Let's run it.
tsc -p tsconfig.json
node dist/index.js
The first command compiles typescript and puts javascript code under the dist folder as described in tsconfig.json
. The second command runs our compiled javascript code from the dist folder.
Step 3: Testing
If we go to the URL, http://localhost:3000/race
it will hit the /race
section API code. First, it will create a random string for "userId", then search for a room that is not full. If all created rooms are full, it will create a new room. If we go to that URL again, a room should already exist with a player. So, we will find that room and be added as another player.
Repeat this a total of 8 times. If you query your MongoDB database, you will find 2 rooms with 4 players each and isFull
set to true for both rooms.
So, everything works, right? Where is the race condition I was blabbering about?
The thing is, this is not a real case scenario. Let's suppose 20 players concurrently hit your API; how will it work then? Let's try clearing the MongoDB database and then executing this code.
ab -n 20 -c 20 http://127.0.0.1:3000/race
This script uses the apache-benchmark tool to send 20 requests through 20 concurrent processes to simulate 20 players hitting the API concurrently.

It looks like my code put the first 19 players in one room and then 1 player in another room. Why did this happen? How to solve it? We will be discussing this next time, so tune in to find out.
This is Part 1 of understanding and fixing Race Conditions in MongoDB. Read Part 2 here.