Engineering notes
Scaling a Node.js API from 200K to 1M users with 60% faster responses
By Muhammad Shahid · · 3 min read
Quick answer
The backend of CV Maker – Resume Builder, an Android app on Google Play, grew from 200K to 1M+ users while average API response time dropped by about 60%. The work was MongoDB indexing, replacing repeated queries with aggregation pipelines, response caching, pagination, and request validation with centralised error handling, while releases stayed stable.
| Project | CV Maker – Resume Builder (Google Play) |
|---|---|
| Growth | 200K to 1M+ users |
| Speed | About 60% faster average API response time |
| Fix | Indexes, aggregation pipelines, caching, pagination |
| Stack | Node.js, Express, MongoDB |
Where does the time go in a slow Node.js API?
In most apps it is not Node.js itself. It is the database: queries without indexes, many small queries where one would do, and endpoints that return far more data than the screen shows. Measure response time per endpoint first, then work down the list from the slowest endpoint that is called most often.
How do indexes change the picture?
An index lets MongoDB jump to the documents a query needs instead of reading the whole collection. Add compound indexes that match the filters and sort order of your hottest queries, and check with explain() that each query now examines roughly as many documents as it returns. This is usually the single biggest win.
Why replace repeated queries with an aggregation pipeline?
A common pattern is to fetch a list, then loop over it in Node.js and run one more query per item. With 50 items that is 51 round trips. An aggregation pipeline does the filtering, joining and counting inside MongoDB in one request:
const resumes = await Resume.aggregate([
{ $match: { userId } },
{ $sort: { updatedAt: -1 } },
{ $limit: 20 },
{ $lookup: { from: "templates", localField: "templateId", foreignField: "_id", as: "template" } },
{ $project: { title: 1, updatedAt: 1, "template.name": 1 } },
])
Put $match and $limit as early as possible so later stages work on fewer documents, and $project only the fields the app shows.
What should you cache?
Responses that are identical for many users and change rarely: template lists, app configuration, static content. Cache them in memory or Redis with a short expiry. Per-user data that changes on every edit is usually better served fast from an index than cached.
How should an API paginate large lists?
Never return a whole collection. Return a page of results and a way to get the next one. Skip-and-limit is fine for small offsets; for long lists, cursor pagination on an indexed field stays fast at any depth:
// Next page: everything older than the last item the client has
const page = await Resume.find({ userId, updatedAt: { $lt: cursor } })
.sort({ updatedAt: -1 })
.limit(20)
How do validation and central error handling reduce production errors?
Validate every request body at the edge, so bad input is rejected with a clear 400 instead of failing deep inside the code. Then send every error through one Express error handler that logs it with context and returns a consistent response. Errors stop being scattered, so you can see which ones happen most and fix them.
app.use((err, req, res, next) => {
logger.error({ err, path: req.path, user: req.user?.id })
const status = err.status || 500
res.status(status).json({ error: status === 500 ? "Something went wrong" : err.message })
})
How do you keep releases stable while traffic grows?
Ship small changes, keep new API fields backward compatible so older app versions keep working, and watch error rates and response times right after each release. Mobile apps make this matter more: users do not all update at once, so the backend must serve several app versions at the same time.
Frequently asked questions
How much faster did the CV Maker API get?
Average response time dropped by about 60%, while the user base grew from 200K to over 1M.
What is the N+1 query problem?
Fetching a list and then running one more query for each item. It multiplies database round trips; an aggregation pipeline or a single query with $in usually replaces it.
Should I use skip and limit for pagination?
For short lists, yes. For long lists, use cursor pagination on an indexed field such as updatedAt, which stays fast at any page depth.
Is Node.js fast enough for 1M users?
Yes. In practice the database and the shape of your queries decide performance far more than the runtime.
Why centralise error handling in Express?
One handler logs every error with context and returns a consistent response, so you can see which errors happen most and fix them.
Can you scale my app backend?
Yes. I have scaled a mobile app backend to 1M+ users. See the Node.js backend service page.