JavaScript
Mongoose
Async/Await
Node.js
MongoDB

Using Async/await with mongoose

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

async and await make Mongoose code much easier to read than callback-heavy code, but there are still a few important rules that affect correctness. The most useful ones are: connect once at startup, handle errors close to the query, and remember that Mongoose queries are thenables, not the same thing as native promises.

Connect Once, Not Per Request

The database connection belongs in application startup, not inside every route handler or service method.

javascript
1import mongoose from "mongoose";
2
3export async function connectDatabase(uri) {
4  await mongoose.connect(uri, {
5    maxPoolSize: 20,
6    serverSelectionTimeoutMS: 5000,
7  });
8
9  console.log("MongoDB connected");
10}

Then start the server only after the connection succeeds:

javascript
1import { connectDatabase } from "./db.js";
2
3await connectDatabase(process.env.MONGO_URI);
4// start HTTP server here

This avoids repeated connection setup and removes a whole class of race conditions under load.

Basic CRUD with await

Once the connection exists, ordinary CRUD code becomes straightforward.

javascript
1import mongoose from "mongoose";
2
3const userSchema = new mongoose.Schema(
4  {
5    email: { type: String, required: true, unique: true },
6    name: { type: String, required: true },
7  },
8  { timestamps: true }
9);
10
11const User = mongoose.model("User", userSchema);
12
13const created = await User.create({ email: "[email protected]", name: "Ana" });
14const users = await User.find({}).lean();
15
16console.log(created._id.toString());
17console.log(users.length);

Using lean() for read-heavy paths is often a good choice when you only need plain objects and not full Mongoose document features.

Handle Errors with try and catch

await does not remove the need for explicit error handling. It just makes the flow easier to follow.

javascript
1async function createUser(payload) {
2  try {
3    const user = await User.create(payload);
4    return { ok: true, user };
5  } catch (error) {
6    if (error.code === 11000) {
7      return { ok: false, error: "duplicate_email" };
8    }
9
10    return { ok: false, error: "unknown_db_error" };
11  }
12}

Mapping known database errors into stable application-level results is much better than leaking raw driver details to callers.

Queries Are Thenables, Not Native Promises

This detail matters more than many developers realize. Mongoose queries can be awaited, but they are not native promises in the same sense as doc.save(). Query objects are thenables, and observing the same query more than once can cause it to execute again.

javascript
1const query = User.findOne({ email: "[email protected]" });
2
3const first = await query;
4const second = await query;
5
6console.log(first === second);

That code is risky because the query may run multiple times. If you want a fully-fledged promise, call exec().

javascript
const promise = User.findOne({ email: "[email protected]" }).exec();
const user = await promise;

This is especially important when mixing old promise chains, callbacks, and await in the same codebase.

Use Parallel await Only for Independent Work

Sequential await is clear, but sometimes it adds unnecessary latency when the operations do not depend on each other.

javascript
1const [count, newest] = await Promise.all([
2  User.countDocuments({}),
3  User.findOne({}).sort({ createdAt: -1 }).lean().exec(),
4]);

This is a good optimization for independent reads. Do not do it when one result depends on the other or when the operations must happen in a transaction-like order.

Transactions for Multi-Document Consistency

If several writes must succeed or fail together, use a session and transaction.

javascript
1async function transferCredits(fromUserId, toUserId, amount) {
2  const session = await mongoose.startSession();
3
4  try {
5    await session.withTransaction(async () => {
6      await User.updateOne({ _id: fromUserId }, { $inc: { credits: -amount } }, { session });
7      await User.updateOne({ _id: toUserId }, { $inc: { credits: amount } }, { session });
8    });
9  } finally {
10    await session.endSession();
11  }
12}

Transactions are powerful, but they are also heavier than ordinary operations, so use them for real consistency requirements rather than routine single-document writes.

Common Pitfalls

The most common mistake is calling mongoose.connect() inside request handlers. Another is forgetting that awaited queries are thenables and may re-execute if observed more than once. Developers also skip exec() when they really want a stable promise boundary, and they often serialize unrelated database reads with back-to-back await calls instead of using Promise.all. Finally, broad catch blocks that collapse all failures into one generic message make debugging much harder than it needs to be.

Summary

  • Connect to MongoDB once during application startup.
  • Use await to keep CRUD code readable and direct.
  • Handle known Mongoose errors explicitly with try and catch.
  • Remember that queries are thenables; use exec() when you want a real promise boundary.
  • Use Promise.all for independent reads and transactions only when multi-document consistency truly matters.

Course illustration
Course illustration

All Rights Reserved.