node.js
mongodb
asynchronous queries
database management
javascript

Handling asynchronous database queries in node.js and mongodb

System Design practice on Codemia

Work through 120+ system design problems with detailed solutions, from rate limiters to multi-region storage.

Practice system design

Introduction

MongoDB queries in Node.js are asynchronous, which means your code should be structured around promises and async/await, not around blocking assumptions. The main goals are to keep control flow readable, avoid leaking connections or cursors, and handle failures without turning the application into nested callback logic.

Start with One Shared MongoDB Client

The first design decision is usually more important than the query syntax: do not create a new database connection for every request.

javascript
1const { MongoClient } = require("mongodb");
2
3const client = new MongoClient(process.env.MONGODB_URI);
4
5async function connectDb() {
6  if (!client.topology || !client.topology.isConnected()) {
7    await client.connect();
8  }
9  return client.db("appdb");
10}

In real applications, initialize the client once during startup and reuse it. Connection reuse matters for both performance and operational stability.

Use async and await for Query Flow

Modern Node.js MongoDB code is usually cleanest with async and await.

javascript
1async function findUserByEmail(email) {
2  const db = await connectDb();
3  return db.collection("users").findOne({ email });
4}
5
6async function main() {
7  try {
8    const user = await findUserByEmail("[email protected]");
9    console.log(user);
10  } catch (err) {
11    console.error("query failed:", err);
12  }
13}
14
15main();

This is easier to reason about than callback-based flow, especially when one request performs several dependent queries.

Handle Collections of Documents Carefully

A find() call returns a cursor, not an array of documents. You normally either:

  • convert the cursor to an array
  • iterate the cursor

Small result sets:

javascript
1async function listActiveUsers() {
2  const db = await connectDb();
3  return db.collection("users").find({ active: true }).toArray();
4}

For very large result sets, toArray() may be the wrong choice because it loads everything into memory.

Cursor iteration is better in those cases:

javascript
1async function printUsers() {
2  const db = await connectDb();
3  const cursor = db.collection("users").find({});
4
5  for await (const doc of cursor) {
6    console.log(doc.email);
7  }
8}

That keeps memory usage more predictable.

Run Queries in Parallel Only When Independent

If two queries do not depend on each other, Promise.all can reduce total latency.

javascript
1async function loadDashboardData(userId) {
2  const db = await connectDb();
3
4  const [user, orders] = await Promise.all([
5    db.collection("users").findOne({ _id: userId }),
6    db.collection("orders").find({ userId }).toArray(),
7  ]);
8
9  return { user, orders };
10}

Do not parallelize queries blindly. If one query depends on the result of another, sequential flow is correct.

Error Handling and Timeouts

Database code should fail explicitly. Wrap request-level handlers in try/catch and log enough context to diagnose query failures.

javascript
1async function getUserHandler(req, res) {
2  try {
3    const db = await connectDb();
4    const user = await db.collection("users").findOne({ email: req.params.email });
5
6    if (!user) {
7      return res.status(404).json({ error: "not found" });
8    }
9
10    res.json(user);
11  } catch (err) {
12    console.error("getUserHandler failed:", err);
13    res.status(500).json({ error: "internal error" });
14  }
15}

Do not let promise rejections disappear silently. Unhandled query failures become production incidents that are much harder to trace later.

Be Deliberate with Writes

Asynchronous writes should still be awaited, especially if later code depends on success.

javascript
1async function createUser(email) {
2  const db = await connectDb();
3  const result = await db.collection("users").insertOne({ email, active: true });
4  return result.insertedId;
5}

Fire-and-forget writes are usually a bad idea unless you have a very explicit background-processing design.

Transactions and Sessions

If multiple writes must succeed or fail together, use sessions and transactions where your MongoDB deployment supports them.

That is still asynchronous code, but the control flow becomes more structured:

  • start a session
  • run transactional operations
  • commit or abort

Do not try to simulate transactions with timing assumptions or unsequenced promises.

Common Pitfalls

  • Opening a new MongoDB client for every query instead of reusing one.
  • Using find() and forgetting that it returns a cursor, not documents.
  • Calling toArray() on large datasets without thinking about memory usage.
  • Running dependent queries in parallel when the order actually matters.
  • Letting async query errors go unhandled.

Summary

  • In Node.js, MongoDB queries should normally be written with async and await.
  • Reuse a shared MongoDB client instead of reconnecting per request.
  • Use toArray() for manageable result sets and cursor iteration for large ones.
  • Use Promise.all only for truly independent queries.
  • Treat error handling and resource usage as part of the async query design, not as afterthoughts.

Related reading
Course
Beginner
27 lessons
10 hours
System Design Fundamentals

Build a strong foundation in designing scalable, reliable distributed systems.

View the course
Track what you have practised

A free account saves your progress, solutions and study plan across every problem on Codemia.

System Design practice on Codemia

Work through 120+ system design problems with detailed solutions, from rate limiters to multi-region storage.

Practice system design

All Rights Reserved.