August 3, 2026 · Varun Sharma

The Bug That Silently Crashes Every MERN Stack App: Unhandled Async Errors

Your React + Node.js + MongoDB app works flawlessly on your machine. Every request returns fast. Every form submits cleanly. You deploy it.

Two days later, your server is down. No obvious cause in the logs. No crash on a specific action you can reproduce. Just... dead, and you have to manually restart it.

Nine times out of ten, the culprit is the same thing: an unhandled error somewhere in an async chain, quietly waiting for the one edge case that finally trips it — a slow network, a malformed request, a MongoDB timeout — and takes the whole process down with it.

Let's walk through where this bug hides at each layer of the stack, because it shows up differently in Express, in Mongoose, and in React — but it's the same root cause every time.

Layer 1: Express Routes That Don't Catch Their Own Errors

This is the big one. In Node.js, an unhandled rejection inside an async function doesn't just fail that request — it can crash the entire server process, taking down every other user's connection with it.

javascript

// ❌ DANGEROUS: no try/catch, no error handling
app.get('/api/users/:id', async (req, res) => {
  const user = await User.findById(req.params.id);
  res.json(user);
});

Looks fine, right? It works every time you test it — until req.params.id isn't a valid MongoDB ObjectId, or the database connection blips for a second. Then findById throws, nothing catches it, and depending on your Node version and setup, that unhandled rejection can crash the process.

javascript

// ✅ SAFE: explicit try/catch in every async route
app.get('/api/users/:id', async (req, res) => {
  try {
    const user = await User.findById(req.params.id);
    if (!user) return res.status(404).json({ error: 'User not found' });
    res.json(user);
  } catch (err) {
    res.status(500).json({ error: 'Something went wrong' });
  }
});

Writing try/catch in every single route gets repetitive fast, so wrap it in a helper instead of relying on memory and discipline:

javascript

// A reusable async wrapper — catches errors and forwards them to Express's error handler
const asyncHandler = (fn) => (req, res, next) => {
  Promise.resolve(fn(req, res, next)).catch(next);
};

app.get('/api/users/:id', asyncHandler(async (req, res) => {
  const user = await User.findById(req.params.id);
  if (!user) return res.status(404).json({ error: 'User not found' });
  res.json(user);
}));

Then add a centralized error-handling middleware as your safety net, so nothing ever falls through uncaught:

javascript

// Must be defined AFTER all your routes
app.use((err, req, res, next) => {
  console.error(err);
  res.status(500).json({ error: 'Internal server error' });
});

Layer 2: Mongoose Queries That Assume the Happy Path

A shocking amount of MERN apps assume every MongoDB query succeeds and returns exactly what's expected. Two specific traps:

javascript

// ❌ Assumes user exists — crashes if id is valid format but no match
const user = await User.findById(req.params.id);
res.json({ name: user.name }); // TypeError: Cannot read properties of null

// ❌ Assumes req.params.id is a valid ObjectId — throws a CastError otherwise

javascript

// ✅ Validate the ID format and check for a result before using it
const mongoose = require('mongoose');

if (!mongoose.Types.ObjectId.isValid(req.params.id)) {
  return res.status(400).json({ error: 'Invalid user ID' });
}

const user = await User.findById(req.params.id);
if (!user) {
  return res.status(404).json({ error: 'User not found' });
}

Also make sure your MongoDB connection itself has error handling — a lot of tutorials skip this entirely:

javascript

mongoose.connect(process.env.MONGO_URI)
  .then(() => console.log('MongoDB connected'))
  .catch((err) => {
    console.error('MongoDB connection failed:', err);
    process.exit(1); // fail fast and loudly instead of running in a broken state
  });

mongoose.connection.on('error', (err) => {
  console.error('MongoDB runtime error:', err);
});

Layer 3: React Components That Fetch Without Handling Failure (or Unmounting)

On the frontend, the same "assumes success" pattern shows up as components that never account for a failed request, a slow request, or a component that's already gone by the time the response arrives.

javascript

// ❌ No error handling, no loading state, no cleanup
useEffect(() => {
  fetch(`/api/users/${id}`)
    .then(res => res.json())
    .then(data => setUser(data));
}, [id]);

If the fetch fails, setUser never runs and the component just hangs silently in a broken state — no error message, no retry, nothing. Worse, if the component unmounts before the fetch resolves (say, the user navigated away), you'll get a React warning about setting state on an unmounted component, and in rarer cases, real bugs from stale updates.

javascript

// ✅ Handles loading, errors, and unmounting
useEffect(() => {
  let isMounted = true;
  const controller = new AbortController();

  const fetchUser = async () => {
    setLoading(true);
    try {
      const res = await fetch(`/api/users/${id}`, { signal: controller.signal });
      if (!res.ok) throw new Error(`Request failed: ${res.status}`);
      const data = await res.json();
      if (isMounted) setUser(data);
    } catch (err) {
      if (isMounted && err.name !== 'AbortError') setError(err.message);
    } finally {
      if (isMounted) setLoading(false);
    }
  };

  fetchUser();

  return () => {
    isMounted = false;
    controller.abort();
  };
}, [id]);

The Global Safety Net (For When You Still Miss One)

Even with careful code, you should have a last line of defense on the Node.js process itself, so an unexpected error logs clearly instead of crashing silently or leaving the process in a corrupted state:

javascript

process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled Rejection:', reason);
});

process.on('uncaughtException', (err) => {
  console.error('Uncaught Exception:', err);
  process.exit(1); // restart cleanly rather than continue in a broken state
});

Pair this with a process manager like PM2 or a container orchestrator that automatically restarts the server if it does go down — so a missed edge case costs you a few seconds of downtime, not a 3am page.

The Takeaway

The MERN stack doesn't fail because React, Node, or MongoDB are unreliable — it fails because async code makes it deceptively easy to write a happy-path-only implementation that works perfectly in every test you happen to think of, and falls over the first time reality sends it something you didn't.

The rule that saves you: every await needs a plan for what happens when it fails — in your Express routes, your Mongoose queries, and your React effects. If you haven't written the failure path, you haven't finished the feature.