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:
- Developer ships a feature with a large JavaScript bundle or unoptimized image
- The regression goes unnoticed for days or weeks
- Users and search rankings are affected
- 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:
- PR opens with the change
- CI runs SpeedCurve tests against a staging URL
- LCP is 800ms worse than the main branch — budget is breached
- PR check fails with a link to the SpeedCurve results
- 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— High3— 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"
fiEnvironment 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.