Build a complete automated performance gate in GitHub Actions integrating k6 load tests, Lighthouse CI, bundle size checks, and database query timing as deployment blockers.
Performance testing in CI/CD is the difference between catching regressions before users experience them and debugging production incidents under pressure. A regression that adds 500ms to the p95 API latency is invisible in unit tests and code review — it only shows up when real traffic hits the system.
This lesson builds a complete performance quality gate: k6 for API performance, Lighthouse CI for frontend Core Web Vitals, bundle size enforcement, and a GitHub PR bot that comments performance metrics on every pull request.
A production-grade performance pipeline has three stages:
Stage 1: Static Analysis (on PR)
Stage 2: Dynamic Load Testing (after staging deploy)
Stage 3: Comparison and Reporting (on PR close/merge)
1# .github/workflows/performance.yml — complete pipeline
2name: Performance Gate
3
4on:
5 pull_request:
6 branches: [main]
7 push:
8 branches: [main, 'release/**']
9
10jobs:
11 bundle-check:
12 name: Bundle Size Analysis
13 runs-on: ubuntu-latest
14 steps:
15 - uses: actions/checkout@v4
16 - uses: actions/setup-node@v4
17 with: { node-version: '20', cache: 'pnpm' }
18 - run: pnpm install --frozen-lockfile
19 - run: pnpm build
20 - run: node scripts/check-bundle-size.js
21 # Exits 1 if any page's initial JS > 150 KBAfter deploying to staging, run a smoke test before any load test. A failing smoke test means the deployment itself broke something — no point running a 25-minute load test against a broken deployment.
1# In performance.yml — k6 jobs (run after staging deploy)
2 k6-smoke:
3 name: API Smoke Test
4 needs: [staging-deploy] # depends on deployment job
5 runs-on: ubuntu-latest
6 steps:
7 - uses: actions/checkout@v4
8
9 - name: Install k6
10 run: |
11 sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
12 echo 'deb https://dl.k6.io/deb stable main' | sudo tee /etc/apt/sources.list.d/k6.list
13 sudo apt-get update && sudo apt-get install k6
14
15 - name: Wait for staging to be healthy
16 run: |
17 timeout 60 bash -c 'until curl -sf $STAGING_URL/api/health; do sleep 2; done'
18 env:
19 STAGING_URL: ${{ vars.STAGING_URL }}
20
21 - name: Run k6 smoke test
22 run: |
23 k6 run \
24 --env BASE_URL=${{ vars.STAGING_URL }} \
25 --out json=smoke-results.json \
26 tests/perf/scenarios/smoke.js
27 # Exits 99 if thresholds breach — step fails, blocking load test
28
29 - name: Upload smoke test results
30 if: always()
31 uses: actions/upload-artifact@v4
32 with:
33 name: k6-smoke-results-${{ github.run_id }}
34 path: smoke-results.json
35
36 k6-load:
37 name: API Load Test
38 needs: [k6-smoke] # only runs if smoke passes
39 runs-on: ubuntu-latest
40 steps:
41 - uses: actions/checkout@v4
42 - name: Install k6
43 run: sudo apt-get update && sudo apt-get install k6
44 - name: Run k6 load test
45 run: |
46 k6 run \
47 --env BASE_URL=${{ vars.STAGING_URL }} \
48 --out json=load-results.json \
49 tests/perf/scenarios/load.js
50 - uses: actions/upload-artifact@v4
51 if: always()
52 with:
53 name: k6-load-results-${{ github.run_id }}
54 path: load-results.jsonLighthouse CI comments scores and diffs directly on pull requests, making performance regressions visible to reviewers.
1 lighthouse:
2 name: Lighthouse CI
3 needs: [staging-deploy]
4 runs-on: ubuntu-latest
5 steps:
6 - uses: actions/checkout@v4
7 - uses: actions/setup-node@v4
8 with: { node-version: '20', cache: 'pnpm' }
9 - run: pnpm install --frozen-lockfile
10 - name: Install LHCI
11 run: npm install -g @lhci/cli
12
13 - name: Run Lighthouse CI against staging
14 run: lhci autorun
15 env:
16 LHCI_BUILD_CONTEXT__CURRENT_BRANCH: ${{ github.head_ref }}
17 LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
18 # This token enables the LHCI GitHub App to post inline PR comments
19
20 - name: Upload LHCI reports
21 if: always()
22 uses: actions/upload-artifact@v4
23 with:
24 name: lighthouse-reports-${{ github.run_id }}
25 path: .lighthouseci/1// .lighthouserc.json
2{
3 "ci": {
4 "collect": {
5 "numberOfRuns": 3,
6 "url": [
7 "$STAGING_URL/",
8 "$STAGING_URL/courses",
9 "$STAGING_URL/courses/typescript-fundamentals"
10 ]
11 },
12 "assert": {
13 "assertions": {
14 "categories:performance": ["error", { "minScore": 0.90 }],
15 "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
16 "cumulative-layout-shift": ["error", { "maxNumericValue": 0.10 }],
17 "total-blocking-time": ["error", { "maxNumericValue": 300 }]
18 }
19 },
20 "upload": {
21 "target": "lhci",
22 "serverBaseUrl": "${{ vars.LHCI_SERVER_URL }}"
23 }
24 }
25}A Node.js script that reads Next.js build output stats and fails CI when page sizes exceed budget.
1// scripts/check-bundle-size.js
2const fs = require('fs');
3const path = require('path');
4
5// Read Next.js build stats from .next/build-manifest.json
6const buildDir = path.join(process.cwd(), '.next');
7const statsFile = path.join(buildDir, 'analyze/client.html');
8
9// Use the Next.js standalone build size report from ANALYZE=true build
10// Or parse .next/build-manifest.json directly:
11const manifest = JSON.parse(fs.readFileSync(path.join(buildDir, 'build-manifest.json'), 'utf8'));
12
13// Budget in bytes (150 KB compressed ~ 450 KB uncompressed)
14const INITIAL_JS_BUDGET = 450 * 1024; // 450 KB parsed
15
16const pages = Object.entries(manifest.pages);
17let failed = false;
18
19console.log('=== Bundle Size Report ===\n');
20
21for (const [page, chunks] of pages) {
22 if (page.startsWith('/_') || page === '/404') continue;
23
24 let totalSize = 0;
25 for (const chunk of chunks) {
26 const chunkPath = path.join(buildDir, 'static', chunk);
27 if (fs.existsSync(chunkPath)) {
28 totalSize += fs.statSync(chunkPath).size;
29 }
30 }
31
32 const kb = Math.round(totalSize / 1024);
33 const status = totalSize > INITIAL_JS_BUDGET ? '✗' : '✓';
34 console.log(`${status} ${page}: ${kb} KB`);
35
36 if (totalSize > INITIAL_JS_BUDGET) {
37 console.error(` FAIL: Exceeds ${Math.round(INITIAL_JS_BUDGET / 1024)} KB budget`);
38 failed = true;
39 }
40}
41
42if (failed) {
43 console.error('\nBundle size budget exceeded. Run: ANALYZE=true npm run build');
44 process.exit(1);
45}
46console.log('\nAll pages within budget');Add to CI:
1- name: Check bundle sizes
2 run: |
3 ANALYZE=true pnpm build
4 node scripts/check-bundle-size.jsA GitHub Actions step that posts performance metrics as a PR comment, comparing against the main branch baseline.
1 perf-comment:
2 name: Post Performance Report
3 needs: [k6-load, lighthouse]
4 if: github.event_name == 'pull_request'
5 runs-on: ubuntu-latest
6 steps:
7 - uses: actions/download-artifact@v4
8 with:
9 name: k6-load-results-${{ github.run_id }}
10
11 - uses: actions/download-artifact@v4
12 with:
13 name: lighthouse-reports-${{ github.run_id }}
14
15 - name: Generate performance report
16 id: report
17 run: node scripts/generate-perf-report.js
18
19 - name: Post PR comment
20 uses: actions/github-script@v7
21 with:
22 script: |
23 const fs = require('fs');
24 const report = fs.readFileSync('perf-report.md', 'utf8');
25
26 // Find existing perf comment and update it
27 const { data: comments } = await github.rest.issues.listComments({
28 owner: context.repo.owner,
29 repo: context.repo.repo,
30 issue_number: context.issue.number,
31 });
32
33 const existing = comments.find(
34 (c) => c.body.includes('<!-- performance-report -->')
35 );
36
37 if (existing) {
38 await github.rest.issues.updateComment({
39 owner: context.repo.owner,
40 repo: context.repo.repo,
41 comment_id: existing.id,
42 body: report,
43 });
44 } else {
45 await github.rest.issues.createComment({
46 owner: context.repo.owner,
47 repo: context.repo.repo,
48 issue_number: context.issue.number,
49 body: report,
50 });
51 }1// scripts/generate-perf-report.js
2const fs = require('fs');
3const k6Results = JSON.parse(fs.readFileSync('load-results.json', 'utf8'));
4
5const p95 = k6Results.metrics?.http_req_duration?.values?.['p(95)'];
6const errorRate = (k6Results.metrics?.http_req_failed?.values?.rate * 100).toFixed(2);
7const rps = k6Results.metrics?.http_reqs?.values?.rate?.toFixed(1);
8
9const report = `<!-- performance-report -->
10## Performance Test Results
11
12| Metric | Result | SLO |
13|--------|--------|-----|
14| p95 Latency | ${Math.round(p95)}ms | <500ms |
15| Error Rate | ${errorRate}% | <0.1% |
16| RPS | ${rps} req/s | >100 req/s |
17| Status | ${p95 < 500 && errorRate < 0.1 ? '✓ PASS' : '✗ FAIL'} | |
18`;
19
20fs.writeFileSync('perf-report.md', report);A production GitHub Actions workflow implementing the full performance gate: bundle check, staging deploy verification, k6 smoke + load, Lighthouse CI, and PR comment bot.
1# .github/workflows/performance-gate.yml
2name: Performance Gate
3on:
4 pull_request:
5 branches: [main]
6 push:
7 branches: [main]
8concurrency:
9 group: perf-GH_REF
10 cancel-in-progress: true
11jobs:
12 bundle-size:
13 name: Bundle Size Check
14 runs-on: ubuntu-latest
15 steps:
16 - uses: actions/checkout@v4
17 - uses: pnpm/action-setup@v4
18 with: { version: '10' }
19 - uses: actions/setup-node@v4
20 with: { node-version: '20', cache: 'pnpm' }
21 - run: pnpm install --frozen-lockfile
22 - run: pnpm build
23 - run: node scripts/check-bundle-size.js
24 staging-deploy:
25 name: Deploy to Staging
26 needs: [bundle-size]
27 runs-on: ubuntu-latest
28 outputs:
29 staging_url: DEPLOY_URL
30 steps:
31 - uses: actions/checkout@v4
32 - name: Deploy to Vercel Preview
33 id: deploy
34 run: echo url=https://staging.example.com >> GITHUB_OUTPUT
35 k6-smoke:
36 name: API Smoke Test
37 needs: [staging-deploy]
38 runs-on: ubuntu-latest
39 steps:
40 - uses: actions/checkout@v4
41 - run: sudo apt-get update -q && sudo apt-get install -q k6
42 - run: k6 run --env BASE_URL=STAGING_URL tests/perf/scenarios/smoke.js
43 - uses: actions/upload-artifact@v4
44 if: always()
45 with: { name: smoke-results, path: smoke.json }
46 k6-load:
47 name: API Load Test
48 needs: [k6-smoke]
49 if: github.event_name == 'push'
50 runs-on: ubuntu-latest
51 steps:
52 - uses: actions/checkout@v4
53 - run: sudo apt-get update -q && sudo apt-get install -q k6
54 - run: k6 run --env BASE_URL=STAGING_URL --out json=load.json tests/perf/scenarios/load.js
55 - uses: actions/upload-artifact@v4
56 if: always()
57 with: { name: load-results, path: load.json }
58 lighthouse:
59 name: Core Web Vitals
60 needs: [staging-deploy]
61 runs-on: ubuntu-latest
62 steps:
63 - uses: actions/checkout@v4
64 - uses: actions/setup-node@v4
65 with: { node-version: '20' }
66 - run: npm install -g @lhci/cli
67 - run: lhci autorun
68 env:
69 LHCI_GITHUB_APP_TOKEN: LHCI_TOKEN_SECRET
70 - uses: actions/upload-artifact@v4
71 if: always()
72 with: { name: lighthouse-reports, path: .lighthouseci/ }
73 pr-comment:
74 name: Post Performance Report
75 needs: [k6-smoke, lighthouse]
76 if: github.event_name == 'pull_request'
77 runs-on: ubuntu-latest
78 steps:
79 - uses: actions/checkout@v4
80 - uses: actions/download-artifact@v4
81 with: { name: smoke-results, path: results/ }
82 - run: node scripts/generate-perf-report.js
83 - uses: actions/github-script@v7
84 with:
85 script: |
86 const fs = require('fs');
87 if (!fs.existsSync('perf-report.md')) return;
88 const report = fs.readFileSync('perf-report.md', 'utf8');
89 const { data: comments } = await github.rest.issues.listComments(
90 { ...context.repo, issue_number: context.issue.number });
91 const existing = comments.find(c => c.body.includes('perf-bot'));
92 if (existing) {
93 await github.rest.issues.updateComment(
94 { ...context.repo, comment_id: existing.id, body: report });
95 } else {
96 await github.rest.issues.createComment(
97 { ...context.repo, issue_number: context.issue.number, body: report });
98 }This complete GitHub Actions workflow implements all five performance gates as separate jobs with dependency chains. Bundle size check and staging deploy run first in parallel-capable sequence. k6 smoke runs after deploy and only starts the load test if smoke passes. Lighthouse CI runs against the staging URL concurrently with k6. The PR comment job runs after both and posts a combined report even if tests failed (using always()). The load test only runs on push to main — not every PR — to avoid 25-minute CI times on every commit. This is a pragmatic tradeoff: smoke tests catch deployment breakage, load tests verify sustained SLOs after merge.
You have completed the Performance Testing module. The next module covers Security Testing — starting with an overview of the OWASP Top 10 and how to systematically test for each vulnerability category in a Node.js web application.
Your progress is saved in this browser, on this device only.