DEV Community

Cover image for What I Learned Building a Production LMS with Node.js, MongoDB & AWS in 2026
Tisankan
Tisankan

Posted on • Originally published at tisankan.dev

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
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

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": [
    {},
    {},
    {},
    {}
  ]
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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,
  },
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Then enforce uniqueness at database level:

paymentSchema.index(
  {
    gatewayTransactionId: 1,
  },
  {
    unique: true,
  },
);
Enter fullscreen mode Exit fullscreen mode

For more complex payment flows, I also want the transaction state machine to be explicit.

For example:

PENDING
   |
   +----> PAID
   |
   +----> FAILED
   |
   +----> CANCELLED
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Operational

Services required to operate the product reliably.

Monitoring
Logging
Alerts
CI/CD
Security tooling
Enter fullscreen mode Exit fullscreen mode

Optional

Services that provide convenience but are not always required immediately.

Before adding a new managed service, I ask:

  1. What exact problem does this solve?
  2. Can our existing infrastructure solve it?
  3. What happens when usage increases 10x?
  4. What is the expected monthly cost?
  5. What does data transfer cost?
  6. How difficult is it to migrate away later?
  7. 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);
Enter fullscreen mode Exit fullscreen mode

That is not enough for a production platform.

Logs need context.

This log is not very useful:

Payment failed
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

When something breaks, one ID can help trace the entire flow.


10. Authentication and Authorization Are Different Problems

Authentication answers:

Who is this user?

Authorization answers:

What is this user allowed to do?

They should not be treated as the same thing.

An LMS can have roles such as:

Student
Tutor
Academic Coordinator
Student Consultant
Finance Staff
Administrator
Super Administrator
Enter fullscreen mode Exit fullscreen mode

All of these users may be authenticated.

They should not have the same capabilities.

A system built entirely around checks like this can become difficult to maintain:

if (user.role === "ADMIN") {
  // allow action
}
Enter fullscreen mode Exit fullscreen mode

As the product grows, I prefer permissions that represent capabilities.

For example:

class.create
class.view
class.update

attendance.view
attendance.update

payment.view
payment.verify

recording.view
recording.manage

student.view
student.update

user.permission.manage
Enter fullscreen mode Exit fullscreen mode

Roles then become collections of permissions.

Tutor
  class.view
  attendance.view
  homework.create
  homework.grade
  recording.view

Finance Staff
  payment.view
  payment.verify
  invoice.view
Enter fullscreen mode Exit fullscreen mode

This model scales better when responsibilities change.

It also reduces the temptation to give a user a powerful role just because they need one extra action.


11. Validate Data at the API Boundary

Frontend validation improves user experience.

It is not a security boundary.

Anyone can call your API directly.

That means the backend must validate incoming data independently.

For example:

const schema = Joi.object({
  classId: Joi.string().required(),
  studentId: Joi.string().required(),
  amount: Joi.number().positive().required(),
});
Enter fullscreen mode Exit fullscreen mode

I prefer this flow:

Request
   |
   v
Validation
   |
   v
Authentication
   |
   v
Authorization
   |
   v
Controller
   |
   v
Service
   |
   v
Database / External Services
Enter fullscreen mode Exit fullscreen mode

Your service layer should receive structurally valid data.

This keeps business logic cleaner and reduces defensive checks throughout the codebase.


12. Keep Controllers Small

One backend pattern I strongly prefer is:

Controller
    |
    v
Service
    |
    v
Repository / Database
Enter fullscreen mode Exit fullscreen mode

Controllers should coordinate HTTP concerns.

They should not become the entire application.

This becomes difficult to maintain:

@Post()
async create() {
  // validate request
  // query student
  // check enrollment
  // verify payment
  // create enrollment
  // send notification
  // send email
  // create audit log
  // update analytics
}
Enter fullscreen mode Exit fullscreen mode

A cleaner controller might look like:

@Post()
async create(@Body() dto: CreateEnrollmentDto) {
  return this.enrollmentService.create(dto);
}
Enter fullscreen mode Exit fullscreen mode

Then the service owns the business process.

For example:

@Injectable()
export class EnrollmentService {
  async create(dto: CreateEnrollmentDto) {
    const student = await this.getStudent(dto.studentId);

    await this.assertCanEnroll(student, dto.classId);

    const enrollment = await this.createEnrollment(dto);

    await this.queuePostEnrollmentTasks(enrollment);

    return enrollment;
  }
}
Enter fullscreen mode Exit fullscreen mode

This improves:

  • Testability
  • Readability
  • Reuse
  • Debugging
  • Maintenance

The service should still be kept focused.

A single 2,000-line service is not better than a 2,000-line controller.


13. Observability Is More Than CPU and RAM

A server showing:

CPU: 20%
RAM: 40%
Enter fullscreen mode Exit fullscreen mode

does not mean the application is healthy.

Those metrics are useful, but they are only part of the picture.

For a production LMS, I care about metrics such as:

  • API response time
  • API error rate
  • Database latency
  • Slow queries
  • Failed background jobs
  • Queue depth
  • Payment verification failures
  • Login failures
  • SMS failures
  • Email failures
  • External API latency
  • Application restarts
  • Memory growth
  • Disk usage
  • Availability

I also want alerts based on useful thresholds.

For example:

5xx rate > normal threshold
Payment callback failures increasing
Queue backlog continuously growing
Database latency suddenly increasing
Application restarting repeatedly
Storage nearing capacity
Enter fullscreen mode Exit fullscreen mode

The goal is simple.

I want the engineering team to know about an important problem before students or tutors need to report it.


14. Background Jobs Need Retry Rules

Moving work to a queue does not automatically make it reliable.

You also need to decide what happens when a job fails.

For example:

Send SMS
   |
   v
Provider timeout
   |
   v
Retry
   |
   v
Provider timeout
   |
   v
Retry later
Enter fullscreen mode Exit fullscreen mode

Not every job should retry forever.

A useful policy might look like:

Attempt 1: immediately
Attempt 2: after 30 seconds
Attempt 3: after 2 minutes
Attempt 4: after 10 minutes
Final: mark failed and alert if important
Enter fullscreen mode Exit fullscreen mode

This is a simplified example.

The correct policy depends on the operation.

Payment processing, email delivery, recording synchronization, and analytics may all need different retry behaviour.

You should also make jobs idempotent whenever possible.

A retried job should not accidentally create duplicate data.


15. Audit Logs Are Worth Adding Early

Production systems eventually reach a point where somebody asks:

Who changed this?

You need an answer.

For important administrative actions, I want an audit event containing data such as:

{
  "actorId": "USR001",
  "action": "STUDENT_STATUS_UPDATED",
  "entityType": "student",
  "entityId": "ST001",
  "previousValue": {
    "status": "ACTIVE"
  },
  "newValue": {
    "status": "SUSPENDED"
  },
  "timestamp": "2026-08-10T04:30:00Z"
}
Enter fullscreen mode Exit fullscreen mode

Examples of operations worth auditing include:

  • Payment changes
  • Refund actions
  • Student status changes
  • Enrollment changes
  • Role changes
  • Permission changes
  • Tutor changes
  • Manual attendance changes
  • Important configuration changes

Application logs tell you what the system did.

Audit logs tell you what users and administrators changed.

They solve different problems.


16. Security Needs Multiple Layers

There is no single "security feature" that makes a platform secure.

For a production LMS, I think about security across multiple layers.

Edge

Cloudflare
WAF
Rate limiting
DDoS protection
Bot controls
Enter fullscreen mode Exit fullscreen mode

Application

Authentication
Authorization
Input validation
Secure session/token handling
Idempotency
Safe error handling
Enter fullscreen mode Exit fullscreen mode

Data

Least privilege
Encryption
Backups
Database access controls
Audit logs
Enter fullscreen mode Exit fullscreen mode

Infrastructure

Network restrictions
Secrets management
Patch management
Container security
Monitoring
Enter fullscreen mode Exit fullscreen mode

Development Process

Dependency scanning
Code review
CI/CD controls
Secret scanning
Environment separation
Enter fullscreen mode Exit fullscreen mode

Security becomes much easier when these controls are part of the architecture from the beginning.


17. Separate Development, Staging, and Production

One lesson that becomes obvious as a system grows is that environments need clear boundaries.

At minimum, I want:

Local
Development / Staging
Production
Enter fullscreen mode Exit fullscreen mode

Production credentials should never be casually used during development.

The same applies to:

  • Databases
  • Payment gateway credentials
  • SMS credentials
  • Email credentials
  • Storage buckets
  • API keys
  • Webhooks

Environment separation protects real users and real data.

It also makes deployment testing much safer.


18. Backups Are Only Useful if You Can Restore Them

"Backups enabled" is not enough.

The real question is:

Can we restore the system successfully when we need to?

A production backup strategy should consider:

  • Database backups
  • File/object backups
  • Retention periods
  • Restore procedures
  • Restore testing
  • Recovery time objectives
  • Recovery point objectives

If your team has never tested a restore, you do not fully know whether your backup process works.

Recovery should be treated as an engineering workflow, not just a checkbox.


19. What I Would Change If I Started Again

If I rebuilt the LMS today, I would make several decisions earlier.

1. Design background jobs from day one

Email, SMS, notifications, recordings, analytics, and other non-critical processing should not block normal API requests.

2. Add structured logging immediately

Debugging production without useful logs wastes engineering time.

3. Add correlation IDs

Tracing one request across the API, workers, database operations, and external services becomes much easier.

4. Design permissions before adding many roles

Role-only authorization becomes harder to maintain as the organization grows.

5. Create indexes from real query patterns

Do not add indexes randomly.

Measure how the application actually reads data.

6. Make payment operations idempotent

Anything involving money must handle duplicate callbacks and retries safely.

7. Add audit logs for important changes

You need to know who changed critical data.

8. Treat infrastructure cost as an engineering metric

A technically impressive architecture that costs more than the business can support is not a good architecture.

9. Test failure paths

Do not test only the happy path.

Test what happens when:

MongoDB is slow
SMS provider fails
Email provider fails
Payment callback is duplicated
Worker crashes
External API times out
User sends the same request twice
Enter fullscreen mode Exit fullscreen mode

10. Keep the architecture understandable

A small team should not need 20 services just because microservices are popular.

Complexity must solve a real problem.


20. Would I Still Choose Node.js?

For this type of system, yes.

I would still be comfortable choosing a stack such as:

Node.js
NestJS
MongoDB or PostgreSQL
AWS
Cloudflare
Docker
Enter fullscreen mode Exit fullscreen mode

Node.js provides good development speed and fits I/O-heavy workloads well.

Would Spring Boot also work?

Yes.

Would Django work?

Yes.

The framework is rarely the main reason a production platform succeeds or fails.

The surrounding engineering decisions matter more.

A badly designed Spring Boot application can fail.

A badly designed Node.js application can fail.

A well-designed version of either can handle serious production workloads.


21. MongoDB or PostgreSQL If I Started Today?

This is one area where I would spend more time on the data model before choosing.

MongoDB works very well when the application's data naturally benefits from document-oriented modelling and flexible structures.

PostgreSQL becomes attractive when the system has increasingly relational workflows such as:

  • Students
  • Classes
  • Enrollments
  • Invoices
  • Payments
  • Attendance
  • Academic periods
  • Permissions
  • Reporting

For a new LMS, I would evaluate these relationships carefully before making the database decision.

The correct answer is not:

MongoDB is always better
Enter fullscreen mode Exit fullscreen mode

or:

PostgreSQL is always better
Enter fullscreen mode Exit fullscreen mode

The better question is:

Which data model makes the core business rules easier to enforce, query, report on, and maintain?

That is the database decision I care about now.


22. The Architecture I Prefer Today

If I were starting a similar platform today, I would keep the first production architecture relatively simple.

                     Cloudflare
                         |
                         v
                 Web / Mobile Clients
                         |
                         v
                  API Load Balancer
                         |
                         v
                Node.js / NestJS API
                         |
          +--------------+--------------+
          |              |              |
          v              v              v
      Database        Queue/Cache      AWS S3
          |              |
          |              v
          |          Background Workers
          |              |
          |      +-------+-------+
          |      |       |       |
          |      v       v       v
          |     SMS     Email   Other APIs
          |
          v
     Backups / Monitoring
Enter fullscreen mode Exit fullscreen mode

I would not introduce additional architectural complexity until the product demonstrates a clear need for it.

A modular monolith can be a very good architecture.

You can still have:

  • Clear module boundaries
  • Background workers
  • Independent queues
  • Strong observability
  • Horizontal scaling
  • Good security controls

without immediately splitting everything into microservices.


Final Thoughts

Building production software changed how I evaluate technology.

I care less about questions like:

Which framework is fastest?

I care more about:

Can we debug it?

Can we secure it?

Can we scale it?

Can we recover from failure?

Can another developer maintain it?

Can we observe it?

Can the business afford to operate it?
Enter fullscreen mode Exit fullscreen mode

Those questions matter much more once real students, tutors, classes, and payments depend on the platform.

The biggest lesson for me has been this:

Production architecture is not about choosing the most powerful technology. It is about making good engineering decisions around the technology you already have.

If you were building an LMS today, which backend would you choose?

Node.js + MongoDB, Node.js + PostgreSQL, Spring Boot + PostgreSQL, Django, or something else?

I would especially like to hear from developers who have operated these stacks in production.

Top comments (0)