Web Performance That Actually Matters Frontend, Backend, and Database

Savita VermaSavita Verma
9 min read

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:

  1. Frontend – what the user sees and touches

  2. Backend – where API responses are made

  3. 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() or dynamic() (if you're using Next.js) for code splitting

  • Replace heavy libraries (e.g., use date-fns instead of moment)

  • 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.

0
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