What I Learned Building a Production LMS with Node.js, MongoDB & AWS in 2026
Building an LMS looks simple at the beginning. You need users, classes, payments, attendance, recordings, homework, notifications, and reports. Then you move into production. Suddenly, you are dealing with authentication, permissions, concurrent requests, payment verification, background jobs, database indexes, cloud costs, logging, deployments, backups, and uptime. I have been working on a production LMS used across web and mobile, where students attend live classes, tutors manage academic activities, payments are processed online, and several services need to work together reliably. This post covers the technical decisions that worked, the problems that became more important as the system grew, and what I would change if I started the platform again today. 1. The Architecture Our main stack includes: - Node.js - NestJS - MongoDB - AWS - Cloudflare - Docker - React / Next.js - Flutter At a high level, the architecture looks like this: Students / Tutors | +---------+---------+ | | v v Web App Mobile App React / Next.js Flutter | | +---------+---------+ | v Cloudflare | v Node.js / NestJS API | +----------------+----------------+ | | | v v v MongoDB AWS Services External APIs | +--------+--------+ | | | v v v S3 SES CloudFront This architecture has worked well for us. But the interesting part is not the technology itself. The important part is how you design the system around it. 2. Node.js Was Not the Problem One question I often hear is: Can Node.js handle a serious production system? For this type of workload, yes. A typical LMS performs a large amount of I/O work. Examples include: - Reading student profiles - Fetching timetables - Loading class information - Processing attendance - Checking enrollments - Verifying payments - Loading recordings - Sending notifications - Calling external APIs - Reading and writing database records Node.js handles this type of workload well. The bigger performance problems usually come from: - Poor database queries - Missing indexes - Too many database round trips - Unnecessary API calls - Blocking work inside request handlers - Poor background job design - Large payloads - Weak caching strategies A slow API does not automatically mean your runtime is slow. Consider a query like this: await Student.find({ instituteId, status: "ACTIVE", classIds: classId, }); It looks harmless. But as the collection grows, the wrong indexing strategy can make this request increasingly expensive. A suitable index might look like: studentSchema.index({ instituteId: 1, status: 1, classIds: 1, }); Of course, indexes should be designed around your actual query patterns and verified using query execution statistics. The lesson is simple: Fix the real bottleneck before replacing the entire technology stack. Moving from Node.js to another runtime will not fix an unindexed database query. 3. MongoDB Worked Well, but Schema Design Still Matters MongoDB is easy to start with. That flexibility can also create problems if you treat schema design as optional. It is not optional. Imagine storing every attendance record inside a student document: { "studentId": "ST001", "attendance": [ {}, {}, {}, {} ] } This might look convenient when the system is small. It becomes harder to manage when students accumulate large amounts of historical data. For frequently growing operational data, I prefer dedicated collections. For example: students tutors classes enrollments attendance payments recordings homework notifications audit_logs This makes it easier to: - Index the data correctly - Query records independently - Paginate history - Archive old records - Maintain auditability - Avoid endlessly growing documents MongoDB gives you flexibility. It does not remove the need for database architecture. 4. Attendance Is More Complex Than It Looks An LMS attendance flow sounds simple: Student joins class | v Mark present Production requirements make it much more complicated. You may need to know: - When the class started - When the student joined - Whether the student joined late - How many minutes late they were - Whether the tutor joined - Whether the student disconnected and rejoined - Whether an attendance record already exists - Whether the class session is actually valid - Whether the student is enrolled in the class Now concurrency matters. Two requests arriving almost at the same time should not create two attendance records. This pattern is risky: const attendance = await Attendance.findOne({ studentId, classId, sessionId, }); if (!attendance) { await Attendance.create(data); } There is a gap between the read and the write. Two concurrent requests can both see no record and both attempt to create one. I prefer protecting the invariant at database level: attendanceSchema.index( { studentId: 1, classId: 1, sessionId: 1, }, { unique: true, }, ); Then the application handles duplicate key conflicts safely. Application checks are useful. Database constraints are stronger. 5. Payments Must Be Idempotent Never assume a payment callback will arrive only once. Payment providers can retry callbacks. Network failures happen. Your API can retry. Users can refresh pages. Workers can retry failed jobs. Imagine this flow: Payment successful | v Store payment | v Activate enrollment | v Create invoice | v Update balance If the same transaction is processed twice, the consequences can be serious. Every payment should have a unique reference from the payment provider or your own transaction system. A basic duplicate check may look like: const existingPayment = await Payment.findOne({ gatewayTransactionId, }); if (existingPayment) { return existingPayment; } Then enforce uniqueness at database level: paymentSchema.index( { gatewayTransactionId: 1, }, { unique: true, }, ); For more complex payment flows, I also want the transaction state machine to be explicit. For example: PENDING | +----> PAID | +----> FAILED | +----> CANCELLED A confirmed payment should not accidentally move backwards because a delayed callback arrived later. Idempotency is one of the most important patterns in any system that handles money. 6. Do Not Put Everything Inside the API Request A common early architecture looks like this: await createEnrollment(); await sendEmail(); await sendSMS(); await sendNotification(); await generateInvoice(); await updateAnalytics(); return response; It works during development. But now the user is waiting for every downstream service. If the SMS provider takes four seconds to respond, your endpoint may also take four extra seconds. If the email provider fails, should the entire enrollment fail? Usually, no. A better architecture is: API Request | v Validate Request | v Critical Database Operation | v Return Response | v Background Queue | +--> Email | +--> SMS | +--> Push Notification | +--> Analytics | +--> Non-critical processing The synchronous request should handle work required to guarantee the main business operation. Non-critical work should move to background processing. This improves: - API latency - Reliability - Retry handling - Failure isolation - User experience It also gives you a better place to manage external provider failures. 7. Cloudflare Became an Important Layer Cloudflare is not only DNS. For a public platform, it can provide several useful controls before traffic reaches your application. Examples include: - DNS - TLS - CDN - DDoS protection - Web Application Firewall - Rate limiting - Bot protection - Caching The request path becomes: Internet | v Cloudflare | v AWS / Application Infrastructure | v Node.js API Rate limiting is especially important for sensitive endpoints. Examples: POST /auth/login POST /auth/password-reset POST /auth/send-otp POST /register POST /payments/verify These endpoints should not accept unlimited requests from the same source. The exact rate limits depend on the endpoint and your users. The key point is to protect expensive and sensitive operations before abuse becomes a production issue. 8. AWS Cost Is Also an Architecture Problem AWS gives you many ways to solve the same problem. That flexibility is useful. It can also become expensive when services are added without understanding how billing behaves at scale. I mentally separate infrastructure into three categories. Critical Services required for the product to function. Compute Database Object storage Backups Networking Operational Services required to operate the product reliably. Monitoring Logging Alerts CI/CD Security tooling Optional Services that provide convenience but are not always required immediately. Before adding a new managed service, I ask: - What exact problem does this solve? - Can our existing infrastructure solve it? - What happens when usage increases 10x? - What is the expected monthly cost? - What does data transfer cost? - How difficult is it to migrate away later? - Is the operational saving worth the additional cost? Cloud architecture is also cost architecture. A solution is not scalable if the business cannot afford the scaling curve. 9. Logging Changed How We Debug Production During development, this is common: console.log(error); That is not enough for a production platform. Logs need context. This log is not very useful: Payment failed A structured event is much easier to investigate: { "level": "error", "event": "PAYMENT_VERIFICATION_FAILED", "studentId": "ST001", "transactionId": "TX12345", "provider": "payment_gateway", "requestId": "REQ-8F23A1", "timestamp": "2026-08-10T04:00:00Z" } Now the operations team can answer useful questions. - What happened? - When did it happen? - Which user was affected? - Which transaction failed? - Which request triggered it? - Which external service was involved? I also like carrying a request or correlation ID across services. For example: Client Request | | requestId: REQ-8F23A1 v API | +--> Database log | +--> Payment log | +--> Background job | +--> Notification log When something breaks, one ID can help trace the entire flow. 10. Authentication and Author
Comments
No comments yet. Start the discussion.