Systems DesignAugust 20256 min read

Optimizing Express.js API Routing for a High-Traffic Hospital System.

When a top specialist opens their calendar, hundreds of patients try to book the same 10 slots simultaneously. Here is how I re-architected a MERN stack Express.js backend to handle massive read/write spikes without crashing.

Author

Faize Muhammed Basheer

Project

Hospital Management System

Stack

MongoDB / Express / React / Node.js

01 / The Context

A hospital appointment system sounds simple on paper: patients view doctors, select a time slot, and book it. In reality, it is a highly volatile, concurrency-heavy environment.

Unlike a standard SaaS app with predictable traffic, hospital systems experience aggressive spikes. When a highly sought-after specialist's schedule opens for the month, you get hundreds of concurrent requests trying to read the same schedule and book the exact same 15-minute slots.

The original MVP of this platform was built as a standard monolithic Express.js application. Under load testing, it crumbled. Response times spiked to 4+ seconds, the event loop blocked, and double-bookings occurred. The problem was not Node.js — it was how the API routing and middleware were structured.

02 / The Middleware Bloat Problem

In many Express.js applications, developers use global middleware indiscriminately. The original codebase had something that looked like this at the top of the server.js file:

// BAD: Global middleware applied to EVERYTHING app.use(express.json()); app.use(cors()); app.use(authenticateUser); // JWT verification app.use(fetchUserRoles); // DB call app.use('/api', mainRouter);

Because authenticateUser and fetchUserRoles were applied globally, a patient simply trying to view the public list of doctors was triggering a JWT verification and a MongoDB user lookup.

During a traffic spike, these unnecessary database lookups exhausted the MongoDB connection pool, causing the entire API to hang.

Middleware is not free. Every function you chain to a route adds latency and consumes event loop cycles. Global database lookups are the silent killer of Express applications.

03 / Decoupling with Modular Routers

The first step was tearing down the global middleware and adopting a strictly modular routing architecture. In Express, express.Router()allows you to create isolated mini-applications.

I split the API into public routes (read-heavy, heavily cached) and private routes (write-heavy, strictly authenticated).

// routes/public/doctors.js const express = require('express'); const router = express.Router(); // NO auth middleware here router.get('/', getDoctorsList); router.get('/:id/slots', getDoctorSlots); // routes/private/appointments.js const express = require('express'); const router = express.Router(); // Auth applied ONLY to the routes that need it router.use(requireAuth); router.post('/book', validateBookingPayload, bookAppointment); router.get('/my-appointments', getUserAppointments); // server.js app.use('/api/public/doctors', publicDoctorsRouter); app.use('/api/private/appointments', privateAppointmentsRouter);

By isolating the routes, public schedule lookups dropped from 250ms to 15ms because they no longer hit the users collection in the database.

04 / The Async Error Wrapper

One of the biggest issues with Express.js is how it handles asynchronous errors. If a database query fails inside a route and you forget to wrap it in a try/catch block, you get an Unhandled Promise Rejection, which can crash the entire Node.js process.

The original codebase was littered with massive try/catch blocks that made the business logic unreadable. I replaced them all with a single Higher-Order Function (HOF) wrapper.

// utils/catchAsync.js // Wraps async controllers and catches errors automatically const catchAsync = (fn) => { return (req, res, next) => { fn(req, res, next).catch(next); }; }; // controllers/appointmentController.js // Look how clean the controller becomes — no try/catch! exports.bookAppointment = catchAsync(async (req, res, next) => { const { doctorId, slotTime } = req.body; const slot = await Slot.findOne({ doctorId, time: slotTime }); if (!slot) return next(new AppError('Slot not found', 404)); if (slot.isBooked) return next(new AppError('Slot already booked', 400)); slot.isBooked = true; slot.patientId = req.user.id; await slot.save(); res.status(200).json({ status: 'success', data: slot }); });

Any error thrown inside catchAsync is automatically forwarded to the Express global error handling middleware via next(). This eliminated process crashes and reduced controller file size by 30%.

05 / Route-Level Redis Caching

When a specialist opens their schedule, 95% of the traffic is read-only — users refreshing the page to see available slots. Hitting MongoDB for every schedule read is a massive waste of resources.

I implemented Redis caching specifically as a route-level middleware.

// middleware/redisCache.js const getCachedSlots = catchAsync(async (req, res, next) => { const { doctorId, date } = req.query; const cacheKey = `slots:${doctorId}:${date}`; const cachedData = await redisClient.get(cacheKey); if (cachedData) { // Cache HIT: Return immediately without hitting MongoDB return res.status(200).json(JSON.parse(cachedData)); } // Cache MISS: Attach key to request and continue to controller req.cacheKey = cacheKey; next(); }); // routes.js router.get('/slots', getCachedSlots, fetchSlotsFromDB);

The critical part is cache invalidation. Whenever an appointment is successfully booked, the booking controller immediately deletes the relevant Redis key (redisClient.del(req.cacheKey)). This guarantees that patients never see "stale" slots that have already been taken.

06 / Transaction Safety in Bookings

The hardest problem was the "double booking" race condition. If two users click "Book" on the 10:00 AM slot at the exact same millisecond, they both read the slot as "available", and both proceed to book it.

In MongoDB, I solved this by moving away from separate read/write operations and utilizing MongoDB's atomic findOneAndUpdate with strict query conditions.

// Atomic booking — prevents race conditions exports.bookAppointment = catchAsync(async (req, res, next) => { const { slotId } = req.body; // We only update if the slot exists AND is currently NOT booked const updatedSlot = await Slot.findOneAndUpdate( { _id: slotId, isBooked: false }, { $set: { isBooked: true, patientId: req.user._id, bookedAt: new Date() } }, { new: true } // Returns the updated document ); // If updatedSlot is null, it means the slot was either not found, // OR someone else booked it a millisecond before this query ran. if (!updatedSlot) { return next(new AppError('Sorry, this slot was just taken.', 409)); } // Clear Redis Cache await redisClient.del(`slots:${updatedSlot.doctorId}:${updatedSlot.date}`); res.status(200).json({ success: true, appointment: updatedSlot }); });

Because MongoDB executes this single document update atomically, the race condition is completely eliminated at the database level. No complex distributed locks required.

07 / Results & Takeaways

01

API Response times dropped by 90%

By removing global auth middleware from public routes and implementing Redis, the doctor search and schedule endpoints went from 250ms to ~15ms.

02

Zero Double-Bookings

Replacing two-step read/write logic with atomic findOneAndUpdate operations mathematically guaranteed that two patients could never secure the same slot.

03

Process Stability

The catchAsync wrapper eliminated Unhandled Promise Rejections. Errors are now caught, formatted, and returned as clean JSON to the React frontend without ever crashing the Node.js instance.

04

Controller Clarity

Moving logic out of massive server files into modular routers, and stripping away try/catch boilerplate made the codebase highly maintainable.