The externally-controlled executor allows you to control k6 execution in real-time via the REST API. You can dynamically scale VUs up or down, pause and resume execution, and adjust test parameters while the test is running.
How It Works
With externally controlled execution:
- Test starts with initial
vus and runs for duration (or indefinitely if duration is 0)
- VUs continuously loop through iterations
- You can control execution via the k6 REST API:
- Scale VUs up or down
- Pause and resume the test
- Update the configuration dynamically
- Test runs until duration expires or you stop it manually
VUs ^
50 | API: scale to 50
30 | ╱‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾╲
10 |‾‾‾‾╯ ╲___
0 +-----------------------------------> time
^ ^ ^ ^
start scale up scale down
(via API) (via API)
Configuration
Must be externally-controlled
Initial number of VUs. Can be 0. Cannot be negative.
Maximum number of VUs that can be used during the test. Once set at test start, this cannot be decreased (only increased via API).
Maximum test duration. Use 0 for infinite duration (test runs until manually stopped). Cannot be negative.
The gracefulStop option is NOT supported by this executor. VUs stop immediately when the test ends.
Example
Basic Setup
import http from 'k6/http';
import { sleep } from 'k6';
export const options = {
scenarios: {
external: {
executor: 'externally-controlled',
vus: 10,
maxVUs: 100,
duration: '30m', // Or '0' for infinite
},
},
};
export default function () {
http.get('https://test.k6.io');
sleep(1);
}
Run with the API enabled:
k6 run --http-debug script.js
Infinite Duration Test
export const options = {
scenarios: {
external: {
executor: 'externally-controlled',
vus: 1,
maxVUs: 50,
duration: '0', // Runs until manually stopped
},
},
};
Start Paused
k6 run --paused script.js
Then control via API to resume when ready.
API Control
The k6 REST API (enabled with --http-debug or environment variable) provides endpoints to control execution.
Get Current Status
curl http://localhost:6565/v1/status
Response:
{
"data": {
"attributes": {
"paused": false,
"vus": 10,
"vus-max": 100,
"stopped": false,
"running": true,
"tainted": false
}
}
}
Scale VUs
curl -X PATCH http://localhost:6565/v1/status \
-H 'Content-Type: application/json' \
-d '{
"data": {
"attributes": {
"vus": 50
}
}
}'
Pause Test
curl -X PATCH http://localhost:6565/v1/status \
-H 'Content-Type: application/json' \
-d '{
"data": {
"attributes": {
"paused": true
}
}
}'
Resume Test
curl -X PATCH http://localhost:6565/v1/status \
-H 'Content-Type: application/json' \
-d '{
"data": {
"attributes": {
"paused": false
}
}
}'
Scale and Adjust MaxVUs
curl -X PATCH http://localhost:6565/v1/status \
-H 'Content-Type: application/json' \
-d '{
"data": {
"attributes": {
"vus": 80,
"vus-max": 200
}
}
}'
When to Use
Use the externally controlled executor when:
- You need dynamic control during test execution
- You’re integrating k6 with external monitoring/alerting systems
- You want to adjust load based on real-time application metrics
- You’re building custom load testing dashboards
- You need to manually respond to test conditions
- You want to run exploratory performance testing
- You’re implementing adaptive load testing algorithms
Behavior Details
Initial VUs and MaxVUs
At test start, k6 initializes maxVUs and activates vus of them:
{
vus: 10, // 10 VUs active
maxVUs: 100, // 100 VUs initialized and ready
}
// 10 VUs are running, 90 are initialized but idle
Dynamic Scaling
Via API, you can scale vus up to maxVUs:
// Start: vus=10, maxVUs=100
// API request: vus=50
// Result: 40 additional VUs become active (total 50 active)
MaxVUs Constraints
You can increase maxVUs via API but cannot decrease it below the initial configuration value.
// Initial config
{ vus: 10, maxVUs: 100 }
// ✓ Valid via API: increase maxVUs
{ vus: 50, maxVUs: 200 }
// ❌ Invalid via API: decrease maxVUs below initial
{ vus: 10, maxVUs: 50 } // Error! Cannot go below 100
Duration Behavior
Finite duration:
{ duration: '30m' }
// Test runs for 30 minutes then stops
// Cannot change duration via API
Infinite duration:
{ duration: '0' }
// Test runs until you stop it (Ctrl+C or API)
// No automatic stop
Pause Behavior
When paused:
- Running iterations complete
- No new iterations start
- VUs remain allocated
- Test timer continues (for non-zero duration)
// Timeline with pause:
// 0-5m: Running normally
// 5-10m: Paused (no new iterations)
// 10-30m: Running normally
// If duration is 30m, test still ends at 30m total
Not Distributable
The externally-controlled executor does NOT support distributed execution (execution segments). It can only run on a single k6 instance.
Common Patterns
Integration with Monitoring
#!/bin/bash
# Scale based on response time
while true; do
# Get current p95 from your monitoring system
p95=$(curl -s http://monitoring.example.com/api/p95)
if [ "$p95" -lt 1000 ]; then
# Response time good, increase load
curl -X PATCH http://localhost:6565/v1/status \
-H 'Content-Type: application/json' \
-d '{"data":{"attributes":{"vus":50}}}'
else
# Response time degrading, reduce load
curl -X PATCH http://localhost:6565/v1/status \
-H 'Content-Type: application/json' \
-d '{"data":{"attributes":{"vus":20}}}'
fi
sleep 30
done
Manual Exploratory Testing
import http from 'k6/http';
import { sleep } from 'k6';
export const options = {
scenarios: {
manual: {
executor: 'externally-controlled',
vus: 0, // Start with no load
maxVUs: 500, // Allow scaling up to 500
duration: '0', // Run indefinitely
},
},
};
export default function () {
http.get('https://test.k6.io/api');
sleep(1);
}
Then manually control:
# Start at 10 VUs to warm up
curl -X PATCH http://localhost:6565/v1/status -d '{"data":{"attributes":{"vus":10}}}'
# Observe for 2 minutes...
# Scale to 50 VUs
curl -X PATCH http://localhost:6565/v1/status -d '{"data":{"attributes":{"vus":50}}}'
# If system handles it well, scale to 100
curl -X PATCH http://localhost:6565/v1/status -d '{"data":{"attributes":{"vus":100}}}'
Dashboard Integration
// React/Node.js dashboard example
const scaleVUs = async (vuCount) => {
await fetch('http://localhost:6565/v1/status', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
data: {
attributes: {
vus: vuCount
}
}
})
});
};
const togglePause = async (paused) => {
await fetch('http://localhost:6565/v1/status', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
data: {
attributes: {
paused: paused
}
}
})
});
};
Validation
Valid Configurations
// ✓ Minimal config
{
executor: 'externally-controlled',
vus: 1,
maxVUs: 10,
duration: '10m',
}
// ✓ Infinite duration
{
executor: 'externally-controlled',
vus: 0,
maxVUs: 100,
duration: '0',
}
// ✓ maxVUs defaults to vus
{
executor: 'externally-controlled',
vus: 50,
duration: '1h',
// maxVUs automatically set to 50
}
Invalid Configurations
// ❌ Negative VUs
{
vus: -10, // Error!
}
// ❌ vus > maxVUs
{
vus: 100,
maxVUs: 50, // Error!
}
// ❌ Negative duration
{
duration: '-5m', // Error!
}
// ❌ Duration not specified
{
vus: 10,
maxVUs: 50,
// duration: ??? // Error!
}
// ❌ Using gracefulStop
{
vus: 10,
duration: '10m',
gracefulStop: '30s', // Error! Not supported
}
Metrics
Standard k6 metrics are emitted:
iterations - Total completed iterations
iteration_duration - Time per iteration
vus - Current number of active VUs (changes via API)
vus_max - Current maximum VUs (matches maxVUs config)
Best Practices
- Set high maxVUs: Allow plenty of headroom for scaling
- Start low: Begin with few VUs and scale up as needed
- Monitor carefully: Watch metrics when scaling to avoid overload
- Use infinite duration for exploration:
duration: '0' for manual control
- Secure the API: Restrict access to the REST API in production
- Gradual scaling: Avoid jumping directly from 10 to 1000 VUs
- Test API integration: Ensure your control logic works before production
- Have a plan: Define scaling rules/criteria before running the test
Limitations
Key limitations of the externally-controlled executor:
- No distributed execution support
- Cannot use
gracefulStop
- Cannot decrease
maxVUs below initial value
- Cannot change
duration once test starts
- Cannot pause before test starts (use
--paused flag instead)
See Also