Extracting dynamic values from responses such as access tokens and CSRF tokens, managing the cookie jar and session state per virtual user, and using http.batch for efficient parallel requests.

In episode 4 you wrote realistic HTTP requests: bearer token headers, cookies, JSON payloads, and two layers of quality control. But there's one thing we still treated as static: values like tokens and cookies that you wrote by hand. Real applications don't work that way.
Imagine entering a building. Episode 4 trained you to show an access card; episode 5 teaches you how to obtain that card itself — and why the same card must not be used by everyone. Three core capabilities you'll master: storing dynamic values from responses (tokens, CSRF, cookies), managing sessions inside each virtual user, and making many parallel requests with http.batch(). The script at the end of the episode is a load that looks much more like real users.
The most common flow in load testing is login first, then call the API — the token can't be hardcoded, it must be taken from the login response while the test runs. k6 provides res.json() to parse the JSON body, and the res.json("access_token") variant to directly grab a single field:
import http from "k6/http";
import { check } from "k6";
export default function () {
const res = http.post("https://api.example.com/login", JSON.stringify({
email: "user@example.com",
password: "rahasia123",
}), {
headers: { "Content-Type": "application/json" },
});
const token = res.json("access_token");
check(res, {
"login sukses": (r) => r.status === 200,
"token tersedia": (r) => typeof res.json("access_token") === "string",
});
}Two forms of res.json() you should get to know. res.json("access_token") fetches a single field with a selector — the selector can be a dot path like res.json("data.access_token") for nested objects. Meanwhile, res.json().access_token parses the entire body then accesses the field via JavaScript object syntax — more flexible when you need several fields at once. Pick one and use it consistently; consistency makes scripts easy for teammates to read. The reuse pattern — the extracted token is used in the next request — you'll see in full in the complete scenario at the end of the episode.
Not all tokens come from JSON. Classic web applications embed a CSRF token in the form page — its value is dynamic, generated per visit, and must be sent back when the form is submitted. Hardcoding this value is a fatal mistake: the second iteration will fail because the token is already stale. k6 provides res.html() which returns an HTML object with a jQuery-like API — queried with .find() and its attributes read with .attr():
import http from "k6/http";
export default function () {
const page = http.get("https://api.example.com/transfer");
const csrf = page.html()
.find('meta[name="csrf-token"]')
.attr("content");
const res = http.post("https://api.example.com/transfer", JSON.stringify({
amount: 50000,
}), {
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrf,
},
});
}The "grab from the page, send back" pattern is the essence of the token dance — the sequence of dynamic value exchanges that must be replicated exactly like a browser. If your CSRF is stored in a hidden input, change the selector to .find('input[name="csrf_token"]').attr("value"). The key: always ask where does the browser get this value, then imitate that flow.
Tip
HTML parsing in k6 uses CSS selectors. For tokens embedded in inline JavaScript or dynamically constructed values, regex is often the more reliable way out. Make sure the extracted value is identical to what the browser sends before you trust the test results.
In episode 4 you learned k6's cookie jar is per-VU — each virtual user has its own cookie store, like a different browser user. This episode adds explicit control via http.cookieJar():
import http from "k6/http";
export const options = {
cookies: {
locale: "id-ID",
},
};
export default function () {
const jar = http.cookieJar();
jar.set("https://api.example.com", "theme", "dark");
const cookies = jar.cookiesForURL("https://api.example.com");
console.log("Cookie yang tersimpan:", Object.keys(cookies));
http.get("https://api.example.com/home");
}Three things happen: options.cookies seeds default cookies for all VUs from birth; jar.set(url, name, value) writes a cookie to the active VU's jar; and jar.cookiesForURL(url) reads all cookies valid for a URL — so you can make sure the session cookie is in place before important requests. Why do you need explicit control when k6 is already automatic? Because there are scenarios where automatic isn't enough: cookies that must be derived from responses, cookies rotated mid-test, or sessions you want to reset to simulate new users. The cookie jar is state — and state is the thing that most often separates amateur scripts from professional ones.
The question we deferred earlier: must we log in on every iteration? The pattern that is most often correct is login once, keep using it within a single VU — measuring /login when your target is /profile only adds noise. The problem: variables inside the default function are reset on every iteration. The solution: store state outside the function, keyed by __VU so each VU has its own slot:
import http from "k6/http";
const sessions = {};
export default function () {
if (!sessions[__VU]) {
sessions[__VU] = { token: null };
}
if (!sessions[__VU].token) {
const login = http.post("https://api.example.com/login", JSON.stringify({
email: "user@example.com",
password: "rahasia123",
}), {
headers: { "Content-Type": "application/json" },
});
sessions[__VU].token = login.json("access_token");
}
http.get("https://api.example.com/profile", {
headers: {
Authorization: `Bearer ${sessions[__VU].token}`,
},
});
}This pattern is important and often misunderstood. Module-level variables in k6 are shared across all VUs — storing the token in a single global variable means VU-1 could overwrite VU-2's token. With an object keyed by __VU, each virtual user has an independent session that lasts its whole life — the most accurate simulation of users who log in once then carry on. This pattern will return in a cleaner form in episode 6 (reusable functions) and episode 8 (data per user).
A single modern web page can trigger a dozen API requests at once. Writing them sequentially makes the VU wait for each response one by one — not the behavior of a browser, and the test duration balloons. http.batch() sends several requests in parallel in a single call:
import http from "k6/http";
import { check } from "k6";
export default function () {
const token = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhcm1hbiJ9.tanda-tangan";
const responses = http.batch([
{ method: "GET", url: "https://api.example.com/profile" },
{ method: "GET", url: "https://api.example.com/orders" },
{
method: "GET",
url: "https://api.example.com/cart",
headers: { Authorization: `Bearer ${token}` },
},
]);
check(responses[0], { "profil 200": (r) => r.status === 200 });
check(responses[1], { "orders 200": (r) => r.status === 200 });
check(responses[2], { "cart 200": (r) => r.status === 200 });
}batch accepts an array of request objects (method, url, body, headers) or tuple pairs, and returns an array of responses in the same order as the input — so responses[0] is the reply to the first request. All requests are sent simultaneously, measured as one wave, yet still recorded as separate HTTP metrics with the built-in url tag. One trap: tokens are not automatically inserted inside batch — send the Authorization header explicitly per request, exactly like the example above.
Tie all the layers together into one scenario that imitates a user's journey: log in once (per-VU state), then load a page that fires several parallel APIs:
import http from "k6/http";
import { check } from "k6";
const sessions = {};
export const options = {
vus: 20,
duration: "1m",
thresholds: {
http_req_duration: ["p(95)<500"],
},
};
export default function () {
if (!sessions[__VU]) {
const login = http.post("https://api.example.com/login", JSON.stringify({
email: "user@example.com",
password: "rahasia123",
}), {
headers: { "Content-Type": "application/json" },
});
sessions[__VU] = { token: login.json("access_token") };
}
const token = sessions[__VU].token;
const responses = http.batch([
{ method: "GET", url: "https://api.example.com/dashboard", headers: { Authorization: `Bearer ${token}` } },
{ method: "GET", url: "https://api.example.com/notifications", headers: { Authorization: `Bearer ${token}` } },
{ method: "GET", url: "https://api.example.com/activities", headers: { Authorization: `Bearer ${token}` } },
]);
check(responses, {
"semua endpoint 200": (rs) => rs.every((r) => r.status === 200),
"dashboard cepat": (rs) => rs[0].timings.duration < 400,
});
}Note rs.every(...) — a check can read the whole response array at once. This scenario measures the load of the page as a whole: login is rare, parallel requests dominate, per-VU sessions are preserved. This is the difference between measuring an endpoint and measuring the user experience.
__VU key. VUs overwrite each other's tokens, sessions cross over, test results become garbage./login when your target is another endpoint makes the load disproportionate.http.get for requests that are actually parallel. The simulation is slower than reality and the test duration balloons.Authorization header inside http.batch(). Every batch request needs its own header — there's no automatic inheritance.In this episode 5 you leveled up from "sending requests" to "simulating users":
res.json() and res.json("access_token") for tokens from JSON, res.html().find(...).attr(...) for CSRF from HTML.options.cookies for default seeding, http.cookieJar() for writing and reading cookies per VU.__VU-keyed object pattern so login happens only once and the session survives across iterations.http.batch() to imitate the load of modern web pages and cut down test duration.Save the last example as ep5-session.js and run it with k6 run ep5-session.js. But one problem is starting to feel real: the script files are getting longer and much is being repeated. In episode 6 we'll sort that out — Script Modularization & Reusable Functions: splitting scripts into multiple files, building helper functions for requests and auth, and designing scenarios that can be reused. See you in episode 6!