Architecting EduMaster: Building a Scalable Learning Management System (LMS) with React & Node.js
How I built EduMaster, a full-featured online education platform with interactive modules, progress tracking, quiz scoring, and real-time student analytics.
By Uttam Thapa · · Education
⚡ Executive Summary (TL;DR)
An LMS is two systems wearing one interface: the course, which barely changes, and the student's progress through it, which changes constantly.
EduMaster — React, TypeScript, Node.js and Express — keeps those schemas apart, makes completion tracking idempotent with a
composite unique key, scores every quiz on the server, and absorbs exam-window write spikes with a queue instead of a table lock.
Figure 1: The course player. Curriculum on the left is static content; every tick beside it is student state.
Beyond an Embedded Video Player
Modern e-learning needs more than a video embed and a "next" button. Students need structured lessons, quizzes that respond immediately, progress that survives a
closed laptop, and analytics honest enough to show them how far they actually are. Each of those is a data modelling problem before it is a UI problem.
Two Schemas, Not One
Course content is hierarchical and nearly static: Course → Module → Lesson → Quiz. Student progress is flat, high-volume, and written constantly.
Modelling them together — a completed boolean on the lesson row — breaks immediately, because completion is a fact about a
pair: this student, this lesson.
|
Course definition |
Student progress |
| Shape | Deeply nested tree | Flat rows keyed by (user, lesson) |
| Write volume | Rare — an instructor editing | Continuous — every learner, every lesson |
| Read pattern | Whole tree, cacheable | One student's slice, never cacheable |
| Growth | With the catalog | Students × lessons |
model UserProgress {
id String @id @default(uuid())
userId String
lessonId String
isCompleted Boolean @default(false)
score Int?
completedAt DateTime?
@@unique([userId, lessonId])
}
The composite unique constraint on [userId, lessonId] is doing real work. It makes progress updates idempotent: an upsert against
that key produces the same row whether the client fires once or — as clients on flaky connections do — five times. Without it, a student who double-taps
"mark complete" gets duplicate rows and a completion percentage above 100.
💡 The index you get for free
That unique constraint is also the index behind "show me this student's progress in this course" — the most frequent query in the entire application. Ordering
it [userId, lessonId] rather than [lessonId, userId] is what makes the per-student lookup a range scan instead of a filter.
What the Platform Does
📚
Course player
Collapsible module sidebar; lesson status updates in place with no full-page reload.
✍️
Quiz engine
Multiple choice, code snippet assessment and true/false — all graded server-side.
📈
Student analytics
Completion percentage, quiz averages, study time, and certificate readiness.
Never Ship the Answer Key
Client-side grading requires the correct answers to be present in the browser, which means they are one devtools panel away from any curious student. Every quiz
in EduMaster is submitted to the server, graded there, and returns only the score and per-question correctness — never the key for questions still unanswered.
The same applies to the quiz payload itself: strip isCorrect flags from options before serialising the question to the client. It is a common and
easily missed leak, because the interface looks perfectly correct while shipping the answers inside the props.
Surviving the Exam Window
Traffic in a learning platform is not smooth. It is flat for six days and then several hundred students submit within the same ninety seconds, because the
deadline is the same for all of them.
Submitting synchronously means hundreds of concurrent transactions contending on the same rows, and PostgreSQL spends the window managing locks rather than doing
work. Instead, a submission is accepted, enqueued for scoring, and answered immediately with a receipt token. The frontend then polls or listens on a socket for
the score to publish.
🚨 What the receipt has to guarantee
Returning a token before the work is done is only safe if acceptance is durable. The submission must be persisted before the token is issued — enqueue to a
store that survives a restart, not to an in-process array. A student whose answers vanish because a worker was redeployed mid-window will not accept
"eventually consistent" as an explanation.
✅ Key takeaways
- ✓Separate curriculum from progress. They differ in shape, write volume, cacheability and growth rate.
- ✓Use
[userId, lessonId] as a unique key. It makes completion idempotent and indexes the hottest query.
- ✓Grade on the server. And strip correctness flags from the question payload before it is sent.
- ✓Queue submissions during deadline spikes. Accept fast, score asynchronously, publish the result.
- ✓Persist before acknowledging. A receipt token is a promise you have to be able to keep.
Frequently asked questions
How should student progress be stored in an LMS?
In its own table keyed by the pair of student and lesson, with a composite unique constraint on the two. Completion is a fact about the pair, not a property of the lesson, and the unique key makes updates idempotent.
Should quizzes be graded on the client or the server?
Always the server. Client-side grading requires shipping the answer key to the browser. Strip correctness flags from the question payload too — the interface looks correct while leaking the answers through its props.
How do you handle hundreds of students submitting at the same deadline?
Accept the submission, persist it durably, return a receipt token, and score asynchronously. Synchronous scoring turns a deadline into a lock-contention incident.
Home · Projects · Blog · Services · Résumé · Contact