Performance Optimisation — Profiling, $facet Dashboard, and Angular Pipes

With the application complete and tested, applying the performance optimisation techniques from Chapter 22 reveals how the patterns studied throughout the course manifest in a real application. This lesson profiles the Task Manager, identifies the actual bottlenecks (not the assumed ones), applies MongoDB index optimisation, adds Redis caching where it is missing, tunes Angular change detection, and sets up the Prometheus metrics dashboard that monitors the application in production. The lesson demonstrates the measure-first, optimise-second discipline.

Performance Audit Results (Before Optimisation)

Endpoint / Operation Before Bottleneck Fix
GET /tasks?workspaceId= 280ms COLLSCAN on workspace field Add compound index { workspace:1, status:1, createdAt:-1 }
GET /workspace-dashboard 450ms 4 sequential queries Replace with single $facet aggregation
Task list Angular render 120ms jank No trackBy, Default change detection Add trackBy: task._id, OnPush
POST /auth/login 340ms No index on email field Add { email: 1 } unique index (was already there — found missing in test DB)
Notification bell badge 85ms COUNT query on every page load Read denormalised user.unreadNotifications field instead
Note: Run db.setProfilingLevel(1, { slowms: 50 }) in the MongoDB shell against your staging database while running a realistic load test (k6 with 50 concurrent users, 2-minute run). Then query db.system.profile.find().sort({ millis: -1 }).limit(20) to see the top 20 slowest operations. This is almost always more informative than guessing which queries are slow — production query patterns often differ significantly from what developers expect based on their mental model of the application.
Tip: Use Angular DevTools profiler before applying any optimisation — record a 30-second session with typical user interactions and note which components check most frequently. In the Task Manager, the notification bell component checking on every keystroke in the task title input was the most surprising finding — it was caught because it imported a signal from a store that was also used by the task form. Restructuring to a more targeted signal fixed it without any change detection strategy changes.
Warning: Do not add indexes speculatively. Every index adds write overhead and storage cost. Add only indexes that correspond to actual query patterns observed in profiling. The Atlas Performance Advisor (for Atlas clusters) analyses query logs and suggests specific indexes — use its recommendations rather than pre-emptively indexing every field combination you can think of. Over-indexing write-heavy collections like the notifications and audit logs significantly reduces write throughput.

Applied Performance Optimisations

// ── 1. Missing indexes discovered via profiling ────────────────────────────
// Run after db.setProfilingLevel(1, { slowms: 50 }) + load test:
// db.system.profile.find({ millis: { $gt: 50 } }).sort({ millis: -1 }).limit(10)
//
// Found: tasks queries using COLLSCAN on { workspace, status }
// Fix: add to task.model.js (already there — verify it exists in production):
// taskSchema.index({ workspace: 1, status: 1, createdAt: -1 });

// ── 2. Dashboard: 4 queries → 1 $facet aggregation ───────────────────────
// BEFORE: 4 sequential queries averaging 450ms total
async function getDashboardSlow(workspaceId) {
    const total     = await Task.countDocuments({ workspace: workspaceId, deletedAt: { $exists: false } });
    const done      = await Task.countDocuments({ workspace: workspaceId, status: 'done' });
    const overdue   = await Task.countDocuments({ workspace: workspaceId, dueDate: { $lt: new Date() }, status: { $ne: 'done' } });
    const byStatus  = await Task.aggregate([{ $match: { workspace: workspaceId } }, { $group: { _id: '$status', count: { $sum: 1 } } }]);
    return { total, done, overdue, byStatus };
}

// AFTER: single $facet — ~95ms
async function getDashboard(workspaceId) {
    const wsId = new mongoose.Types.ObjectId(workspaceId);
    const [result] = await Task.aggregate([
        { $match: { workspace: wsId, deletedAt: { $exists: false } } },
        { $facet: {
            totals: [
                { $group: {
                    _id: null,
                    total:       { $sum: 1 },
                    done:        { $sum: { $cond: [{ $eq: ['$status','done'] }, 1, 0] } },
                    overdue:     { $sum: { $cond: [
                        { $and: [{ $lt: ['$dueDate', new Date()] }, { $ne: ['$status','done'] }] },
                        1, 0
                    ]}},
                    dueThisWeek: { $sum: { $cond: [
                        { $and: [
                            { $gte: ['$dueDate', new Date()] },
                            { $lte: ['$dueDate', new Date(Date.now() + 7*86400000)] },
                        ]}, 1, 0
                    ]}},
                }},
            ],
            byStatus: [
                { $group: { _id: '$status', count: { $sum: 1 } } },
            ],
            completionTrend: [
                { $match: { status: 'done', completedAt: { $gte: new Date(Date.now() - 30*86400000) } } },
                { $group: {
                    _id: { $dateToString: { format: '%Y-%m-%d', date: '$completedAt' } },
                    count: { $sum: 1 },
                }},
                { $sort: { _id: 1 } },
            ],
            tagCloud: [
                { $unwind: '$tags' },
                { $group: { _id: '$tags', count: { $sum: 1 } } },
                { $sort: { count: -1 } },
                { $limit: 20 },
            ],
        }},
    ]);
    return {
        ...result.totals[0],
        byStatus:        result.byStatus,
        completionTrend: result.completionTrend,
        tagCloud:        result.tagCloud,
    };
}

// ── 3. k6 load test — identify regressions ───────────────────────────────
// k6-scripts/task-list.js (run: k6 run --vus 50 --duration 2m script.js)
import http   from 'k6/http';
import { check, sleep } from 'k6';
import { Trend } from 'k6/metrics';

const taskListLatency = new Trend('task_list_latency');

export const options = {
    thresholds: {
        http_req_duration: ['p(95)<300'],   // 95th percentile under 300ms
        http_req_failed:   ['rate<0.01'],   // <1% errors
    },
};

export function setup() {
    const res = http.post(`${__ENV.API_URL}/auth/login`,
        JSON.stringify({ email: 'owner@test.io', password: 'Password123!' }),
        { headers: { 'Content-Type': 'application/json' } }
    );
    return { token: res.json('data.accessToken'), workspaceId: res.json('data.user.defaultWorkspaceId') };
}

export default function ({ token, workspaceId }) {
    const start = Date.now();
    const res   = http.get(`${__ENV.API_URL}/tasks?workspaceId=${workspaceId}&page=1&limit=20`, {
        headers: { Authorization: `Bearer ${token}` },
    });
    taskListLatency.add(Date.now() - start);
    check(res, { 'status 200': r => r.status === 200, 'has data': r => r.json('data') !== null });
    sleep(0.5);
}
// ── 4. Angular: pure pipe replaces method call in task-card ───────────────
// BEFORE: method called on every change detection cycle
// template: {{ formatDueDate(task.dueDate) }}
// component: formatDueDate(date: string) { return ... }

// AFTER: pure pipe — cached per input value
@Pipe({ name: 'relativeDate', standalone: true, pure: true })
export class RelativeDatePipe implements PipeTransform {
    transform(date: string | null): string {
        if (!date) return '';
        const d = new Date(date);
        const now = new Date();
        const diffMs = now.getTime() - d.getTime();
        const diffDays = Math.floor(diffMs / 86400000);
        if (diffDays === 0) return 'today';
        if (diffDays === 1) return 'yesterday';
        if (diffDays < 7)  return `${diffDays}d ago`;
        if (diffDays < 30) return `${Math.floor(diffDays / 7)}w ago`;
        return d.toLocaleDateString();
    }
}

// ── 5. Prometheus metrics — task-specific business metrics ────────────────
const { tasksCreated, tasksCompleted } = require('./config/metrics');

// In task.controller.js — after successful create:
tasksCreated.inc({ priority: task.priority, workspace: task.workspace.toString() });

// In task.controller.js — after status changes to 'done':
tasksCompleted.inc({ workspace: task.workspace.toString() });

// Grafana dashboard query examples:
// Task creation rate: rate(taskmanager_tasks_created_total[5m])
// Completion rate:    rate(taskmanager_tasks_completed_total[5m])
// Creation by priority: sum by (priority)(rate(taskmanager_tasks_created_total[5m]))

How It Works

Step 1 — Profiling Reveals Real Bottlenecks

The slow query profiler running during a k6 load test produced a list of actual slow operations. The dashboard’s 4-query problem was the biggest win — consolidating into one $facet aggregation dropped latency from 450ms to 95ms. The pure pipe fix eliminated hundreds of function calls per second during task list scrolling. Neither of these improvements would have been prioritised based on intuition alone — profiling provided the evidence.

Step 2 — $facet Aggregation Consolidates Dashboard Queries

The dashboard refactoring shows a 4.7x latency improvement (450ms → 95ms) by replacing 4 sequential queries with 1 $facet aggregation. The aggregation also reads the collection once (after the initial $match) rather than four times, reducing I/O proportionally. The business logic moved from application code (4 separate async operations) to the database (1 compound aggregation) — where it executes closer to the data.

Step 3 — k6 Thresholds Create Performance Regression Tests

The k6 script with thresholds: { http_req_duration: ['p(95)<300'] } acts as a performance regression test in CI. If a code change degrades the p95 task list latency above 300ms, the k6 test exits with code 1 and the CI build fails. Running this against the staging environment after every deployment catches performance regressions before they reach production users.

Step 4 — Pure Pipe Eliminates Template Method Calls

The formatDueDate method called in every task card’s template ran on every change detection cycle — with 20 tasks on screen, that is 20 method calls per cycle, many cycles per second. The pure pipe is called only when the input value changes. Over a 5-minute task list session with typical user interactions, this eliminates thousands of redundant date format computations. The improvement is measurable via Angular DevTools profiler: component check time drops from 8ms to 0.4ms.

Step 5 — Business Metrics Connect Performance to Outcomes

Adding tasksCreated.inc() and tasksCompleted.inc() counters allows Grafana dashboards to show business health alongside technical health. A deployment that causes a drop in task creation rate (users encountering errors or slowness they don’t report) is visible immediately in the metrics — even before support tickets arrive. Correlating a latency spike with a drop in task creation rate proves user impact, which drives prioritisation.

Quick Reference

Optimisation Technique Improvement
Enable slow query log db.setProfilingLevel(1, { slowms: 50 }) Reveals real bottlenecks
Multi-query → $facet Replace N sequential aggregations with one $facet 4.7x latency reduction
Template method → pipe @Pipe({ pure: true }) Eliminates per-cycle calls
Load test threshold k6 thresholds: { p(95)<300 } Automatic regression detection
Business metric tasksCreated.inc({ priority }) User impact visibility
Read slow query log db.system.profile.find({ millis: { $gt: 50 } }).sort({ millis: -1 }) Top slowest operations

🧠 Test Yourself

A developer adds a new feature. The k6 load test now shows p95 task list latency of 380ms (threshold: 300ms). The CI build fails. Without running the application, what is the most systematic first step to diagnose the regression?