Unpacking how k6 works behind the scenes: the Go engine with goja, the virtual users and iteration execution model, the test lifecycle from init to teardown, plus core components like the http module, checks, thresholds, metrics, and execution options such as stages.

In episode 1 you understood why k6 was born: answering the limitations of JMeter, Gatling, and Locust through JavaScript scripting, CI integration, and the Go engine's efficiency. Now it's time to open the hood. Episode 2 dissects the basic concepts and core architecture of k6 — because a good k6 script is born from understanding how the tool executes scripts, not from memorizing syntax.
There are three big questions we'll answer: what happens when k6 run is executed?, how does k6 manage thousands of virtual users in a single process?, and where does each component like checks, thresholds, and metrics sit in that flow?. Understand this chapter, and writing your first script in episode 3 will feel like filling in parts of a map you already hold.
The first understanding you must hold on to: k6 does NOT run on Node.js. Even though scripts are written in JavaScript, execution is handled by goja — an ECMAScript (JavaScript) engine written purely in Go and embedded inside the k6 binary.
What are the consequences for you as a script writer?
require(), no fs, path, process, or Node's own http. Imports are only allowed for modules provided by k6 (k6, k6/http, k6/ws, and others) and your own local files.const, let, arrow functions, template literals, destructuring — all work. goja keeps getting updated to follow the modern ECMAScript standard.An analogy: a k6 JavaScript script is like a recipe run by a kitchen powered by Go, not a Node.js kitchen. The recipe (JavaScript) is read the same way, but the stove, pots, and fire used are different — so some ingredients (npm libraries) are unavailable.
The central concept of k6 is the Virtual User (VU). Imagine each VU as one simulated user: it runs the script's main function repeatedly, one iteration at a time, in a loop. The execution model:
k6 run launches a number of VUs (determined by vus).This model is crucial to understand because it has implications for scripts: global variables at module level are shared across all VUs, while variables inside the default function are not shared between iterations. This is what makes data-driven testing (episode 9) and session handling (episode 6) chapters of their own — state between iterations does not stick together automatically.
import http from "k6/http";
export default function () {
// One iteration = one call of this function
http.get("https://test.k6.io");
}If k6 is run with vus: 10 and duration: "30s", then 10 VUs each call the function above repeatedly for 30 seconds — the total number of iterations depends on how fast each request completes. This is the most basic form of "load".
k6 execution follows a lifecycle flow consisting of four phases. Understanding these phases determines where a piece of code should go:
| Phase | When It Runs | Example Usage |
|---|---|---|
| init | Once, when the script is loaded, before VUs are born | Import modules, load files, set up global data |
| setup | Once, before the test begins | Prepare a token used by all VUs |
| default (VU code) | Repeatedly, once per iteration per VU | The main HTTP request under test |
| teardown | Once, after all iterations finish | Clean up resources, final report |
import http from "k6/http";
export function setup() {
// Setup phase: one-time preparation
const res = http.post("https://api.example.com/login");
return { token: res.json().token };
}
export function teardown(data) {
// Teardown phase: cleanup
console.log("Done, token used:", data.token);
}
export default function (data) {
// Default phase: the main repeated load
http.get("https://api.example.com/profile");
}The pattern above explains why the lifecycle matters: the login token only needs to be prepared once in setup, then inherited by all VUs — instead of every VU logging in by itself on every iteration (which would actually skew the login endpoint's measurement results).
The k6/http module is the library for sending HTTP requests: http.get, http.post, http.put, http.request, up to http.batch for sending many requests at once. All of your load movement goes through this module.
A check is a functional assertion: a true-or-false statement about the result of a request. For example, "the status must be 200" or "the body contains the word 'success'". Unlike throwing an error, a failed check does not stop the iteration — it is only recorded as a failure. This distinguishes data validation (checks) from fatal technical failures.
import { check } from "k6";
import http from "k6/http";
const res = http.get("https://test.k6.io");
check(res, {
"status is 200": (r) => r.status === 200,
});A threshold is a pass/fail criterion for a metric — it decides the outcome of the test evaluation based on a limit. The most common example: http_req_duration must have a p(95) below 500 milliseconds. If a threshold is violated when the test finishes, k6 exits with exit code 99 — a signal CI can use to fail the pipeline.
Every request that runs automatically produces metrics: http_req_duration (request duration), http_req_failed (error ratio), http_reqs (requests per second), http_req_waiting (waiting time), and others. These metrics can be analyzed through the summary in the terminal, or streamed to external observability systems (episode 15).
k6 scripts control load behavior through export const options. The four most fundamental options you'll encounter over and over:
export const options = {
vus: 10,
duration: "30s",
thresholds: {
http_req_duration: ["p(95)<500"],
},
};vus — the number of virtual users running simultaneously.duration — how long the test runs.stages — a ramping plan: gradually increasing and decreasing load over time, to simulate realistic traffic spikes.thresholds — pass/fail limits that fail the test via exit code 99.export const options = {
stages: [
{ duration: "1m", target: 50 }, // ramp up to 50 VU
{ duration: "3m", target: 50 }, // hold at 50 VU
{ duration: "1m", target: 0 }, // ramp down to 0
],
};Stages are the most tangible difference between static load and realistic load: production applications are rarely hit by 500 users appearing out of nowhere at once, but rather ramping up gradually. Episode 8 will dissect this configuration more deeply.
Beyond the core components, k6 opens three extension doors that will be used repeatedly throughout this series:
tps_transaksi) via k6/metrics to measure things specific to your application, not just the built-in HTTP metrics.{ method: "POST", endpoint: "/login" }) so results can be filtered and grouped in dashboards.__ENV to parameterize scripts without changing code: k6 run script.js -e BASE_URL=https://staging.example.com.The xk6 extensions (episode 19) even let you compile a custom k6 binary with additional modules — from new protocols to integrations with internal systems.
Warning
One of the most common conceptual mistakes: assuming a failed check will stop the test. A check only records. The only things that formally stop a test are a threshold (with exit code 99) or a fatal error. Designing clear checks and thresholds from the start will save you from confusion in episode 4.
In this episode 2 you've unpacked k6's architecture:
http module, check (recorder), thresholds (pass/fail evaluator), and metrics (measured data).vus, duration, stages, thresholds, up to custom metrics, tags, and environment variables.You now have a complete mental map. In episode 3 we go straight to practice: writing your first k6 script — the basic script structure with import, options, export default function, and check, running HTTP GET and POST, validating responses, using group() to break down scenarios, and seeing the actual k6 run script.js output. See you in episode 3!