When we first launched The Localstreet, our entire backend ran on a single Node.js server with a MongoDB Atlas cluster. That was fine for 50 vendors. When we hit 500, things started creaking. At 5,000, we had rebuilt almost every major system. Here’s what I learned.
The Monolith Phase Is Not a Mistake
The biggest misconception in startup engineering is that you should build microservices from day one. We didn’t, and I’m glad.
Our first version was a clean monolith: Express.js, Mongoose, JWT auth, basic REST. It let us ship fast, validate the business model, and understand our actual access patterns before committing to any specific architecture.
Don’t optimize what you haven’t measured. The best architecture is the one that ships.
The moment microservices make sense is when your team size outgrows your codebase, not the other way around.
The First Scaling Wall: MongoDB Queries
At around 1,000 vendors and 200,000 products, our product search started degrading. A simple geospatial query that found nearby products was taking 800ms. Unacceptable.
The fix was understanding MongoDB’s indexing model deeply.
Before: Naive Query
// Querying without a proper compound index
const products = await Product.find({
location: {
$near: {
$geometry: { type: 'Point', coordinates: [lng, lat] },
$maxDistance: 5000
}
},
category: req.query.category,
inStock: true
});
This worked at small scale. At 200k products, MongoDB was doing a full geospatial scan then filtering. Slow.
After: Compound Index + Covered Query
// Add this index in your schema or migration
await Product.createIndex({
location: '2dsphere',
category: 1,
inStock: 1
});
// Same query — now sub-20ms at 200k docs
const products = await Product.find({
location: {
$near: {
$geometry: { type: 'Point', coordinates: [lng, lat] },
$maxDistance: 5000
}
},
category: req.query.category,
inStock: true
}).select('name price vendor thumbnail').lean();
Two things made this fast: the compound index so MongoDB uses one index for all three conditions, and .lean() which returns plain JS objects instead of full Mongoose documents — 30% faster for read-heavy endpoints.
The Second Wall: Session & Cart Performance
Every product page was hitting the database to check session validity and load the cart. At 10,000 daily active users, that’s tens of thousands of redundant DB reads per hour.
We moved sessions and carts to Redis with a simple TTL-based pattern:
import { createClient } from 'redis';
const redis = createClient({ url: process.env.REDIS_URL });
// Session middleware — O(1) lookup instead of DB query
export const getSession = async (sessionId: string) => {
const cached = await redis.get(`session:${sessionId}`);
if (cached) return JSON.parse(cached);
const session = await Session.findById(sessionId).lean();
if (session) {
await redis.setEx(`session:${sessionId}`, 3600, JSON.stringify(session));
}
return session;
};
// Cart — user-specific cache with 24h TTL
export const getCart = async (userId: string) => {
const key = `cart:${userId}`;
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const cart = await Cart.findOne({ userId }).lean();
await redis.setEx(key, 86400, JSON.stringify(cart ?? { items: [] }));
return cart;
};
This cut our database query volume by ~60% during peak hours.
The Third Wall: Real-time Order Updates
Vendors needed to see new orders appear instantly. Customers wanted live delivery tracking. We were polling every 5 seconds — terrible UX and terrible for the server.
The solution was Server-Sent Events (SSE) for vendor dashboards and WebSockets for delivery tracking. SSE is often overlooked but it’s simpler than WebSockets for one-directional push:
// SSE endpoint for vendor order feed
app.get('/api/vendor/:id/orders/stream', async (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
const handler = (order) => {
if (order.vendorId === req.params.id) {
res.write(`data: ${JSON.stringify(order)}\n\n`);
}
};
orderEmitter.on('new-order', handler);
req.on('close', () => orderEmitter.off('new-order', handler));
});
For delivery tracking with bidirectional needs, we used Socket.io with Redis adapter so messages could be broadcast across multiple Node.js instances:
const { createAdapter } = require('@socket.io/redis-adapter');
io.adapter(createAdapter(pubClient, subClient));
What I’d Do Differently
-
Add request IDs from day one. Distributed tracing is painful to retrofit. Every request should carry a
X-Request-IDheader that flows through every service call and log line. -
Index before you need it. We added indexes reactively. Running
db.collection.explain("executionStats")on every new query before shipping it takes 2 minutes and saves hours of debugging. -
Never store sessions in MongoDB. Use Redis or JWT from the start.
-
Read your slow query logs weekly. MongoDB Atlas and AWS RDS both have slow query logs. I check ours every Monday morning — it’s caught issues before users noticed every single time.
Current Stack
| Layer | Technology |
|---|---|
| API | Node.js + Fastify |
| Database | MongoDB Atlas |
| Cache | Redis (Upstash) |
| Queue | BullMQ + Redis |
| Search | MongoDB Atlas Search |
| Real-time | Socket.io + Redis Adapter |
| CDN | Cloudflare |
| Hosting | AWS ECS + Fargate |
The system now handles our peak traffic with under 100ms p95 latency. The key insight: most scaling problems are query problems, not infrastructure problems. Fix your queries before you scale your servers.