How-to

Load Testing in GitHub Actions

Updated October 27, 2026•10 min read•CI/CD•GitHub Actions

Written by

Reviewed by the LoadTester editorial team. Review process: see the editorial policy.

Published
2026-10-27
Last reviewed
2026-10-27
Author
Kristian Razum
Load Testing in GitHub Actions: LoadTester illustration

There are two practical ways to load test from GitHub Actions: run an open-source tool such as k6 directly on the runner and fail the job on thresholds, or use the workflow to trigger a hosted load test with an API token stored as a repository secret and gate on its result. Runner-based tests suit smoke tests and small performance gates on pull requests; anything that needs real load should come from outside the runner. Run a smoke test on PRs, a performance gate on main, and a longer test nightly with a cron schedule.

Two patterns, and when each fits

(a) Tool inside the runner(b) Trigger a hosted test
Where load comes fromThe GitHub-hosted (or self-hosted) runnerThe provider's load generators
Realistic ceilingLimited by a 2–4 vCPU class VM on shared networkWhatever your plan allows
Test definitionScript in the repo (versioned with the code)Saved test in the provider, referenced from the workflow
ResultsConsole summary, artifacts you uploadProvider dashboard, history, run comparison
Best forPR smoke tests, small perf gates, teams already using k6Main-branch gates at real load, nightly and soak tests

Both are reasonable, and many teams use both: k6 on the runner for a 60-second PR check, and a hosted test for the bigger runs. For the broader strategy, see load testing in CI/CD and whether CLI tools are enough for CI/CD.

Pattern (a): k6 inside the runner

k6 is a good fit here: a single binary, JavaScript test scripts, and thresholds that set the process exit code. When any threshold fails, k6 run exits with code 99, which fails the step.

The test script

// tests/perf/smoke.js
import http from 'k6/http';
import { check, sleep } from 'k6';

const BASE_URL = __ENV.BASE_URL;

export const options = {
  scenarios: {
    smoke: {
      executor: 'constant-arrival-rate',   // fixed RPS, not closed-loop VUs
      rate: Number(__ENV.RATE || 20),      // iterations per second
      timeUnit: '1s',
      duration: __ENV.DURATION || '60s',
      preAllocatedVUs: 20,
      maxVUs: 100,
    },
  },
  thresholds: {
    http_req_failed: ['rate<0.01'],                     // < 1% errors
    http_req_duration: ['p(95)<400', 'p(99)<1000'],     // ms
    'http_req_duration{name:checkout}': [{ threshold: 'p(95)<800', abortOnFail: true }],
  },
};

export default function () {
  const list = http.get(`${BASE_URL}/api/products?limit=20`, { tags: { name: 'list' } });
  check(list, { 'list 200': (r) => r.status === 200 });

  const res = http.post(`${BASE_URL}/api/checkout/quote`,
    JSON.stringify({ sku: 'TEST-001', qty: 1 }),
    { headers: { 'Content-Type': 'application/json' }, tags: { name: 'checkout' } });
  check(res, { 'quote 200': (r) => r.status === 200 });
  sleep(0.5);
}

The constant-arrival-rate executor keeps the request rate fixed even if the server slows down, which avoids hiding latency the way a closed loop does (see coordinated omission).

The workflow

# .github/workflows/perf-smoke.yml
name: perf-smoke

on:
  pull_request:
    paths: ['src/**', 'tests/perf/**']

concurrency:
  group: perf-smoke-${{ github.event.pull_request.number }}
  cancel-in-progress: true          # a new push cancels the old run

permissions:
  contents: read

jobs:
  k6-smoke:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v4

      - uses: grafana/setup-k6-action@v1

      - name: Run k6 smoke test
        env:
          BASE_URL: ${{ vars.PREVIEW_BASE_URL }}
          RATE: '20'
          DURATION: '60s'
        run: k6 run --summary-export=summary.json tests/perf/smoke.js

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

Runner limits to respect

  • Small VMs. Standard GitHub-hosted Linux runners are in the 2–4 vCPU class depending on repository visibility and plan. Larger runners exist at extra cost. If the runner's CPU is pegged, your latency numbers include the generator's own queueing.
  • Shared network. Runners sit on shared cloud infrastructure with variable network paths and changing IP addresses. Run-to-run noise is higher than on dedicated hardware, so set gates with some margin and compare trends, not single runs.
  • WAF and rate limits. Runner IPs come from large shared cloud ranges. A WAF may challenge them, and allowlisting is impractical. Test a staging host that is not behind bot protection, or use a self-hosted runner with a fixed egress IP.
  • Time limits. Jobs on GitHub-hosted runners are capped at 6 hours, and you pay for every minute on private repos. Soak tests belong elsewhere.

Pattern (b): trigger a hosted test from the workflow

Here the workflow does not generate load. It authenticates to a hosted service, starts a saved test, and fails the job if the test breached its thresholds. The load comes from the provider's workers, so the runner size does not matter.

With LoadTester, API tokens for CI/CD are included in the Starter ($19/month) and Pro ($49/month) plans. Create a token in the app (tokens start with wst_), then add it as a repository secret named LOADTESTER_TOKEN under Settings → Secrets and variables → Actions. Save the test itself in the app with its thresholds (for example p95 < 400 ms and error rate < 2%, with auto-stop on breach), and make sure the target domain is verified.

To start the run, call the run endpoint documented on the API page in the app. The URL and response fields below are placeholders, not real paths; copy the actual ones from the API docs:

      - name: Trigger saved load test
        env:
          LOADTESTER_TOKEN: ${{ secrets.LOADTESTER_TOKEN }}
          # PLACEHOLDER: set this repository variable to the run endpoint
          # for your saved test, exactly as shown in the app's API docs.
          LOADTESTER_RUN_URL: ${{ vars.LOADTESTER_RUN_URL }}
        run: |
          curl --fail-with-body -sS -X POST "$LOADTESTER_RUN_URL" \
            -H "Authorization: Bearer $LOADTESTER_TOKEN" \
            -o run.json
          cat run.json

      - name: Gate on result
        run: |
          # PLACEHOLDER: wait for completion and read the pass/fail field
          # using the status mechanism described in the API docs, then:
          #   exit 1 if the thresholds were breached
          echo "See API docs for the status and result fields"

Two alternatives avoid polling from the workflow entirely: a scheduled (cron) test in the app with Slack or email alerts and regression notifications against a baseline, or a completion webhook pointed at your own endpoint. These work well for nightly runs where nobody is waiting on a PR.

Deploy markers with the annotations endpoint

When a longer test is running while you deploy (for example a scheduled 10-minute run against staging), a marker on the chart at the moment of the deploy makes cause and effect obvious. LoadTester has an annotations endpoint for this:

POST https://app.loadtester.org/api/annotations/push
Authorization: Bearer $LOADTESTER_TOKEN
Content-Type: application/json

{"test_id":"<uuid>","ts_seconds":45,"label":"Deploy v2.1"}

test_id is the UUID of the test, ts_seconds is where on the test's timeline the marker goes (seconds from the start), and label is the text shown on the chart. As a workflow step after your deploy job:

      - name: Push deploy marker
        if: success()
        env:
          LOADTESTER_TOKEN: ${{ secrets.LOADTESTER_TOKEN }}
          TEST_ID: ${{ vars.LOADTESTER_TEST_ID }}
          TEST_STARTED_AT: ${{ needs.start-test.outputs.started_at }}   # epoch seconds
        run: |
          TS=$(( $(date +%s) - TEST_STARTED_AT ))
          curl --fail-with-body -sS -X POST \
            https://app.loadtester.org/api/annotations/push \
            -H "Authorization: Bearer $LOADTESTER_TOKEN" \
            -H "Content-Type: application/json" \
            -d "{\"test_id\":\"$TEST_ID\",\"ts_seconds\":$TS,\"label\":\"Deploy ${GITHUB_SHA::7}\"}"

If the p95 line steps up right after the marker, you have your regression and the commit that caused it. The performance regression testing guide covers how to set the comparison baseline.

When to run: PR, main, nightly

TriggerPurposeLoad and durationFails the build when
pull_requestSmoke: did this change break or badly slow a hot endpoint?Low RPS, 30–60 s, on the runnerErrors > 1% or p95 well above baseline
push to mainPerformance gate before or after deploy to stagingTarget RPS, 5–10 min, hostedThresholds breached or regression vs baseline
schedule (nightly)Trends, slow leaks, data growthTarget RPS or soak, as long as your plan allowsAlert rather than block; open an issue
on:
  push:
    branches: [main]
  schedule:
    - cron: '17 2 * * *'     # 02:17 UTC daily; avoid minute 0, which is busiest
  workflow_dispatch: {}       # manual runs with the same workflow

Schedules run in UTC, only from the default branch, and on a best-effort basis: they can start late when GitHub is busy. In public repositories, scheduled workflows are disabled automatically after 60 days without repository activity.

Concurrency, environments and secrets

One test per target at a time

Two load tests against the same staging environment at once produce two invalid results. Use a concurrency group keyed on the target, not the branch, and do not cancel a gate that is already running:

concurrency:
  group: loadtest-staging
  cancel-in-progress: false

This also keeps you within your provider's concurrent test limit (LoadTester Pro allows two concurrent tests; Free and Starter allow one).

Protect the environment

jobs:
  perf-gate:
    runs-on: ubuntu-latest
    environment: perf-staging     # environment-scoped secrets and rules
    timeout-minutes: 20

In Settings → Environments, restrict perf-staging to the main branch and store LOADTESTER_TOKEN there instead of at repository level. For anything that touches production, add required reviewers so a person approves each run.

Secrets hygiene

  • Pass tokens through env: and reference them as shell variables, never inline ${{ secrets.X }} inside a script string that might be echoed.
  • Pull requests from forks do not receive secrets. Do not "fix" that with pull_request_target plus a checkout of the PR head; that runs untrusted code with your token.
  • Set permissions: contents: read at workflow level unless a job needs more.
  • Rotate the token if a log ever shows it; GitHub masks registered secrets but not values derived from them.
Starter checklist

Add a 60-second k6 smoke test with http_req_failed and p(95) thresholds on PRs; add a main-branch gate that triggers a hosted test with a secret token in a protected environment; add a nightly cron at an odd minute; put all of them in one concurrency group per target. Compare the options in LoadTester vs k6 if you are choosing between them.

FAQ

Can I run a real load test on a GitHub-hosted runner?

You can run small ones. Standard GitHub-hosted runners are in the 2-4 vCPU class on shared network infrastructure, which is enough for smoke tests and performance gates at modest load. For tests that need thousands of RPS or a stable network path, run the load from dedicated infrastructure or a hosted service and use the workflow only to trigger and gate.

How do I make the workflow fail when the load test fails?

Make the step exit non-zero. k6 exits with code 99 when any threshold fails, which fails the step and the job. For a hosted test, read the result returned by the provider's API and exit 1 when thresholds were breached.

Why does my scheduled load test workflow not run on time?

GitHub runs scheduled workflows on a best-effort basis and they can be delayed during periods of high load, especially at the top of the hour. Pick an unusual minute such as 17 past. Scheduled workflows also only run from the default branch, and in public repositories they are disabled after 60 days without repository activity.

Are secrets available to load tests on pull requests from forks?

No. Workflows triggered by pull_request from a fork do not receive repository secrets, so a test that needs an API token will fail there. Do not work around this with pull_request_target plus a checkout of the fork's code, because that runs untrusted code with access to your secrets.

Should load tests run against production from CI?

Rarely. Run CI load tests against a production-like staging or preview environment. If you do test production, use a protected environment with required reviewers, a small load profile, thresholds with auto-stop, and a schedule outside peak hours.

Run this as a real load testFree plan: 10 VUs, 50 RPS, live p95/p99 and error charts, no credit card. Starter $19/mo (250 VUs) or Pro $49/mo (2,000 VUs) with a 7-day free trial when you need more.
Start freeCompare plans