Web Performance That Actually Matters Frontend, Backend, and Database


I’m currently building a Learning Management System (LMS), and like most real-world apps, it’s not just about features, it’s about how the app feels.
If a student logs in and the dashboard takes five seconds to load, or an instructor clicks “Generate Report” and nothing happens for 10 seconds, the entire experience feels broken. Slow apps are frustrating. Worse, they’re forgettable
This is the second part of my deep dive into web performance. In Part 1, we talked about why performance isn’t just about shaving off seconds but it’s about improving user experience, retention, and trust.
Now in Part 2, let’s get into the “how”. How to actually make things fast. We’ll break it down into three key layers of the app:
Frontend – what the user sees and touches
Backend – where API responses are made
Database – where the real performance traps live
And we’ll understand all of this through the lens of building an LMS. Let's dive in.
Making the Frontend Fast Where It Actually Matters
Make the first paint fast
Let’s start with something every student has felt, they log in 5 minutes before a quiz, land on the dashboard and nothing happens. White screen. Spinner. Panic.
What’s going wrong?
Probably the Critical Rendering Path. The browser needs to fetch HTML, CSS, and JS, parse everything, build the DOM, apply styles, then paint something. If we have got a 300KB CSS file with styles for every page in our app, we are making that process slower than it needs to be.
What we should do:
Inline critical CSS (use tools like
critical
or@vitejs/plugin-critical
)Preload important fonts so they don’t block the render
Keep login and dashboard CSS minimal
<link rel="preload" href="/fonts/Inter.woff2" as="font" type="font/woff2" crossorigin="anonymous">
It’s not about how fast our app loads, it’s about how fast it looks usable.
Keep JavaScript bundle under control
We’ve all done it , installed moment.js
for one date, imported half of lodash
, or tossed in three UI libraries because “we’re still trying things out.”
Suddenly our bundle is 2MB. On fast Wi-Fi, maybe it’s fine. On mobile or 3G? Not so much.
In reality, LMS probably has different pages like dashboard, course player, assignment upload, and admin tools. Not every user needs every feature right away. So don’t load everything upfront.
Use
React.lazy()
ordynamic()
(if you're using Next.js) for code splittingReplace heavy libraries (e.g., use
date-fns
instead ofmoment
)Import only what you need from UI libraries
Use dynamic imports like this:
const AssignmentPage = React.lazy(() => import('./pages/AssignmentPage'));
Import what you need like this:
// Bad
import { Button, Modal, Tooltip } from 'chakra-ui';
// Good
import Button from '@chakra-ui/button';
import Modal from '@chakra-ui/modal';
Also make sure tree-shaking is working so unused code is removed. Use tools like webpack-bundle-analyzer
or Vite’s --analyze
command to see what’s making our bundle fat. Small wins like this really add up.
Lazy load with intent
Lazy loading doesn’t mean throwing React.lazy()
on everything and calling it a day. Think about what a student needs right after logging in. Probably their enrolled courses, upcoming classes, and recent activity.
What they don’t need immediately?
Admin dashboard, analytics for instructors, or certificate downloads.
So only load what’s needed for the current view, and delay the rest.
If we have to load images (like course thumbnails), we will be using loading="lazy"
and always set width and height to avoid layout shifts.
Even better, use IntersectionObserver to load things just before they enter the viewport like when someone scrolls through a course list.
const observer = new IntersectionObserver(callback, options);
Bonus: if someone hovers over the “Start Quiz” button, we can even prefetch the quiz bundle in the background. Feels instant.
Don’t ignore fonts, animations, and layout shifts
Fonts: If we are using Google Fonts, always add &display=swap
so text is shown using fallback fonts until the main one loads.
<link href="https://fonts.googleapis.com/css2?family=Inter&display=swap" rel="stylesheet">
Also don’t load 5 font weights unless you really need them.
Animations: Don’t animate properties like top
, left
, height
, or width
. These trigger layout recalculations. Instead, use transform
and opacity
, they’re GPU-accelerated and much smoother.
Layout shifts: If you render elements (like student avatars or banners) without setting dimensions, the layout will jump. That ruins user experience and hurts Core Web Vitals. Always reserve space ahead of time.
Backend optimization that doesn’t make users wait
So your frontend is fast now and that’s great. But if every interaction makes a slow API call to the server, users still feel the lag.
Backend performance is usually invisible until it becomes a problem. So what should we do?
Keep API responses under 200ms
Let’s say your dashboard calls these APIs:
/api/user
/api/courses
/api/notifications
/api/calendar
If each takes 500ms, even in parallel, that adds up, especially on flaky connections. Ideally, every backend call should respond in under 200ms.
Things that help to solve this issue:
Cache anything that doesn’t change often (like course info or user roles)
Avoid complex DB joins and aggregations in live requests
Precompute stuff like "average score this week" using cron jobs
Also, make sure your backend is actually doing things in parallel. Here's an example using Node.js and Mongoose:
const [user, courses, notifications] = await Promise.all([
User.findById(userId).lean(),
Course.find({ students: userId }).lean(),
Notification.find({ user: userId }).sort({ createdAt: -1 }).limit(10).lean(),
]);
res.json({ user, courses, notifications });
Simple, but effective.
Cache data that doesn’t change often
Our backend shouldn’t do the same expensive queries over and over again.
For semi-static content like:
Public course lists
Instructor bios
Certificate templates
use basic in-memory caching if your app is small, or Redis if you’re scaling up.
const cache = new Map();
app.get('/api/courses', async (req, res) => {
if (cache.has('courses')) return res.json(cache.get('courses'));
const courses = await Course.find().select('title instructor thumbnail');
cache.set('courses', courses);
res.json(courses);
});
For production, you'll want TTLs and proper cache invalidation. But even this approach can drastically reduce DB load. Also don’t forget to add HTTP caching headers for public endpoints:
Cache-Control: public, max-age=3600, stale-while-revalidate=600
That one header can save your backend from thousands of unnecessary requests.
Log and monitor everything
You can’t fix slow APIs if you don’t know what’s slow. Use basic request logging with morgan
or pino
, and set up alerts if a route starts getting slower.
And if you want to catch slow Mongo queries, turn on debug mode:
mongoose.set('debug', true);
Or write a simple timer around your queries to log anything that takes too long.
const start = Date.now();
await SomeQuery();
const duration = Date.now() - start;
if (duration > 200) console.log('Slow query!', duration);
Database optimization that makes everything else easier
You can have the cleanest Node.js code and the most efficient API structure, but if your database takes 1.2 seconds to fetch a student's assignments, the user still waits 1.2 seconds. That’s why database optimization might just be the most important layer of performance.
And in a system like an LMS, your DB ends up doing a lot of heavy lifting like fetching enrolled courses, showing quiz results, searching course catalogs etc.
So let’s go into how to optimize MongoDB + Mongoose to handle this load cleanly.
Indexes are your best friend
MongoDB collections don’t come indexed by default (except _id
). So if you're frequently searching on a field like userId
, courseId
, or email
, Mongo will scan every single document unless you tell it otherwise.
So, to solve this add indexes on fields you use in .find()
, .sort()
, or .filter()
regularly.
In Mongoose schema:
UserSchema.index({ email: 1 }, { unique: true });
CourseSchema.index({ instructor: 1 });
EnrollmentSchema.index({ studentId: 1, courseId: 1 });
If you're loading dashboard data based on userId
, and the query is:
Course.find({ students: userId })
Make sure students
is either a properly indexed reference (via .populate()
), or you’re querying a Enrollment
model instead, which has studentId
indexed.
Use .explain("executionStats")
in Mongo to confirm your query is hitting an index.
Keep documents reasonably sized
Mongo has a 16MB document size limit, but you shouldn't even get close to that. The real issue occurs when document gets oversized, because now it’s slower to read/write, use more RAM and increase query time.
Common mistakes in LMS app is stuffing all course data into one document.
{
_id: "course123",
title: "JavaScript 101",
students: [...1000 userIds],
assignments: [...],
quizzes: [...],
discussion: [...]
}
This works at first but it quickly becomes a nightmare. To solve this, split into multiple collections like Course
, Enrollment
, Assignment
, Quiz
, Discussion
. Now each piece is lean, queryable, and independently cacheable.
Use .lean()
for faster queries
Mongoose returns "full" documents by default — meaning they come with all the class methods, virtuals, and magic. But if you’re just reading data and sending JSON to the frontend, all that extra weight slows things down.
Use .lean()
when you don’t need Mongoose document methods:
const quizzes = await Quiz.find({ courseId }).lean();
It’s significantly faster and uses less memory.
Use aggregation carefully
Aggregations are great for things like getting top 5 popular courses, average score per quiz or total students per instructor but they don’t belong in high-frequency endpoints like /dashboard
or /api/home
.
To fix that, run these aggregations in background jobs (like cron) and store results in a Stats
or Reports
collection.
const avg = await Quiz.aggregate([
{ $match: { courseId: 'abc' } },
{ $group: { _id: '$userId', avgScore: { $avg: '$score' } } }
]);
Cache this result for your dashboard. Don’t recalculate every time.
Avoid the N+1 query trap
In Mongoose, if you’re fetching a list of courses and then manually fetching instructors inside a loop, you’re in N+1 territory:
const courses = await Course.find();
for (let course of courses) {
course.instructor = await User.findById(course.instructorId);
}
That’s one query for courses, and one query per instructor. If there are 10 courses, that’s 11 queries. Bad idea.
Instead, use .populate()
:
const courses = await Course.find().populate('instructor', 'name avatar');
Now you get everything in one go. Much faster, cleaner, and scalable.
Pagination , not full fetch
In LMS, student dashboards, discussion forums, or certificate history could easily return hundreds of items. So don’t do this:
const submissions = await Submission.find({ courseId });
instead use pagination
const submissions = await Submission.find({ courseId }).skip(0).limit(20);
Or use cursor-based pagination for even better performance.
Conclusion
Frontend and backend performance are two sides of the same coin. We can’t fix one and ignore the other. And in an LMS, where timing often matters like exam windows, live classes, or assignment deadlines, speed isn't just a bonus feature. It's critical.
From critical rendering paths to backend caching and database indexes, performance is about respecting your user's time.
In Part 3, we’ll go beyond code, into monitoring, profiling, and setting up alerts. Because performance isn't a one-time fix — it’s something you measure, watch, and tune over time.
Subscribe to my newsletter
Read articles from Savita Verma directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Savita Verma
Savita Verma
I'm a frontend developer trying to do more than just ship fast code. I care about clarity, purpose, and mindful work. Currently based in Bangalore and working my way toward becoming a senior dev. I write about the technical, the emotional, and everything in between. Feel free to reach out, I’m always excited to chat about frontend, tech, or opportunities. Email Id: svitaverma10@gmail.com