Gatling can simulate more users than JMeter on the same hardware because it uses an actor-based, non-blocking architecture rather than JMeter’s thread-per-user model.

Let’s see what that looks like in practice. Imagine we want to simulate 1000 concurrent users hitting an API endpoint /users/{id}.

Here’s a JMeter test plan snippet:

<elementProp name="HTTPArgument.arguments" elementType="Arguments">
    <elementProp name="" elementType="Argument">
        <stringProp name="Argument.name">id</stringProp>
        <stringProp name="Argument.value">${__threadNum}</stringProp>
        <stringProp name="Argument.desc">User ID</stringProp>
        <stringProp name="Argument.metadata">=</stringProp>
    </elementProp>
</elementProp>
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="GET /users/{id}" enabled="true">
    <elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" enabled="true">
        <collectionProp name="Arguments.arguments">
            <elementProp name="" elementType="Argument">
                <stringProp name="Argument.name">id</stringProp>
                <stringProp name="Argument.value">${__threadNum}</stringProp>
                <stringProp name="Argument.desc">User ID</stringProp>
                <stringProp name="Argument.metadata">=</stringProp>
            </elementProp>
        </collectionProp>
    </elementProp>
    <stringProp name="HTTPSamplerProxy.domain">localhost</stringProp>
    <stringProp name="HTTPSamplerProxy.port">8080</stringProp>
    <stringProp name="HTTPSamplerProxy.protocol">http</stringProp>
    <stringProp name="HTTPSamplerProxy.contentEncoding"></stringProp>
    <stringProp name="HTTPSamplerProxy.path">/users/${__threadNum}</stringProp>
    <stringProp name="HTTPSamplerProxy.method">GET</stringProp>
    <boolProp name="HTTPSamplerProxy.follow_redirects">true</boolProp>
    <boolProp name="HTTPSamplerProxy.use_keepalive">true</boolProp>
    <boolProp name="HTTPSamplerProxy.do_ூretrieve_all_embedded">false</boolProp>
    <stringProp name="HTTPSamplerProxy.connect_timeout"></stringProp>
    <stringProp name="HTTPSamplerProxy.response_timeout"></stringProp>
</HTTPSamplerProxy>
<ThreadGroup guiclass="ThreadGroupGui" testclass="ThreadGroup" testname="Thread Group" enabled="true">
    <stringProp name="ThreadGroup.num_threads">1000</stringProp>
    <stringProp name="ThreadGroup.ramp_time">10</stringProp>
    <boolProp name="ThreadGroup.same_user_on_next_iteration">false</boolProp>
    <stringProp name="ThreadGroup.on_sample_error">continue</stringProp>
    <elementProp name="ThreadGroup.main_controller" elementType="LoopController" guiclass="LoopControlPanel" testclass="LoopController" enabled="true">
        <boolProp name="LoopController.continue_forever">false</boolProp>
        <stringProp name="LoopController.loops">1</stringProp>
    </elementProp>
</ThreadGroup>

Now, here’s the Gatling equivalent using Scala:

import io.gatling.core.Predef._
import io.gatling.http.Predef._
import scala.concurrent.duration._

class BasicSimulation extends Simulation {

  val httpProtocol = http
    .baseUrl("http://localhost:8080")
    .acceptHeader("text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
    .doNotTrackHeader("1")
    .acceptLanguageHeader("en-US,en;q=0.5")
    .acceptEncodingHeader("gzip, deflate")
    .userAgentHeader("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0")

  val scn = scenario("UserSimulation")
    .exec(http("request_1")
      .get("/users/${userId}")
      .check(status.is(200)))

  setUp(
    scn.inject(
      rampUsers(1000) during (10 seconds)
    ).protocols(httpProtocol)
  )
}

Notice how in Gatling, we define the number of users and the duration over which they ramp up, not the number of threads directly. The userId would typically be passed via a feeder, but for this example, let’s assume it’s handled.

The fundamental difference is how they manage concurrency. JMeter, by default, creates a new Java thread for each virtual user. Threads are resource-intensive; each thread has its own stack, and the operating system has to context-switch between them. When you have thousands of threads, the overhead of managing them can overwhelm the system, leading to poor performance and high CPU usage, even if the actual network requests are minimal.

Gatling, on the other hand, is built on Akka, which uses an actor model and non-blocking I/O. An actor is a lightweight concurrency primitive. Instead of one thread per user, Gatling uses a small pool of threads (often matching the number of CPU cores) and multiplexes thousands of user "sessions" across these threads. When a user session makes a network request, it doesn’t block the thread waiting for a response. Instead, it sends a message and yields control. When the response arrives, the thread picks up the message and continues the session. This drastically reduces the overhead per user, allowing Gatling to simulate far more concurrent users on the same hardware.

This architectural choice means Gatling excels at high-concurrency scenarios where the bottleneck is often the load generator itself, not the application under test. JMeter, with its thread-per-user model, can be easier to get started with for simpler tests and has a vast ecosystem of plugins. However, for sustained, high-volume load generation, Gatling’s efficiency is a significant advantage.

When you’re trying to push thousands of concurrent users, Gatling’s ability to achieve higher throughput with less resource consumption is its primary differentiator. JMeter might hit its CPU or memory limits much sooner when trying to simulate the same load.

The reporting in Gatling is also a significant strength, providing detailed, HTML-based reports that are often more insightful out-of-the-box than JMeter’s default listeners. These reports include response time distributions, throughput, and error rates, making it easier to identify performance bottlenecks.

A common misconception is that Gatling’s Scala-based DSL makes it harder to learn. While it requires familiarity with Scala, the DSL is designed for clarity and conciseness, often resulting in less code than an equivalent JMeter test plan for complex scenarios. Many common patterns, like variable injection, parameterization, and assertions, are elegantly handled.

The next step after choosing between JMeter and Gatling is often understanding how to distribute your load tests across multiple machines to simulate even higher user loads than a single machine can handle.

Want structured learning?

Take the full Jmeter course →