A quick summary - for the busy ones
TL;DR: For a new HTTP load-testing project, I would start with k6. But… it depends!
The first test is a short JS script. You run it from the command line with k6 installed, keep it next to the application, and review changes in a pull request. For a developer who wants to start testing an API, that is a good starting point.
Gatling would be next on my list due to its JVM provenance. JMeter on the other hand, would need a concrete justification these days: an existing suite, an integration we depend on, or a team that already works effectively with it.
That is my starting position. But choosing the easiest first script only gets us so far. The test will eventually need authentication, realistic data, several dependent requests, a meaningful load profile and a reliable way to fail a build. Someone will also have to maintain it after the original author moves on.
Let's compare the tools against that work: building a realistic HTTP test, reviewing changes and running it reliably in CI. This article compares development and execution models.
Start with an order, not a homepage
A test that repeatedly requests / tells us little about a service that processes orders.
Consider a small checkout workflow. A customer authenticates, browses products, creates an order and checks its status. The order ID comes from the response. Different customers use different accounts and carts. Some orders take longer to process than others.
Even this modest example raises questions like:
- Does each iteration log in, or does it reuse sessions?
- Are we testing more browsing through the site or order creation?
- Does an accepted order count as success, or do we wait for processing to finish?
- If a request fails, does the simulated customer retry, abandon the cart or continue?
- Are accounts and product IDs shared across generators?
Those choices affect the load shape more than the scripting language does. Logging in for every order can turn a checkout test into an authentication benchmark. Polling every few milliseconds can overwhelm a status endpoint while making order processing look like the problem.
Write the workload scenario and success criteria down before implementing them in any tool.
The examples below start with a prepared cart. POST /orders accepts its cartId and returns HTTP 201 with an id; GET /orders/{id} retrieves the order. Authentication and load configuration are omitted from these fragments. Each iteration needs its own cart.
k6: the first tool I would try
For this kind of HTTP workflow, k6 gives me the shortest path from an idea to a reviewable test.
Requests, response handling and ordinary control flow live together in a script. Extract an order ID, pass it to the next request, record a check. When the response format changes, the resulting diff can show exactly what changed in the test.
There is an important qualification: familiar JavaScript syntax does not make k6 a Node.js application. Its runtime provides its own APIs, and packages that depend on Node.js or browser APIs are not available out of the box. Check dependency compatibility before reusing your application's authentication client or SDK. The k6 module documentation explains that boundary.
For our checkout scenario, I would keep authentication and request construction in small helpers, while leaving the sequence of business steps visible in the scenario. Hiding everything behind a generic test framework would erase much of the simplicity that made k6 attractive.
Inside the scenario function, cartId comes from the test data, while params contains authentication and the JSON content-type header:
The order ID is an ordinary JavaScript variable passed to the next request.
The practical question is whether another developer can open the script and answer: what traffic are we generating, and what makes this test fail?
Gatling: a real alternative, including outside Scala
Older comparisons often reduce Gatling to "the Scala one." Its current SDKs support Java, Kotlin, Scala, JavaScript and TypeScript, so language choice alone no longer separates it neatly from k6.
For JavaScript and TypeScript, the documented setup uses Node.js and npm: install the project dependencies, then run the simulation through npx gatling run. That gives a JavaScript team a familiar project workflow. I would compare that setup directly with k6 before treating installation effort as a deciding factor. Gatling JavaScript installation guide
Gatling expresses the scenario through a DSL: requests, checks, session data, pauses and an injection profile. There is more terminology to learn, but the structure makes the workload explicit.
In our example, a response check extracts the order ID into the virtual user's session. A later request uses it. Feeders supply account or product data. The scenario describes the journey; the injection profile describes how users enter it.
In the Java DSL, a feeder supplies cartId to the session. A response check saves orderId for the next request:
The request sequence and extraction rules sit together in the scenario. #{orderId} reads the value stored in the virtual user's session.
For a JVM team, a test project using a familiar language and build setup is a concrete advantage. I would evaluate Gatling alongside k6 rather than assume that a slightly shorter JavaScript example settles the choice.
There is also a trap when writing a DSL-based test: code that constructs a scenario and code that executes for each user are different things. Authentication, data generation and custom logic must run at the intended point in the lifecycle. This is worth understanding before extracting a shared helper library.
My choice would depend on which version of the checkout test the team can read, debug and extend more reliably. Gatling's extra structure may be useful once the suite contains many related journeys.
JMeter: the workflow is what puts me off
JMeter's age is not the reason to dismiss it. The authoring and maintenance workflow is a bigger one.
A conventional JMeter plan spreads behaviour across a tree of samplers, configuration elements, controllers, extractors, timers and assertions. That representation can be convenient when exploring a test interactively. It becomes less convenient when I want to review how a branch changed or why a value is visible to one request but not another.
The saved .jmx plan is XML. It can be versioned, but a textual diff is often less direct than a change to a small script. A reviewer may need to open the plan in JMeter to reconstruct the relevant scope and execution order.
JMeter can still run in automation. In fact, its own best-practice guidance recommends CLI mode for load generation and warns against expensive listeners during the test. The GUI is for building and debugging the plan, not a requirement for running the load.
For example:
In JMeter, the same flow uses two HTTP Request samplers, with an extractor and assertions attached to them. A CSV Data Set Config supplies cartId, and an HTTP Header Manager supplies authentication and Content-Type: application/json.
| Element | Configuration |
|---|---|
HTTP Request: create_order | POST /orders, body: {"cartId":"${cartId}"} |
Response Assertion under create_order | Response code equals 201 |
JSON Assertion under create_order | Require $.id to exist |
JSON Extractor under create_order | Variable: orderId; JSONPath: $.id; match number: 1 |
HTTP Request: read_order | GET /orders/${orderId} |
Response Assertion under read_order | Response code equals 200 |
Configure the Thread Group to start the next loop iteration after a sampler error, so a failed order creation does not trigger the lookup. The extractor belongs under the creation request; the lookup is the next sampler.
These examples verify that the order can be created and retrieved. Waiting for business processing to finish would require another step.
Scripting and alternative authoring approaches can improve the maintenance story. But if we are choosing a tool for a new project, I would ask why we would need that additional layer when Gatling and k6 already make code-based authoring central to the workflow.
An existing, well-maintained JMeter suite is a different decision. Rewriting it consumes time and can quietly change the workload. I would migrate because of a concrete limitation, not because the UI looks old.
Compare the second week of work
The first request is easy in all three. A more useful comparison is what happens after the API changes.
Suppose order creation moves its identifier from id to order.id, adds a required header, and introduces a new rejection response.
| Task | JMeter's conventional plan | Gatling | k6 |
|---|---|---|---|
| Extract the new order ID | Update the response extractor | Update the response check and session assignment | Update response parsing |
| Add the header | Review the header configuration and its scope | Update protocol or request configuration | Update shared or request-specific parameters |
| Handle rejection | Review assertions and controller logic | Update checks and scenario control flow | Update checks and branching |
| Review the change | Inspect the plan diff, often with the GUI | Review the DSL code | Review the script |
All three can represent this workflow. My preference comes from how easily the team can see the consequences of a change.
The same applies to test data. A large suite needs reproducible fixtures, enough distinct accounts, and deliberate handling of data that a run consumes. A test that runs out of inventory halfway through is measuring a different workload from the one that began.
"One thousand users" is not a workload specification
This is the part I would spend the most time getting right.
A closed workload maintains a population of users. Each user performs work, waits for responses and eventually starts another iteration. If the application slows down, those users spend more time waiting, so they generate fewer new operations.
An open workload schedules new arrivals independently of how long earlier work takes. If the application slows down, outstanding work can accumulate.
Neither model is universally correct. A fixed group of employees using an internal application may behave differently from orders arriving at a public API. The model should reflect the source of demand.
Suppose we want 100 new checkout journeys per second. Setting 100 virtual users does not express that requirement. If an iteration takes two seconds, those users can only complete roughly 50 iterations per second, before allowing for additional pacing. If it grows to ten seconds, the offered rate can fall much further.
The service may appear to stabilize partly because the test has reduced the pressure on it.
Gatling documents open and closed injection profiles. k6 documents the same workload distinction. In JMeter, review the selected thread group and timer behaviour rather than assuming a thread count defines an arrival rate.
For a fair comparison, match the intended arrivals, journey mix, pacing, timeouts and retry behaviour. Equal virtual-user counts are not enough.
A generator can also fail to deliver the load
An arrival-rate configuration expresses a target. It does not guarantee the generator can sustain it.
As iterations get slower, the generator needs enough execution capacity to start new ones. In k6, dropped_iterations records iterations that were not started; the interpretation depends on the executor, and for arrival-rate executors insufficient available Virtual Users can prevent scheduled work from starting.
This can happen because the target slowed down, because capacity was undersized, or because the generator itself is constrained. Increasing VUs without investigating may simply move the bottleneck.
For every tool, monitor generator CPU, memory, network and achieved traffic. Report the intended load alongside what actually reached the service. A latency chart under half the requested load does not answer the original capacity question.
A green process exit is not enough
A performance test needs an explicit failure policy.
For checkout, I would separate request-level expectations from business completion. An HTTP request may be fast and successful while the order remains stuck in a queue. Measure order acceptance and time to the required terminal state separately.
In k6, checks record whether conditions hold; failed checks alone do not fail the test run. Thresholds define the overall pass/fail criteria and produce a non-zero exit code when they fail.
An illustrative threshold configuration could look like this:
This is a configuration fragment, not a complete test. The order request must carry the name: create_order tag, and the scenario must emit the relevant metrics. The values are example acceptance criteria, not recommended defaults. A global check-rate threshold can also hide a rare but critical failure; such checks deserve their own metric or filtered threshold.
Gatling provides aggregate assertions for test acceptance, separate from individual response checks. For example, using its Java DSL:
These assertions require a p95 response time below 500 ms for create_order and an overall failed-request percentage below 1%. Either assertion failing causes the simulation to fail.
With JMeter, explicitly configure the CI integration or result-processing step to evaluate the desired error and latency limits. Do not treat the existence of a report as evidence that the workload passed.
For each tool, deliberately violate one criterion and verify that the pipeline fails. Also require enough samples to evaluate the result: a test that stopped generating traffic must not pass on the strength of a few fast responses.
Put the right test in CI
I would not run a capacity test on every pull request against a shared environment and expect stable results.
A short smoke test runs the checkout journey under minimal load, verifying authentication, response checks and the scenario itself. It fits naturally into a pull-request pipeline. Other tests answer different questions:
Load: can the service meet its latency and error targets at expected traffic levels?
Stress: what happens when traffic exceeds those levels?
Breakpoint: how far can we increase traffic before those targets stop being met?
Soak: does sustained traffic over several hours reveal memory leaks, growing queues or resource exhaustion?
Spike: how does the service handle a sudden burst, and does it recover afterwards?
I would schedule longer runs separately in a controlled environment, with capacity and failure tests isolated from shared development workloads.
Those runs need enough metadata to compare them: application revision, test revision, generator version, target configuration, dataset and load profile. Pin plugins and extensions too.
If the environment changes between runs, a ten-percent latency difference may have little to do with the pull request. Performance gates become useful when a failure leads to an investigation people trust. Otherwise, the team learns to rerun the job until it passes.
Distributed testing is an operational choice
Once a single generator is insufficient, compare what you will actually operate and pay for.
| Tool | Route to evaluate | Work that still needs an owner |
|---|---|---|
| JMeter | Remote engines or independently orchestrated CLI runs | Engine configuration, data distribution, load allocation and result collection |
| Gatling | Gatling Enterprise for managed distributed execution | Locations, workload definition, data strategy and service cost |
| k6 | The k6 Operator on Kubernetes or Grafana Cloud k6 | Cluster operations for self-hosting, or managed-service configuration and cost |
These are deployment options, not equivalent features of each local executable. Check the boundaries between the open-source runner and the commercial service. See the documentation for JMeter remote testing, Gatling Enterprise, and distributed k6 tests.
One JMeter detail is particularly easy to miss: each remote engine runs the full test plan. A plan with 500 threads sent to four engines produces 2,000 threads, not 500 split four ways. Referenced data files also need to be made available on the engines.
Whatever the tool, decide whether generators share accounts, how their load adds up, and how results are combined. Do not average per-generator p95 values and call the result a global p95. Keep mergeable distributions or suitable underlying observations.
Browser testing belongs in a separate column
Our checkout example has so far described protocol-level traffic. That does not tell us whether the page renders quickly or whether JavaScript blocks user interaction.
k6 browser supports browser-level testing. That can be useful alongside a larger protocol workload: generate backend pressure at scale, then observe selected user journeys through an actual browser.
A recorded HTTP journey is not equivalent to running a browser. Compare browser requirements separately, including frontend metrics, supported interactions and the resources needed per session. Do not use browser-session counts and protocol-level virtual users as interchangeable units.
Connect the result to the system
The load generator can tell us that order creation became slow. Application telemetry helps explain why.
Put request rate and latency beside database latency, connection-pool saturation, queue depth and worker throughput. If order acceptance stays fast while completion time rises, a report limited to HTTP acceptance latency can miss the problem.
An existing Grafana setup makes k6 worth evaluating, but observability integration is not exclusive to it. Compare the actual output or integration you plan to use, including any edition restrictions. Use stable request names and record the test revision and run ID with the results. Keep unique run IDs out of long-lived metric labels unless you have an explicit cardinality and retention policy. Align timestamps so a latency spike can be matched with changes in the service.
What I would choose
For a new HTTP suite maintained by developers, k6 remains my first candidate. The scripting workflow makes the first useful test easy to write and the next change easy to review.
I would choose Gatling when its DSL and SDK fit the team better, particularly when performance tests will be maintained as a substantial codebase. I would implement the same representative journey before deciding; language familiarity alone is not enough.
I would keep JMeter where an existing suite, team expertise or a verified integration justifies it. For a new project, its conventional plan-authoring workflow would need to earn its place against the other two.
The evaluation does not need to start with an enormous benchmark. Build one realistic journey, change its response contract, make an acceptance criterion fail, and run it in CI. Then increase the load until either the service or the generator becomes constrained. That exercise tells you more about the tool you will live with than the length of its first example.
Reviewed by Michał Ostruszka.




