JMeter Tutorial for Beginners: Load Test a REST API

Learn JMeter 5.6.3 step by step: build a test plan with a thread group, HTTP requests, an assertion and think time, run it in CLI mode and read the HTML report.

Apache JMeter is an open-source Java application that sends requests to our app from many simulated users at the same time and measures the response times, the throughput and the errors. We build the test, called a test plan, in the JMeter GUI, save it as a .jmx file and run it from the command line, which also creates an HTML report.

We use JMeter to load test a web app or a REST API before a release, for example to check that the search page still answers in under a second when 50 users search at the same time. JMeter also tests other protocols, such as JDBC, JMS and FTP, but HTTP is by far the most common use.

The following example runs a saved test plan against a small recipe API in CLI mode, with the result lines as comments.

jmeter -n -t recipes-load-test.jmx -l results.jtl -e -o report
# summary =    200 in 00:00:15 =   13.2/s Avg:    11 Min:     1 Max:   350 Err:     0 (0.00%)
# results.jtl          -> one CSV line per request
# report/index.html    -> HTML dashboard with response times and throughput

Notice the 200 requests in the summary line. The plan starts 20 users, and each user runs 5 loops of 2 requests, so JMeter sends 20 x 5 x 2 = 200 requests, and none of them failed.

Next, we look at the parts of a test plan and install JMeter 5.6.3. After that, we build the plan step by step in the GUI, run it from the command line and read the HTML report.

1. How a JMeter Load Test Works

A load test checks how an app behaves when many users use it at the same time. JMeter simulates each user with one Java thread, and every thread runs the same steps of the test plan, such as “open the recipe list, wait half a second, open one recipe”.

For example, a recipe app gets most of its traffic between 6 and 8 PM, when people decide what to cook. Before a release, the team wants to know whether the recipe API still answers fast with 20 users at once, and whether any request fails under that load.

A test plan is a tree of elements. The position of an element in the tree decides which requests it applies to, so an assertion under one HTTP request checks only that request, and a timer under the thread group delays every request in the group.

Element typeWhat it doesIn our test plan
Thread GroupStarts the simulated users and sets how many loops each user runsRecipe app users, 20 threads
Config ElementHolds shared settings, such as the server name and portHTTP Request Defaults, localhost:8090
SamplerSends one request and records the result (a sample)List recipes, Open recipe
AssertionChecks the response and marks the sample as failed if the check failsBody has Tomato soup
TimerWaits before each request, like a user who reads the page firstThink time, 500 ms
ListenerShows or saves the resultsView Results Tree, Summary Report

For each request, JMeter runs the elements in a fixed order. The configuration elements apply first, the timers wait, the sampler sends the request, the assertions check the response, and the listeners get the result.

2. Installing and Starting JMeter

JMeter needs Java 8 or newer. We use JMeter 5.6.3, the current release, on JDK 25. We download the binary .zip (Windows) or .tgz (Linux, macOS) from the JMeter download page and extract it, because JMeter has no installer.

tar xzf apache-jmeter-5.6.3.tgz
cd apache-jmeter-5.6.3/bin

./jmeter --version      # prints the JMeter banner and 5.6.3
./jmeter                # opens the GUI (jmeter.bat on Windows)

The download page also lists a SHA-512 checksum for each file. We compare it with the output of sha512sum apache-jmeter-5.6.3.tgz before we extract the archive.

3. The App Under Test

A load test needs a running app. We use a small recipe API built with the JDK’s own HTTP server, so it needs no framework and starts with one command. It answers GET /recipes with all recipes and GET /recipes/2 with one recipe, and returns 404 for an unknown ID.

import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.concurrent.Executors;

void main() throws Exception {
  Map<String, String> recipes = Map.of(
      "1", "{\"id\":1,\"name\":\"Pancakes\"}",
      "2", "{\"id\":2,\"name\":\"Tomato soup\"}",
      "3", "{\"id\":3,\"name\":\"Fried rice\"}");

  HttpServer server = HttpServer.create(new InetSocketAddress(8090), 0);
  server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
  server.createContext("/recipes", exchange -> {
    String id = exchange.getRequestURI().getPath().replaceFirst("^/recipes/?", "");
    String json = id.isEmpty() ? "[" + String.join(",", recipes.values()) + "]" : recipes.get(id);
    int status = json == null ? 404 : 200;
    byte[] body = (json == null ? "{\"error\":\"not found\"}" : json).getBytes(StandardCharsets.UTF_8);
    exchange.getResponseHeaders().add("Content-Type", "application/json");
    exchange.sendResponseHeaders(status, body.length);
    try (var out = exchange.getResponseBody()) {
      out.write(body);
    }
  });
  server.start();
  IO.println("Recipe API on http://localhost:8090/recipes");
}

We start it with java RecipeServer.java and check it with curl. The server handles each request on a virtual thread, so 20 users at once are no problem for it. To test a real app instead, such as a Spring Boot REST API, we change only the server name, the port and the paths in the test plan.

java RecipeServer.java                       # Recipe API on http://localhost:8090/recipes
curl http://localhost:8090/recipes/2         # {"id":2,"name":"Tomato soup"}

4. Creating the Test Plan in the GUI

We build the plan in the GUI, because the GUI shows every option of an element with its default. Every element is added with a right-click on its parent and the Add menu, and the tree on the left shows the result.

4.1. Adding the Thread Group

We right-click Test Plan, then choose Add, Threads (Users) and Thread Group. The thread group controls the load with three values.

  • Number of Threads (users) is the number of simulated users, here 20.
  • Ramp-up period (seconds) is the time JMeter takes to start all threads, here 10. JMeter starts the threads evenly over that time.
  • Loop Count is how many times each thread runs the requests in the group, here 5.
JMeter 5.6.3 GUI with the test plan tree on the left (Recipe API load test, the thread group Recipe app users, HTTP Request Defaults, List recipes, Open recipe, Think time, View Results Tree and Summary Report) and the Thread Group panel on the right with Number of Threads 20, Ramp-up period 10 seconds and Loop Count 5.
The finished test plan in JMeter 5.6.3. The thread group Recipe app users starts 20 threads over 10 seconds, and each thread runs 5 loops.

With 20 threads and a ramp-up of 10 seconds, JMeter starts a new thread every 0.5 seconds, so the load grows step by step instead of hitting the server with 20 requests in the same millisecond. A thread that has finished its 5 loops stops, so the number of active users goes up during the ramp-up and down at the end.

Timeline from 0 to 16 seconds. Thread 1 starts at 0 s, thread 2 at 0.5 s, thread 3 at 1 s, thread 4 at 1.5 s and thread 20 at 9.5 s. Each thread runs 5 iterations, and one iteration is think time 500 ms, List recipes, think time 500 ms and Open recipe with its assertion. A bracket from 0 to 10 s marks the ramp-up with one new thread every 0.5 s, and all threads are done after about 15 s.
With a ramp-up of 10 seconds, JMeter starts one of the 20 threads every 0.5 seconds. Each thread runs its 5 loops, so the test ends after about 15 seconds.

The field Action to be taken after a Sampler error keeps its default Continue, so a failed request is recorded and the thread goes on with the next one.

4.2. Setting the Server Once With HTTP Request Defaults

Every HTTP request needs a protocol, a server and a port. Instead of typing them into each request, we add Config Element, then HTTP Request Defaults, under the thread group and set the protocol http, the server name localhost and the port 8090.

We also set Connect to 5000 ms and Response to 10000 ms on the Advanced tab. Without timeouts, a request to a server that hangs waits forever, and the test never finishes.

4.3. Adding the HTTP Requests

A sampler sends one request each time a thread reaches it. We add Sampler, then HTTP Request, twice under the thread group, and leave the server fields empty, because they come from the defaults.

NameMethodPath
List recipesGET/recipes
Open recipeGET/recipes/2

The name of a sampler is the label in every report, so we give each request a short name that says what the user does.

4.4. Checking the Response With an Assertion

By default, JMeter treats HTTP response codes from 400 to 599 as failures and every other code as a success. So a page that returns 200 with the wrong content, or an error message inside a 200 response, still counts as a success.

An assertion checks the content, so a wrong answer counts as an error. We right-click Open recipe and add Assertions, then Response Assertion, which makes the assertion apply to that request only. We name it Body has Tomato soup, keep Text Response as the field to test, choose Substring and add the pattern Tomato soup.

The pattern matching rules differ in a way that matters for JSON. Substring and Equals compare plain text, whereas Contains and Matches treat the pattern as a regular expression, so characters such as [ or . have a special meaning there.

4.5. Adding Think Time With a Timer

Real users read a page before they click the next link. Without a timer, every thread sends its next request the moment the previous one ends, which creates far more load per user than real people do.

We add Timer, then Constant Timer, under the thread group, name it Think time and set the delay to 500 ms. A timer waits before each sampler in its scope, so this timer waits before List recipes and again before Open recipe, as the timeline in section 4.1 shows.

4.6. Adding Listeners and Saving the Plan

A listener collects the results. We add Listener, then View Results Tree, and Listener, then Summary Report, under the thread group.

  • View Results Tree shows every single request with its request, response and assertion result. We use it while we build the plan, to check that each request returns what we expect.
  • Summary Report shows one row per sampler with the number of samples, the average, minimum and maximum response time, the error percentage and the throughput.

We save the plan with File, then Save Test Plan as, as recipes-load-test.jmx. The file is plain XML, so we can keep it in Git next to the app code. A quick run with the green start button in the GUI shows whether the plan works before the real load test.

5. Running the Load Test From the Command Line

The GUI uses memory and CPU to draw every result, and that work slows down the threads that send the requests. So the JMeter user manual says to use the GUI only to create and debug a test, and CLI mode (also called non-GUI mode) for the load test itself.

jmeter -n -t recipes-load-test.jmx -l results.jtl -e -o report

Each option has one job.

OptionMeaning
-nRun in CLI mode, without the GUI
-t recipes-load-test.jmxThe test plan to run
-l results.jtlThe file that gets one line per sample
-eCreate the HTML report after the test
-o reportThe folder for the HTML report, which must be empty or must not exist

While the test runs, JMeter prints a summary line every 30 seconds and a total at the end. The summary + lines cover the last interval, and the summary = line covers the whole test.

Created the tree successfully using recipes-load-test.jmx
Starting standalone test @ 2026 Oct 4 20:42:46 GMT+05:30 (1791126766978)
summary +    173 in 00:00:12 =   14.4/s Avg:    12 Min:     1 Max:   350 Err:     0 (0.00%) Active: 7 Started: 20 Finished: 13
summary +     27 in 00:00:03 =    8.5/s Avg:     7 Min:     2 Max:    31 Err:     0 (0.00%) Active: 0 Started: 20 Finished: 20
summary =    200 in 00:00:15 =   13.2/s Avg:    11 Min:     1 Max:   350 Err:     0 (0.00%)
Tidying up ...    @ 2026 Oct 4 20:43:03 GMT+05:30 (1791126783454)
... end of run

We can see the ramp-up in the first line. After 12 seconds, all 20 threads have started, 13 have finished their loops and 7 are still active. The file results.jtl is a CSV file, and each line holds the time, the label, the response code, the success flag and the response time of one request.

timeStamp,elapsed,label,responseCode,responseMessage,threadName,dataType,success,failureMessage,bytes,sentBytes,grpThreads,allThreads,URL,Latency,IdleTime,Connect
1791126770004,322,List recipes,200,OK,Recipe app users 1-1,text,true,,195,125,4,4,http://localhost:8090/recipes,316,0,297

6. Reading the Test Results

The HTML report in report/index.html is the main result of a load test. Its Statistics table has one row per sampler and a Total row, and the charts under Charts show how the response times and the throughput changed during the test.

Statistics table of the JMeter HTML report. Total row with 200 samples, 0 failures, 0.00% errors, average 11.94 ms, min 1, max 350, median 5.50, 90th percentile 20.00, 95th 31.00, 99th 319.51 ms and 14.85 transactions per second. List recipes has 100 samples with average 15.26 ms and max 350 ms, and Open recipe has 100 samples with average 8.61 ms and max 73 ms.
The Statistics table of the HTML report. All 200 requests passed, and 90% of them took 20 ms or less.

The columns answer different questions, so we read them together.

  • #Samples and FAIL show how many requests ran and how many failed. Here 200 requests ran, and none failed.
  • Average is the mean response time in milliseconds. A few slow requests pull it up, so it hides how most users felt.
  • Median and 90th pct say that half the requests took 5.5 ms or less, and 90% took 20 ms or less. Teams often write performance goals as percentiles, such as “the 90th percentile stays under 500 ms”.
  • Max is the slowest request. The 350 ms requests were the first calls of the test, as the first lines of results.jtl show, when the server JVM was still loading its classes.
  • Transactions/s is the throughput, which is the number of requests per second that the server handled.

Notice that the average of List recipes is 15.26 ms, but its median is 5 ms. The few slow first calls raise the average, which is why we look at the median and the percentiles first.

To see a failing check, we point Open recipe at /recipes/3, which returns Fried rice. The server still answers with status 200, but the assertion fails, so half of all requests count as errors.

summary =    200 in 00:00:15 =   13.4/s Avg:     9 Min:     1 Max:   357 Err:   100 (50.00%)

The failureMessage column of results.jtl tells us why each sample failed.

1791126807513,17,Open recipe,200,OK,Recipe app users 1-2,text,false,Test failed: text expected to contain /Tomato soup/,136,127,5,5,http://localhost:8090/recipes/3,17,0,0

7. JMeter FAQs

7.1. Does JMeter Need Coding?

No. We build a test plan by adding elements in the GUI, and JMeter saves it as XML. For logic that the elements cannot express, such as building a signed request, JMeter has the JSR223 elements, which run a short Groovy script.

7.2. Why Should I Not Run a Load Test in GUI Mode?

Because the GUI competes with the test threads for CPU and memory. Listeners such as View Results Tree keep every request and response in memory, so a long test in the GUI slows down or runs out of memory, and the measured response times include JMeter’s own delays. We run the load test with jmeter -n and turn the .jtl file into the HTML report.

7.3. How Many Users Can JMeter Simulate?

It depends on the machine and the test plan, so we measure it. Each simulated user is one Java thread, and JMeter must also read every response. We watch the CPU and memory of the JMeter machine during a test, and when they reach their limit, we use more machines with distributed testing, where one JMeter instance controls several others.

8. Conclusion

JMeter simulates users with threads and runs the steps of a test plan for each of them. The thread group sets the load with the number of threads, the ramp-up period and the loop count, and samplers, timers and assertions describe what one user does.

We build and debug the plan in the GUI, then run the real load test in CLI mode with jmeter -n -t plan.jmx -l results.jtl -e -o report. The HTML report shows the error rate, the percentiles and the throughput of each request.

A good first test has think time between requests, timeouts on the HTTP requests and at least one assertion on the response content, so a wrong answer shows up as an error and not as a fast success. For ideas on what to fix when the numbers are bad, read improving web application performance.

9. References

Happy Learning !!

Leave a Comment

Comments are closed.

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.