SpeedCurve CI/CD Integration: Automated Performance Testing in Your Pipeline

SpeedCurve CI/CD Integration: Automated Performance Testing in Your Pipeline

Continuous performance monitoring catches regressions after they reach production. CI/CD integration catches them before. By running SpeedCurve tests against every pull request and blocking deploys when performance budgets are breached, teams can prevent regressions from shipping in the first place.

This guide covers SpeedCurve's API, GitHub Actions integration, Jenkins pipelines, performance gates, and the Deploy API for annotating production releases with performance data.

Why CI/CD Performance Testing Matters

Without CI gates, performance regressions follow a predictable pattern:

  1. Developer ships a feature with a large JavaScript bundle or unoptimized image
  2. The regression goes unnoticed for days or weeks
  3. Users and search rankings are affected
  4. Someone eventually notices the slowdown, has to bisect through commits, and fixes it under pressure

With CI gates, the regression is caught before it merges:

  1. PR opens with the change
  2. CI runs SpeedCurve tests against a staging URL
  3. LCP is 800ms worse than the main branch — budget is breached
  4. PR check fails with a link to the SpeedCurve results
  5. Developer investigates and optimizes before merging

The total cost is much lower: the regression never ships, no users are affected, and the developer has full context because the change is fresh in their mind.

Understanding the SpeedCurve API

SpeedCurve's API is the foundation for all CI/CD integrations. It lets you:

  • Trigger test runs programmatically
  • Retrieve test results
  • Compare results against a baseline
  • Send deploy notifications

Authentication

All API requests require your SpeedCurve API key, which you'll find under Account > API.

# Test your API key
curl -u your-api-key:x \
  "https://api.speedcurve.com/v1/sites"

Triggering a Test Run

To trigger a fresh test of all URLs in a site:

curl -X POST \
  -u your-api-key:x \
  "https://api.speedcurve.com/v1/tests" \
  -d "site_id=YOUR_SITE_ID&priority=3"

The priority parameter controls how quickly the test is queued:

  • 1 — Urgent (highest priority, for CI use)
  • 2 — High
  • 3 — Normal

The response includes a test_id you can use to poll for results.

Polling for Results

Tests take 2–5 minutes to complete depending on the number of URLs and profiles. Poll using:

curl -u your-api-key:x \
  "https://api.speedcurve.com/v1/tests/TEST_ID"

When state is completed, the response contains the full results including all metrics, budget status, and a link to the dashboard.

Checking Budget Status

The test results response includes a budgets array showing which budgets were breached:

{
  "test_id": "abc123",
  "state": "completed",
  "budgets": [
    {
      "metric": "lcp",
      "threshold": 2500,
      "value": 3240,
      "status": "breach",
      "url": "https://example.com/",
      "profile": "Chrome Cable"
    }
  ]
}

A non-empty budgets array with status: breach entries means your CI step should fail.

GitHub Actions Integration

Here's a complete GitHub Actions workflow that runs SpeedCurve tests on every PR and blocks merge on budget breaches.

Basic Workflow

# .github/workflows/performance.yml
name: Performance Testing

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

jobs:
  performance:
    name: SpeedCurve Performance Test
    runs-on: ubuntu-latest
    
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Deploy to staging
        id: staging
        run: |
          # Your staging deploy command here
          echo "staging_url=https://staging.example.com" >> $GITHUB_OUTPUT

      - name: Trigger SpeedCurve test
        id: trigger
        env:
          SPEEDCURVE_API_KEY: ${{ secrets.SPEEDCURVE_API_KEY }}
          SPEEDCURVE_SITE_ID: ${{ vars.SPEEDCURVE_SITE_ID }}
        run: |
          RESPONSE=$(curl -s -X POST \
            -u "$SPEEDCURVE_API_KEY:x" \
            "https://api.speedcurve.com/v1/tests" \
            -d "site_id=$SPEEDCURVE_SITE_ID&priority=1")
          
          TEST_ID=$(echo $RESPONSE | jq -r '.test_id')
          echo "test_id=$TEST_ID" >> $GITHUB_OUTPUT
          echo "Triggered test: $TEST_ID"

      - name: Wait for results
        id: results
        env:
          SPEEDCURVE_API_KEY: ${{ secrets.SPEEDCURVE_API_KEY }}
        run: |
          TEST_ID="${{ steps.trigger.outputs.test_id }}"
          MAX_ATTEMPTS=30
          ATTEMPT=0
          
          while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do
            RESPONSE=$(curl -s -u "$SPEEDCURVE_API_KEY:x" \
              "https://api.speedcurve.com/v1/tests/$TEST_ID")
            
            STATE=$(echo $RESPONSE | jq -r '.state')
            echo "Test state: $STATE (attempt $ATTEMPT)"
            
            if [ "$STATE" = "completed" ]; then
              echo "results=$RESPONSE" >> $GITHUB_OUTPUT
              break
            fi
            
            ATTEMPT=$((ATTEMPT + 1))
            sleep 30
          done
          
          if [ "$STATE" != "completed" ]; then
            echo "Timed out waiting for results"
            exit 1
          fi

      - name: Check performance budgets
        env:
          RESULTS: ${{ steps.results.outputs.results }}
        run: |
          BREACHES=$(echo "$RESULTS" | jq '[.budgets[] | select(.status == "breach")]')
          BREACH_COUNT=$(echo "$BREACHES" | jq 'length')
          
          if [ "$BREACH_COUNT" -gt "0" ]; then
            echo "❌ Performance budget breaches detected:"
            echo "$BREACHES" | jq -r '.[] | "  \(.metric) on \(.url): \(.value)ms (budget: \(.threshold)ms)"'
            
            DASHBOARD_URL=$(echo "$RESULTS" | jq -r '.dashboard_url')
            echo ""
            echo "View full results: $DASHBOARD_URL"
            exit 1
          else
            echo "✅ All performance budgets passed"
          fi

Environment Setup

Add these to your GitHub repository:

  • Secret: SPEEDCURVE_API_KEY — your SpeedCurve API key
  • Variable: SPEEDCURVE_SITE_ID — found in SpeedCurve under Settings > Sites

Using Branch-Specific Testing

For a more sophisticated setup, test the staging URL for your PR branch specifically, rather than the default site URLs. SpeedCurve allows you to trigger tests against specific URLs:

- name: Trigger SpeedCurve test against staging
  env:
    SPEEDCURVE_API_KEY: ${{ secrets.SPEEDCURVE_API_KEY }}
  run: |
    STAGING_URL="${{ steps.staging.outputs.staging_url }}"
    
    # Trigger test against specific URL
    curl -s -X POST \
      -u "$SPEEDCURVE_API_KEY:x" \
      "https://api.speedcurve.com/v1/tests" \
      -d "url=$STAGING_URL&site_id=${{ vars.SPEEDCURVE_SITE_ID }}&priority=1"

This tests the actual PR build on staging rather than the current production URLs — making the CI gate truly representative of what will ship.

Jenkins Integration

For teams using Jenkins, the integration uses the same API calls wrapped in a Pipeline script.

Jenkinsfile Example

pipeline {
  agent any
  
  environment {
    SPEEDCURVE_API_KEY = credentials('speedcurve-api-key')
    SPEEDCURVE_SITE_ID = '12345'
  }
  
  stages {
    stage('Deploy to Staging') {
      steps {
        sh './deploy-staging.sh'
      }
    }
    
    stage('Performance Test') {
      steps {
        script {
          // Trigger test
          def response = sh(
            script: """
              curl -s -X POST \
                -u "${SPEEDCURVE_API_KEY}:x" \
                "https://api.speedcurve.com/v1/tests" \
                -d "site_id=${SPEEDCURVE_SITE_ID}&priority=1"
            """,
            returnStdout: true
          ).trim()
          
          def testId = readJSON(text: response).test_id
          echo "Triggered SpeedCurve test: ${testId}"
          
          // Poll for results
          def state = ''
          def attempts = 0
          def results
          
          while (state != 'completed' && attempts < 30) {
            sleep(30)
            def statusResponse = sh(
              script: """
                curl -s -u "${SPEEDCURVE_API_KEY}:x" \
                  "https://api.speedcurve.com/v1/tests/${testId}"
              """,
              returnStdout: true
            ).trim()
            
            results = readJSON(text: statusResponse)
            state = results.state
            attempts++
            echo "Test state: ${state} (attempt ${attempts})"
          }
          
          if (state != 'completed') {
            error('SpeedCurve test timed out')
          }
          
          // Check budgets
          def breaches = results.budgets.findAll { it.status == 'breach' }
          
          if (breaches) {
            def message = "Performance budget breaches:\n"
            breaches.each { breach ->
              message += "  ${breach.metric} on ${breach.url}: ${breach.value}ms (budget: ${breach.threshold}ms)\n"
            }
            error(message)
          }
          
          echo "All performance budgets passed"
        }
      }
    }
    
    stage('Deploy to Production') {
      when {
        branch 'main'
      }
      steps {
        sh './deploy-production.sh'
      }
    }
  }
}

Handling Test Timeouts

In CI environments, tests shouldn't block forever. Set a maximum wait time (30 attempts × 30 seconds = 15 minutes is usually sufficient) and fail gracefully with a clear message if the tests time out rather than hanging the pipeline.

The SpeedCurve Deploy API

Even when you're not using CI gates, the Deploy API is essential for correlating performance changes with deployments. When SpeedCurve knows when you deployed, trend charts show deployment markers that make it obvious whether a performance change was caused by your code.

Sending Deploy Events

Call the Deploy API at the end of every production deployment:

curl -X POST \
  -u your-api-key:x \
  "https://api.speedcurve.com/v1/deploys" \
  -d "site_id=YOUR_SITE_ID" \
  -d "note=Deploy v2.4.1 - feature/checkout-redesign" \
  -d "detail=GitHub SHA: $GITHUB_SHA"

The note appears as a label on trend charts. Use it to include the version number, branch name, or a brief description of what changed.

Integrating Deploy Events in GitHub Actions

Add a deploy notification step after your production deployment:

- name: Notify SpeedCurve of deploy
  if: github.ref == 'refs/heads/main' && success()
  env:
    SPEEDCURVE_API_KEY: ${{ secrets.SPEEDCURVE_API_KEY }}
    SPEEDCURVE_SITE_ID: ${{ vars.SPEEDCURVE_SITE_ID }}
  run: |
    curl -X POST \
      -u "$SPEEDCURVE_API_KEY:x" \
      "https://api.speedcurve.com/v1/deploys" \
      -d "site_id=$SPEEDCURVE_SITE_ID" \
      -d "note=Deploy ${{ github.sha }}" \
      -d "detail=Branch: ${{ github.ref_name }}, PR: ${{ github.event.pull_request.number }}"

Using Deploy Data for Incident Response

When a performance regression is detected in monitoring, the first question is "what deployed recently?" SpeedCurve's deploy markers answer this immediately — you can see which deployment preceded the regression without searching through git logs or deploy history.

This speeds up incident response significantly: instead of spending 20 minutes narrowing down the cause, you can identify the culprit deployment in seconds and either roll back or start a targeted investigation.

Performance as a CI Gate: Best Practices

Choose the Right Metrics for Gating

Not every metric should block a deploy. Gate on:

  • LCP: Most impactful for user experience and search rankings
  • CLS: Visual stability regressions are immediately noticeable
  • TBT: A large increase often indicates a JavaScript bundle problem
  • Page weight (JS): A sudden jump in JavaScript size is a reliable signal of accidental bundle bloat

Don't gate on:

  • TTFB in CI: Server response time from a staging server won't be representative of production performance
  • Score metrics: Too coarse to catch specific regressions without noise

Set Looser Thresholds for CI

Your continuous monitoring budgets should be set at your aspirational targets. Your CI budgets should be set to catch regressions relative to your current baseline — not to require the code to already be perfect.

For example:

  • Monitoring budget: LCP < 2.5s (your target)
  • CI budget: LCP < 3.5s (catch if the PR makes things significantly worse, but don't block all PRs if you're currently at 2.8s)

A CI gate that always fails is worse than no CI gate — it trains developers to ignore or bypass it.

Handle Flaky Tests

Synthetic tests have some variability. A test that normally returns LCP 2.2s might occasionally return 2.6s due to CDN routing variability or test infrastructure load. If your CI budget is set at 2.5s, you'll get occasional false failures.

Strategies to reduce flakiness:

  • Run 2–3 test repetitions and take the median
  • Use SpeedCurve's comparison mode (fail only if the result is more than X% worse than the rolling median)
  • Allow one retry before marking the check as failed

Make Failures Actionable

A CI failure message that just says "performance budget breached" is frustrating. Your failure output should include:

  • Which metric failed on which URL
  • The actual value vs. the budget
  • A direct link to the SpeedCurve results
  • The filmstrip comparison (if comparing against a baseline)

This means developers can investigate without leaving the PR — they can click the link, see the waterfall, and immediately start debugging.

Testing Performance in Preview Environments

If your deployment pipeline creates preview environments for every PR (common with Vercel, Netlify, or Heroku Review Apps), SpeedCurve can test against preview URLs automatically.

The key is passing the preview URL to the SpeedCurve API trigger rather than testing the fixed production URL. Most preview environment platforms expose the URL as an environment variable or API response that you can capture in the CI workflow:

- name: Get preview URL
  id: preview
  run: |
    # For Vercel:
    PREVIEW_URL=$(vercel --token ${{ secrets.VERCEL_TOKEN }} inspect --json | jq -r '.url')
    echo "url=https://$PREVIEW_URL" >> $GITHUB_OUTPUT

- name: Run SpeedCurve against preview
  run: |
    curl -X POST \
      -u "${{ secrets.SPEEDCURVE_API_KEY }}:x" \
      "https://api.speedcurve.com/v1/tests" \
      -d "url=${{ steps.preview.outputs.url }}" \
      -d "site_id=${{ vars.SPEEDCURVE_SITE_ID }}&priority=1"

Conclusion

Integrating SpeedCurve into your CI/CD pipeline closes the feedback loop between code changes and performance impact. Developers get immediate feedback on whether their changes affect performance, regressions are caught before they reach users, and the Deploy API keeps your trend charts interpretable.

Start with a simple GitHub Actions workflow that triggers tests and checks budgets. Use the Deploy API from day one — the deploy markers become invaluable within weeks as you start correlating changes with trends. Then iterate: tighten budgets as you improve performance, add more pages to the CI test set, and refine your failure messages to be more actionable.

Performance engineering is most effective when it's embedded in the development workflow rather than treated as a post-hoc audit. CI/CD integration is what makes that embedding possible.

Read more

Start now free