Frontend Development 18 min read

How to Build a Custom Frontend Performance Testing System from Scratch

This article explains how to design and implement a bespoke performance testing platform for web pages using Lighthouse, Puppeteer, and Node.js, covering architecture, data collection, hook functions, gatherer modules, scoring logic, and automated weekly reporting.

政采云技术
政采云技术
政采云技术
How to Build a Custom Frontend Performance Testing System from Scratch

Introduction

Front‑end page performance directly affects user retention and experience; a load time beyond two seconds can cause massive user loss. Understanding and optimizing performance requires a tailored set of metrics that match the specific business context, whether an e‑commerce site heavy on images or a middle‑office form‑driven page.

Lighthouse

Lighthouse is an open‑source automation tool that audits web‑app quality. It can be run via Chrome DevTools, a Chrome extension, the Node CLI, or as a Node module. The system described here builds on the Node module, extending it to meet internal requirements.

Overall Architecture

The performance platform (named “BaiCe”) consists of the following components:

Front‑end UI built with Ant Design and Ant Design Charts for page display and performance charts.

Back‑end services developed with NestJS, integrated with Sentry for alerting and Helmet for basic web‑security hardening.

node‑schedule for weekly aggregation of collected metrics and nodemailer for email delivery.

Compression middleware to enable gzip.

The core detection service built on Puppeteer and Lighthouse.

Data Collection Process

The platform uses synthetic monitoring: it runs a headless Chrome instance to load pages, gathers a set of collectors, computes performance scores, and stores the results in a database.

Entry Point

/**
  * Execute page information collection
  *
  * @param {PassContext} passContext
  */
async run(runOptions: RunOptions) {
  const gathererResults = {};
  // Use Puppeteer to create a headless browser and page
  const passContext = await this.prepare(runOptions);
  try {
    // Login if credentials are provided
    await this.preLogin(passContext);
    // Hook before page opens
    await this.beforePass(passContext);
    // Open page and collect data
    await this.getLhr(passContext);
    // Hook after page opens
    await this.afterPass(passContext, gathererResults);
    // Collect performance artifacts
    return await this.collectArtifact(passContext, gathererResults);
  } catch (error) {
    throw error;
  } finally {
    // Close page and browser
    await this.disposeDriver(passContext);
  }
}

Create Headless Browser

/**
  * Prepare before login: create browser and page
  *
  * @param {RunOptions} runOptions
  */
async prepare(runOptions: RunOptions) {
  const launchOptions: puppeteer.LaunchOptions = {
    headless: true,
    defaultViewport: { width: 1440, height: 960 },
    args: ['--no-sandbox', '--disable-dev-shm-usage'],
    executablePath: '/usr/bin/chromium-browser',
  };
  if (process.env.NODE_ENV === 'development') {
    delete launchOptions.executablePath;
  }
  const browser = await puppeteer.launch(launchOptions);
  const page = (await browser.pages())[0];
  return { browser, page };
}

Login Simulation

The login flow opens the GovBuyCloud login page in the headless browser, fills in username and password via Puppeteer API, clicks the login button, and then reuses the shared cookies to open the target page for performance measurement.

Open Page with Lighthouse

/**
  * Use Lighthouse inside Puppeteer
  *
  * @param {RunOptions} runOptions
  */
async getLhr(passContext: PassContext) {
  const { browser, url } = passContext;
  const { artifacts, lhr } = await lighthouse(url, {
    port: new URL(browser.wsEndpoint()).port,
    output: 'json',
    logLevel: 'info',
    emulatedFormFactor: 'desktop',
    throttling: {
      rttMs: 40,
      throughputKbps: 10 * 1024,
      cpuSlowdownMultiplier: 1,
      requestLatencyMs: 0,
      downloadThroughputKbps: 0,
      uploadThroughputKbps: 0,
    },
    disableDeviceEmulation: true,
    onlyCategories: ['performance'],
  });
  passContext.lhr = lhr;
  passContext.artifacts = artifacts;
}

Hook Functions

/**
  * Execute afterPass of all gatherers
  *
  * @param {PassContext} passContext
  * @param {GathererResults} gathererResults
  */
async afterPass(passContext: PassContext, gathererResults: GathererResults) {
  const { page, gatherers } = passContext;
  // Run afterPass of each gatherer
  for (const gatherer of gatherers) {
    const gathererResult = await gatherer.afterPass(passContext);
    gathererResults[gatherer.name] = gathererResult;
  }
  // Capture screenshot after all gatherers finish
  gathererResults.screenshotBuffer = await page.screenshot();
}

Gatherer Implementation Example (DOMStats)

import { Gatherer } from './gatherer';
import { PassContext } from '../interfaces/pass-context.interface';
/**
  * DOMStats gatherer collects DOM‑related metrics
  */
export default class DOMStats extends Gatherer {
  horizontalScrollBar;
  /** Page hook before load */
  async beforePass(passContext: PassContext) {
    const { browser } = passContext;
    browser.on('targetchanged', async target => {
      const page = await target.page();
      page.on('domcontentloaded', async () => {
        this.horizontalScrollBar = await page.evaluate(() => {
          return document.body.scrollWidth > document.body.clientWidth;
        });
      });
    });
  }
  /** Page hook after load */
  async afterPass(passContext: PassContext) {
    const { artifacts } = passContext;
    const { DOMStats: { depth, width, totalBodyElements } } = artifacts;
    return {
      numElements: totalBodyElements,
      maxDepth: depth.max,
      maxWidth: width.max,
      hasHorizontalScrollBar: !!this.horizontalScrollBar,
    };
  }
}

Scoring Logic

/**
  * Calculate score based on model configuration
  *
  * @param {Artifact} artifact
  * @param {string[]} whitelist
  */
async calc(artifact: Artifact, whitelist?: string[]): Promise
{
  const audit = await import(`../audits/${this.meta.id}`).then(m => m.default);
  const { rawValue, score, displayValue, details = [] } = audit.audit(artifact, whitelist);
  const auditDto = new AuditDto();
  auditDto.id = this.meta.id;
  auditDto.title = this.meta.title;
  auditDto.description = this.meta.description;
  auditDto.details = details;
  auditDto.level = this.level;
  auditDto.score = score * this.weight <= -this.upperLimitScore ? -this.upperLimitScore : score * this.weight;
  auditDto.rawValue = rawValue;
  auditDto.displayValue = displayValue;
  return auditDto;
}

Automatic Weekly Detection

import { Injectable, OnModuleInit } from '@nestjs/common';
import * as schedule from 'node-schedule';
@Injectable()
export class ScheduleService implements OnModuleInit {
  onModuleInit() { this.init(); }
  async init() {
    if (process.env.NODE_ENV !== 'development') {
      // Every Friday 02:00 collect performance data
      schedule.scheduleJob('hawkeye-weekly-report', '0 0 2 * * 5', async () => {
        await this.report();
      });
      // Every Friday 18:00 send weekly report
      schedule.scheduleJob('hawkeye-weekly-send', '0 0 18 * * 5', async () => {
        await this.send();
      });
    }
  }
}

Conclusion

The article demonstrates how to build a custom performance testing platform from zero, covering everything from browser automation and Lighthouse integration to data collection, scoring, and automated reporting. Readers are encouraged to adapt the concepts to their own business pages and define suitable detection models.

FrontendPuppeteerPerformance Testingweb performanceLighthousenodejs
政采云技术
Written by

政采云技术

ZCY Technology Team (Zero), based in Hangzhou, is a growth-oriented team passionate about technology and craftsmanship. With around 500 members, we are building comprehensive engineering, project management, and talent development systems. We are committed to innovation and creating a cloud service ecosystem for government and enterprise procurement. We look forward to your joining us.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.