Back to Blog

Load Testing for Peak Traffic: A k6 Guide for Black Friday Readiness

Black Friday doesn't forgive unpreparedness. Learn how to simulate realistic traffic spikes with k6, interpret results to find your system's breaking point, and build a repeatable load testing pipeline that runs automatically before every major release.

Published

7 min read

Reading time

Load Testing for Peak Traffic: A k6 Guide for Black Friday Readiness

Every year, engineering teams discover their infrastructure limits during the worst possible moment: the first wave of Black Friday traffic. Checkout APIs buckle. Database connection pools exhaust. Queue processors fall behind. Customers leave.

The difference between teams that survive peak traffic and teams that don't comes down to one thing: they tested before the traffic arrived.

k6 is the modern standard for developer-centric load testing. It runs JavaScript, integrates with CI/CD pipelines, and produces structured output that feeds dashboards and alerting. This guide covers everything from writing your first k6 script to running a full Black Friday simulation.


Why k6 Over Other Load Testing Tools

Feature k6 JMeter Locust Gatling
Scripting language JavaScript XML/GUI Python Scala
CI/CD integration Native Complex Moderate Good
Protocol support HTTP, WS, gRPC HTTP, WS HTTP HTTP, WS
Resource efficiency High Low Moderate High
Open source
Cloud execution k6 Cloud No Locust.io Gatling Cloud
Learning curve Low High Medium High

k6's JavaScript API feels natural to frontend/backend engineers, unlike JMeter's XML-heavy config or Gatling's Scala DSL.


Anatomy of a k6 Script

// tests/load/baseline.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Trend, Counter } from 'k6/metrics';

// Custom metrics
const checkoutDuration = new Trend('checkout_duration');
const failedCheckouts = new Counter('failed_checkouts');

// Test configuration
export const options = {
  stages: [
    { duration: '2m', target: 50 }, // Ramp up to 50 users
    { duration: '5m', target: 50 }, // Hold at 50 users
    { duration: '1m', target: 0 }, // Ramp down
  ],
  thresholds: {
    http_req_duration: ['p(95)<500'], // 95th percentile < 500ms
    http_req_failed: ['rate<0.01'], // Error rate < 1%
    checkout_duration: ['p(95)<2000'], // Checkout under 2s
  },
};

const BASE_URL = __ENV.BASE_URL || 'https://staging.yourapp.com';

export default function () {
  // Step 1: Browse product
  const productRes = http.get(`${BASE_URL}/api/products/featured`);
  check(productRes, {
    'product page 200': (r) => r.status === 200,
    'product page fast': (r) => r.timings.duration < 300,
  });

  sleep(1);

  // Step 2: Add to cart
  const cartRes = http.post(
    `${BASE_URL}/api/cart/add`,
    JSON.stringify({
      productId: 'prod_123',
      quantity: 1,
    }),
    { headers: { 'Content-Type': 'application/json' } },
  );

  check(cartRes, { 'cart add 200': (r) => r.status === 200 });

  // Step 3: Checkout (track separately)
  const start = Date.now();
  const checkoutRes = http.post(
    `${BASE_URL}/api/checkout/initiate`,
    JSON.stringify({
      cartId: cartRes.json('cartId'),
    }),
    { headers: { 'Content-Type': 'application/json' } },
  );

  checkoutDuration.add(Date.now() - start);

  if (checkoutRes.status !== 200) {
    failedCheckouts.add(1);
  }

  sleep(2);
}

The Four Load Test Types

flowchart LR
    A[Smoke Test\n5 users, 2 min] --> B[Load Test\n50 users, 30 min]
    B --> C[Stress Test\nRamp to breaking point]
    C --> D[Soak Test\n50 users, 8 hours]

    style A fill:#22c55e,color:#fff
    style B fill:#3b82f6,color:#fff
    style C fill:#f59e0b,color:#fff
    style D fill:#ef4444,color:#fff
Test Type Purpose Users Duration
Smoke Verify script works, basic sanity 1–5 2–5 min
Load Validate normal + 2× peak traffic 50–500 30–60 min
Stress Find the breaking point Ramp until failures Until failure
Soak Detect memory leaks, connection exhaustion Normal load 8–24 hours

Black Friday Simulation: Realistic Traffic Shaping

Real traffic spikes don't follow a smooth ramp. They have waves, flash points, and flash sales. Model this with spike scenarios:

// tests/load/black-friday.js
export const options = {
  scenarios: {
    // Normal pre-sale browsing
    background_traffic: {
      executor: 'constant-vus',
      vus: 100,
      duration: '10m',
      startTime: '0s',
    },
    // Flash sale spike (midnight EST)
    flash_sale_spike: {
      executor: 'ramping-vus',
      startVUs: 0,
      stages: [
        { duration: '30s', target: 2000 }, // Sudden spike
        { duration: '3m', target: 2000 }, // Hold
        { duration: '1m', target: 500 }, // Partial drop
        { duration: '5m', target: 500 }, // Sustained elevated
        { duration: '2m', target: 0 },
      ],
      startTime: '2m', // Starts 2 minutes in
    },
  },
  thresholds: {
    'http_req_duration{type:checkout}': ['p(99)<5000'],
    http_req_failed: ['rate<0.05'], // Allow 5% error rate at peak
  },
};

Interpreting k6 Results

After a run, k6 outputs a summary. Here's how to read the critical metrics:

✓ product page 200 ......... 98.3% ✓ 4915  ✗ 85
✓ cart add 200 ............. 100%  ✓ 5000
✗ checkout 200 ............. 94.2% ✓ 4710  ✗ 290

http_req_duration.............: avg=342ms min=12ms med=289ms max=8.2s p(90)=612ms p(95)=1.1s p(99)=4.8s
checkout_duration.............: avg=891ms                              p(90)=1.8s  p(95)=2.4s ← FAILING threshold
failed_checkouts..............: 290

The p(95) and p(99) values matter most. An average of 342ms can hide a p(99) of 4.8 seconds — meaning 1% of users wait nearly 5 seconds. Under 5,000 users that's 50 people with terrible experiences. Under 500,000 it's 5,000.

What to look for:

  • Checkout failures during spike (indicates rate limiting, connection pool exhaustion, or payment provider throttling)
  • p(99) latency climbing with user count (indicates resource contention)
  • Memory/CPU on server during test (correlate timestamps with k6 output)

Database and Connection Pool Testing

The most common failure mode under load on SaaS apps is connection pool exhaustion. A database accepts 100 concurrent connections, your app has 10 instances each expecting 15 connections — that's 150 connections requested against a 100 limit.

// tests/load/db-pressure.js
// Simulate concurrent database-heavy operations
export const options = {
  stages: [
    { duration: '1m', target: 20 },
    { duration: '3m', target: 100 }, // Each VU triggers a DB query
    { duration: '1m', target: 200 }, // Exceed typical connection limit
    { duration: '2m', target: 0 },
  ],
  thresholds: {
    // If p(95) starts climbing above this, connection pool is likely exhausted
    http_req_duration: ['p(95)<800'],
  },
};

export default function () {
  // Dashboard endpoint that runs expensive aggregation
  const res = http.get(`${BASE_URL}/api/reports/summary?range=30d`);

  check(res, {
    'no 503 (pool exhausted)': (r) => r.status !== 503,
    'no 504 (timeout)': (r) => r.status !== 504,
    'response fast enough': (r) => r.timings.duration < 800,
  });

  sleep(0.5);
}

CI/CD Integration

Run load tests automatically on staging before every major deploy:

# .github/workflows/load-test.yml
name: Load Test on Staging
on:
  workflow_dispatch:
  push:
    branches: [staging]

jobs:
  load-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run k6 load test
        uses: grafana/k6-action@v0.3.0
        with:
          filename: tests/load/baseline.js
          flags: --out json=results.json
        env:
          BASE_URL: ${{ vars.STAGING_URL }}
          K6_CLOUD_TOKEN: ${{ secrets.K6_CLOUD_TOKEN }}

      - name: Upload results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: k6-results
          path: results.json

      - name: Comment results on PR
        uses: actions/github-script@v7
        if: github.event_name == 'pull_request'
        with:
          script: |
            const fs = require('fs');
            // Parse results.json and post summary comment

Related articles: Also see understanding which load test type is right for peak-traffic preparation, injecting latency and failures to complement your load testing, and the server-side performance bottleneck exposed first under high load.


Release Readiness Checklist

Before deploying to production ahead of a traffic event:

[ ] Smoke test passes (1 user, all critical paths 200)
[ ] Load test passes at 2× expected peak traffic
[ ] Stress test identifies breaking point > 3× expected peak
[ ] Database connection pool configured correctly for number of instances
[ ] Autoscaling rules are tested and verified
[ ] CDN caching verified for static assets and eligible API responses
[ ] Rate limiting configured on checkout/auth endpoints
[ ] Fallback/degradation behavior tested at near-failure load
[ ] Rollback plan documented and tested

Load testing with k6 is one piece of the readiness puzzle. Pairing it with continuous functional monitoring ensures that your performance-tested system is also working correctly under load.

Further Reading

Monitor production behavior under real traffic: Try ScanlyApp free and set up post-deploy smoke checks that run automatically on every release.

Related Posts

Testing CDN Caching Rules and Cache Invalidation: A Developer's Guide
Performance & Scalability
6 min read

Testing CDN Caching Rules and Cache Invalidation: A Developer's Guide

CDN misconfiguration is one of the hardest bugs to catch in QA — it works perfectly in staging (which bypasses the CDN) but fails in production. Learn how to test cache headers, validate invalidation logic, and build automated checks that keep your caching layer honest.