L5 · Non-functional depth
L5Core6 min read

Performance testing: k6, JMeter, Gatling

Scripting a realistic load, running it somewhere that can generate it, and reading the result without fooling yourself. k6 is the reasonable modern default; the concepts transfer to whichever your team already runs.

A performance test tells you what happens to your system under real load, and the tool you pick decides how much of that story you can actually see. Most teams inherit a tool rather than choose one, then spend months fighting its defaults instead of reading their results. This guide compares the three tools you will meet on almost any team, k6, JMeter and Gatling, and walks through scripting a realistic load test so the choice stops being theoretical.

Why the tool matters less than the plan

Before comparing tools, it helps to be clear about what a performance test is actually for. It is not a single number to put in a slide, it is evidence for a decision. Can this service handle Black Friday, will this new endpoint survive a marketing push, did last week's change quietly double response times under load.

Every tool below can answer that question. What differs is how quickly you can write the test, how well it fits your stack, and how much the report tells you once it finishes.

Pick a tool your team can maintain without you. A load test script that nobody but its author understands stops being run within a quarter, and if that script needs a fresh dataset each run, see test data management for the factory and seeding approach that keeps it from going stale.

k6: the reasonable modern default

k6 scripts are written in JavaScript, run from the command line, and generate load from a single lightweight binary rather than spinning up a JVM. That makes it the easiest of the three to bolt into a CI pipeline: install one binary, run one command, fail the build on a threshold.

Its scripting model uses virtual users executing a function in a loop, with built in support for stages that ramp load up and down. Metrics come out as JSON or can stream straight into Grafana, which is where most teams end up reading them. If your team already writes JavaScript or TypeScript, k6 is close to zero ramp up.

The tradeoff is protocol coverage. k6 is strongest on HTTP and WebSocket. Testing something like a legacy SOAP service or an FTP transfer will feel like fighting the tool rather than using it.

JMeter: mature, GUI first, still everywhere

JMeter has been the default performance tool for over two decades, and that longevity shows up as protocol support: HTTP, JDBC, JMS, LDAP, SOAP, FTP and more, each with a dedicated sampler. If your system talks a protocol from 2005, JMeter almost certainly already speaks it.

Its test plans are built in a desktop GUI as a tree of samplers, listeners and controllers, then usually exported to XML and run headless from the command line for real load. That two step workflow is JMeter's biggest strength for less technical testers and its biggest source of friction for engineers who would rather write code. Version control on an XML test plan is painful, and diffing one in a pull request tells a reviewer almost nothing.

JMeter earns its place when the system under test is protocol heavy, the team includes non-programmers who need a visual tool, or the organisation already has years of JMeter test plans it cannot justify rewriting.

Gatling: code first, with reporting that reads like a report

Gatling scripts are written in Scala, or in Gatling's own Java and Kotlin DSLs for teams that would rather avoid Scala. What sets it apart is the HTML report it produces after every run: response time distributions, percentile charts and error breakdowns, generated automatically and readable by someone who was not in the room when the test was written.

Gatling's async, non blocking engine also means a single machine can generate a large amount of load without the memory overhead JMeter carries per virtual user. For teams already comfortable in the JVM ecosystem, Gatling scripts read close to a specification of the load scenario itself, which makes them easier to review than either of the alternatives.

The cost is the learning curve. Scala is not a language most testers already know, and the DSL, while readable once learned, is not obvious on a first read the way a k6 script is.

Choosing between them

Three questions settle most of these decisions in practice:

  • What language does your team already write in day to day.
  • What protocols does the system under test actually speak.
  • Does the report need to go straight to a non-technical stakeholder, or will an engineer be reading raw metrics.

Writing a first realistic load test

A load test that only hits one endpoint with no pacing teaches you almost nothing about production behaviour, because real traffic never arrives that cleanly. The script below ramps virtual users up, holds a steady load, then ramps back down, the same shape as a marketing spike followed by a return to baseline. It targets the same kind of endpoint you would already cover in API testing and schema validation, just under load instead of a single request.

load-test.js
import http from 'k6/http'
import { check, sleep } from 'k6'

export const options = {
  stages: [
    { duration: '2m', target: 50 },
    { duration: '5m', target: 50 },
    { duration: '2m', target: 0 },
  ],
  thresholds: {
    http_req_duration: ['p(95)<500'],
    http_req_failed: ['rate<0.01'],
  },
}

export default function () {
  const res = http.get('https://staging.example.com/api/products')

  check(res, {
    'status is 200': (r) => r.status === 200,
  })

  sleep(1)
}

Two things in that script matter more than the syntax. The thresholds block turns the test into a pass or fail check a pipeline can act on, rather than a report a human has to interpret every time. And the sleep call between requests matters, because a real user pauses to read a page before clicking again, and a script with no pauses generates a request pattern no production traffic ever produces.

Reading the results without fooling yourself

The most common mistake in performance testing has nothing to do with tooling. It is reading an average response time and calling the test passed. An average of 200 milliseconds can hide a tenth of users waiting three seconds, and those are exactly the users who leave a review complaining about a slow site.

Read percentiles, not averages, and read them alongside the error rate. A service that returns errors fast will show a deceptively good response time chart, and a load test that only reports errors is missing the same story a flaky test hides: a failure that only shows up under specific, repeatable conditions.

FAQ

Questions people ask

Should I run performance tests in staging or production?

Staging first, always, because a broken load test can take down a real environment. Some teams do run controlled, low-traffic tests in production once staging results are stable, specifically to catch differences in infrastructure sizing that staging cannot reproduce.

How much load should a load test generate?

Start from real traffic data. Take your peak requests per second over the last quarter, add the growth you expect over the next one, and script for that number plus a safety margin, rather than picking a round number that feels impressive.

Can I use k6, JMeter or Gatling for API testing as well as load testing?

All three can send functional assertions alongside load, but none of them is a good substitute for a dedicated API testing tool for day to day functional coverage. Use them for load, and keep functional API tests in the tool your team already runs on every pull request.

Do I need a paid SaaS product to generate meaningful load?

No. All three tools here are free and open source, and a single reasonably sized cloud instance can generate thousands of requests per second with k6 or Gatling. Paid platforms mostly buy you distributed load generation and hosted reporting, which matter once a single machine cannot generate enough traffic for your target.