The mental model
LoadRunner is three programs — VuGen writes the script, Controller runs the scenario, Analysis reads the results. JMeter is one: the test plan holds the script and the load profile, and the HTML dashboard is generated from the results file afterwards. k6 is a CLI and a JavaScript file: the script is the scenario, and the summary prints when it finishes.
The other shift is order. In VuGen a correlation is registered before the request that produces the value (
web_reg_save_param then web_submit_data). In JMeter the extractor is a child of the sampler whose response it reads; in k6 you read res.body after the call. Same idea, opposite direction on the page.
| LoadRunner | JMeter | k6 |
|---|---|---|
| vuser_init() | setUp Thread Group (runs once, before) — or the first steps of the Thread Group with a Once Only Controller | export function setup() — runs once, its return value is passed to every VU |
| Action() | Thread Group — the loop body; one iteration per loop | export default function () — one call per iteration |
| vuser_end() | tearDown Thread Group | export function teardown(data) |
| Multiple Actions, weighted | Several Thread Groups, or a Throughput Controller per action with percent execution | Several scenarios in options.scenarios, each with its own exec function — there is no weighting field; the weight is each scenario's own vus or arrival rate |
| Controller scenario (Vusers, ramp, duration) | Thread Group: threads, ramp-up, loop count / scheduler duration | options.stages, or a ramping-vus executor |
| Goal-oriented scenario (hits/sec) | Constant Throughput Timer, or Concurrency Thread Group + Throughput Shaping Timer (plugins) | constant-arrival-rate / ramping-arrival-rate executor — this is native in k6 and better than either LoadRunner or JMeter at it |
| Run-time settings → Iterations | Loop Count on the Thread Group | iterations in the executor, or per-vu-iterations |
Requests
| LoadRunner | JMeter | k6 |
|---|---|---|
| web_url() | HTTP Request, method GET | http.get(url, params) |
| web_submit_data() | HTTP Request, POST, parameters in the body table (ITEMDATA → one row each) | http.post(url, { field: value }, params) — an object body is form-encoded |
| web_custom_request() | HTTP Request, any method, Body Data — the raw Body= string goes in verbatim | http.request(method, url, body, params) |
| web_submit_form(), web_link(), web_image() | Does not map. These are HTML-mode calls that find the target in the previous response. Re-record in URL mode, or find the underlying request in the recording log and write it as an HTTP Request | Does not map — same reason. parseHTML(res.body) can find the link, but you are rewriting, not converting |
| web_add_header() / web_add_auto_header() | HTTP Header Manager — as a child of one sampler, or at Thread Group scope for auto headers | params.headers = {…}, or set once in a shared object |
| web_set_user() | HTTP Authorization Manager (Basic/Digest); for NTLM/Kerberos see the manager's mechanism field | Put credentials in the URL, or params.auth — documented values are basic, digest and ntlm |
| web_set_sockets_option / web_set_max_html_param_len | Mostly not needed; response size limits live in jmeter.properties | Not needed |
| web_concurrent_start() … web_concurrent_end() | Parallel Controller (plugin), or HTTP Request "Retrieve All Embedded Resources" with a parallel download count when the group is page assets | http.batch([...]) — native |
| web_set_timeout() | HTTP Request → Advanced → connect / response timeouts | params.timeout |
| Run-time setting: download non-HTML resources | HTTP Request → "Retrieve All Embedded Resources" (plus a URL-must-match filter) | Not supported — k6 does not parse pages for assets. Request them explicitly, or accept that k6 measures the API and not the page |
| Run-time setting: simulate new user each iteration | HTTP Cookie Manager → "Clear cookies each iteration" (and Cache Manager likewise) | Default behaviour — k6 resets the per-VU cookie jar each iteration; noCookiesReset: true keeps it |
Correlation — the part that is the actual work
| LoadRunner | JMeter | k6 |
|---|---|---|
| web_reg_save_param("p", "LB=…", "RB=…") | Boundary Extractor (left/right boundary) — child of the sampler; variable ${p} | Slice res.body: const p = res.body.substring(res.body.indexOf(lb) + lb.length).split(rb)[0], or a regex |
| web_reg_save_param_regexp("RegExp=…") | Regular Expression Extractor; template $1$; match no. 1 | const p = res.body.match(/…/)[1] |
| web_reg_save_param_json("QueryString=$.a.b") | JSON Extractor (JSONPath) or JSON JMESPath Extractor | res.json('a.b') — k6 supports a dotted selector natively |
| web_reg_save_param_xpath() | XPath2 Extractor (needs the response to be XML/XHTML; tick "Use Tidy" for real-world HTML) | parseHTML(res.body).find('css selector') — k6 has CSS selectors, not XPath |
| "ORD=ALL" → p_count, p_1, p_2… | Match No. -1 → ${p_matchNr}, ${p_1}, ${p_2}… — same shape | [...res.body.matchAll(/…/g)].map(m => m[1]) — an array |
| "ORD=n" / lr_paramarr_random | Match No. n; random: Match No. 0 | arr[n-1]; random: arr[Math.floor(Math.random()*arr.length)] |
| "NotFound=warning" / "NotFound=error" | Default Value field (the variable is set to it when nothing matches); to fail, add a Response Assertion on the variable | Check the value: check(p, { 'found': v => v !== undefined }); a missing match does not fail the request by itself |
| "SaveLen", "SaveOffset" | No field — use a regex that captures the exact span | String slicing |
| Automatic correlation rules (the VuGen library) | Nothing built in. Every extractor is written by hand, once. That is the job the £250 review scopes | Nothing built in — same |
| Registration before the request | Extractor is a child of the request — it reads that sampler's response | Code after the call — reads res |
Checks, transactions, timing
| LoadRunner | JMeter | k6 |
|---|---|---|
| web_reg_find("Text=…") | Response Assertion → Text Response → Contains | check(res, { 'has text': r => r.body.includes('…') }) |
| web_reg_find("Fail=NotFound") | Same — an assertion failing marks the sample failed | A failed check does not fail the test; add a threshold on checks, or fail() |
| web_reg_find("SaveCount=n") | Regex Extractor with Match No. -1, then assert on ${n_matchNr} | res.body.match(/…/g).length |
| web_global_verification() | Response Assertion at Thread Group / Test Plan scope — applies to every sampler below it | A helper called after every request, or a threshold on a custom metric |
| HTTP status checks (implicit in LR) | Implicit — a non-2xx/3xx is a failed sample unless "Ignore status" is set | Not implicit — a 500 is a successful request in k6 unless you check(res, { 'status 200': r => r.status === 200 }). The most common migration surprise |
| lr_start_transaction("T") … lr_end_transaction("T", LR_AUTO) | Transaction Controller "T" with "Generate parent sample" ticked — the transaction is one row in the report | group('T', () => { … }) — reported as group_duration, tagged by name |
| lr_end_transaction("T", LR_FAIL) | Any failed sampler inside fails the transaction | Groups do not fail; failures are per-check. Use a threshold: 'checks{group:::T}': ['rate>0.99'] — the group tag is the name with a :: prefix, so three colons in the selector |
| lr_think_time(n) | Constant Timer (n ms) as a child of the next sampler, or Uniform Random Timer for a spread | sleep(n) — seconds |
| Run-time settings → Think time (multiply / random %) | Uniform Random Timer, or ${__Random(…)} in a Constant Timer | sleep(Math.random() * 2 + 1) |
| Run-time settings → Pacing | Constant Throughput Timer (per thread), or Flow Control Action "pause" at the end of the loop | An arrival-rate executor is the right tool; otherwise sleep for the remainder of the interval |
| lr_rendezvous() | Synchronizing Timer — holds threads until n arrive | No equivalent. Nothing in the options or scenarios reference synchronises VUs; the closest is a constant-vus executor starting everyone at once |
| Think time excluded from transaction time (Analysis option) | Transaction Controller → untick "Include duration of timer and pre/post processors" | group_duration is "the total time to execute the group function", so a sleep inside the group is counted; http_req_duration never includes it. Keep sleep outside the group |
Parameters and data
| LoadRunner | JMeter | k6 |
|---|---|---|
| .prm parameter from a .dat file | CSV Data Set Config — filename, variable names, delimiter | new SharedArray('name', () => open('file.csv').split('\n').slice(1).map(…)) — loaded once, shared by all VUs |
| Select next row: Sequential | Sharing mode "Current thread" (each thread walks the file from the top) | data[__ITER % data.length] |
| Select next row: Unique | Sharing mode "All threads" — each thread takes the next unread line | data[(__VU - 1) % data.length] per VU, or exec.scenario.iterationInTest (from k6/execution) for a number unique across every VU and iteration in the run |
| Select next row: Random | Sharing mode "Current thread" + ${__RandomFromMultipleVars}, or a Random CSV Data Set (plugin) | data[Math.floor(Math.random() * data.length)] |
| Update value on: Each iteration / Each occurrence / Once | CSV Data Set advances once per iteration (when the element is reached); "Once" = read it in a setUp group into a property | Where you index it — top of the default function = per iteration; inside setup() = once |
| When out of values: Cycle / Abort Vuser | Recycle on EOF = True / False; Stop thread on EOF | Modulo for cycle; exec.test.abort() to stop |
| lr_eval_string("{p}") | ${p} anywhere in a field | Template literal `…${p}…` |
| lr_save_string("v", "p") | User Defined Variables (static), or vars.put("p", "v") in JSR223 | const p = 'v' |
| lr_save_int / lr_save_datetime | ${__intSum}, ${__time(yyyy-MM-dd)} | Plain JS; new Date().toISOString() |
| Unique number / Vuser ID parameter | ${__threadNum}, ${__counter(FALSE)}, ${__UUID} | __VU, __ITER, scenario.iterationInTest, uuidv4() from k6-utils |
| Date/time parameter with format and offset | ${__timeShift(yyyy-MM-dd,,P1D,,)} | new Date(Date.now() + 86400000) and format by hand |
Logic and code
| LoadRunner | JMeter | k6 |
|---|---|---|
| if / else in C | If Controller (JavaScript or Groovy expression: ${__groovy(vars.get("p") == "x")}) | Plain JS |
| for / while | Loop Controller (fixed) / While Controller (condition) | Plain JS |
| switch on a parameter | Switch Controller — by index or by name | Plain JS |
| Custom C function, string handling (sprintf, strtok…) | JSR223 Sampler / Pre / PostProcessor in Groovy — hand-ported, and priced separately in the review | A JS function — hand-ported, same |
| lr_output_message / lr_error_message | log.info(…) in JSR223; Debug Sampler for variables | console.log(…) |
| lr_exit(LR_EXIT_VUSER, LR_FAIL) | Flow Control Action → Stop Thread; a failed assertion with "Stop thread on error" on the Thread Group | exec.test.abort() (whole test) or return from the iteration |
| lr_load_dll() / external C libraries | Does not map — rewrite in Groovy or call a Java library | Does not map — k6 has no FFI; xk6 extensions in Go if it truly must |
Results and reports
- Analysis sessionJMeter:
jmeter -n -t plan.jmx -l r.jtl -e -o report/. k6: end-of-test summary,--out json, orhandleSummary() - Transaction percentilesJMeter dashboard / Aggregate Report (p90/95/99). k6:
http_req_duration p(95),group_duration - SLA rulesJMeter: none built in — a gate in CI (perf-reporting). k6:
options.thresholds, and a breached threshold fails the run - Online monitorsJMeter: Backend Listener → InfluxDB/Grafana. k6:
--outto Prometheus/InfluxDB/Grafana Cloud - Open a .jtl without the GUIThe free viewer at tools.martkos-it.co.uk/report — also reads k6 JSON
Does not carry across — say so before you start
Protocols other than Web – HTTP/HTML. TruClient and DevWeb are different scripting models, not different syntax. Citrix, SAP GUI, RTE, RDP, Oracle NCA and the like drive a client, not a protocol; JMeter and k6 have nothing to convert them to. IP spoofing — JMeter has a per-sampler source address, k6 has none. Network virtualisation and Diagnostics — neither. VTS (Virtual Table Server) — JMeter: the Inter-Thread Communication plugin or an external store; k6: an external store —
k6/x/redis ships a Redis client, marked experimental. Automatic correlation — nothing in either tool finds session values for you; a recording replays the values it recorded and fails on the second run until every one has an extractor. That, not the syntax, is where the migration effort goes — and it is what the £250 review counts before anyone quotes you.