Samplers send requests. Logic Controllers decide which requests get sent, in what order, and how many times. Without logic controllers, every JMeter thread executes the same sequence of samplers in the same order on every iteration. With logic controllers, you can branch on conditions, weight traffic across scenarios, loop specific sections, and group requests into measurable transactions — all without writing a line of code.
The controller model
Logic controllers work as containers. You add samplers (and other elements) inside them, and the controller decides whether and how to execute its children. Right-click any logic controller to add child samplers.
The tree structure in JMeter's GUI directly represents the controller hierarchy:
Thread Group
├── HTTP Request — "GET /homepage" ← always runs
├── If Controller (condition: user is logged in)
│ └── HTTP Request — "GET /dashboard" ← runs only when condition is true
└── Loop Controller (5 loops)
└── HTTP Request — "GET /products" ← runs 5 times per iteration
Loop Controller
Runs its children N times within a single thread iteration.
When to use it: repeating a specific action (polling, paginating, adding N items to a cart) without increasing thread count or the Thread Group's loop count.
Thread Group (1 user, 1 loop)
└── HTTP Request — "GET /login"
Loop Controller (count: 10)
└── HTTP Request — "GET /product/${productId}"
HTTP Request — "POST /checkout"
This simulates a user who logs in once, browses 10 products, then checks out — all in one Thread Group iteration. The Loop Controller's loop count is independent of the Thread Group's loop count.
If Controller
Runs its children only when a condition evaluates to true. The condition is a Groovy expression that returns true or false.
Condition: ${__groovy("${response_code}" == "200")}
Reference any JMeter variable in the condition. The __groovy function evaluates the expression as Groovy code and returns the boolean result.
Common patterns:
# Run follow-up only if login succeeded
${__groovy("${login_status}" == "200")}
# Run only for admin users
${__groovy("${user_role}" == "admin")}
# Run on even iterations only
${__groovy(${__counter(FALSE,)} % 2 == 0)}
The Interpret Condition as Variable Expression checkbox changes evaluation mode — when checked, the condition is a simple variable reference that must contain true or false as a string, without the __groovy wrapper. The Groovy mode is more powerful.
While Controller
Runs its children repeatedly while a condition is true. Use it for polling — wait until a status endpoint returns a specific value.
While Controller — condition: ${__groovy("${job_status}" != "completed")}
├── HTTP Request — "GET /api/jobs/${jobId}/status"
│ └── JSON Extractor: extract $.status → job_status
└── Constant Timer — 2000ms (wait before polling again)
This simulates a user who submits a job and then polls every 2 seconds until it completes. The While Controller exits as soon as the Groovy condition returns false.
Set a sensible exit condition — if the condition never becomes false (for example, the API never returns completed), the While Controller runs forever, blocking that thread.
Switch Controller
Routes execution to exactly one child based on a switch value. The switch value is compared against each child controller's or sampler's name.
Switch Controller — Switch Value: ${user_type}
├── HTTP Request — (name: "admin") → admin-specific flow
├── HTTP Request — (name: "member") → member flow
└── HTTP Request — (name: "guest") → guest flow
If ${user_type} is "admin", only the sampler named "admin" runs. If no match is found, the first child runs as a default.
Switch Controller is cleaner than nested If Controllers when routing between three or more exclusive paths.
Throughput Controller
Runs its children a specified percentage of the time — or a fixed number of times per test run.
Percentage mode: set to 30%, and 30% of thread iterations execute the children. The remaining 70% skip them.
Total executions mode: set to 100, and the children run exactly 100 times across all threads for the entire test, regardless of total iterations.
Logic Controllers — when to use each
Loop Controller
Repeat a block N times per iteration
Browse N products before checkout
Poll an endpoint N times
Independent of Thread Group loop count
If Controller
Conditional execution via Groovy
Run follow-ups only on HTTP 200
Route by extracted user role
Skip flows that don't apply to this user
Throughput Controller
Weighted scenario mixing
60% browse, 30% search, 10% checkout
Percentage or fixed count mode
Simulates realistic user distribution
Transaction Controller
Groups samplers as a named transaction
Reports aggregate timing for the group
Measures end-to-end flow duration
Appears as single entry in Aggregate Report
Transaction Controller
The Transaction Controller groups a set of samplers and reports their combined execution time as a single transaction. It does not control which samplers run — it is a measurement wrapper.
Transaction Controller — "User Login Flow"
├── HTTP Request — "GET /login-page"
├── HTTP Request — "POST /api/auth/login"
└── HTTP Request — "GET /api/user/profile"
In the Aggregate Report, "User Login Flow" appears as a single row with the sum of all three request times. This is how you measure end-to-end SLAs: "the login flow must complete in under 3 seconds" is a Transaction Controller assertion, not a per-sampler assertion.
Check Generate parent sample to create a parent result entry in the JTL file. Check Include duration of timer and pre-post processors in generated sample to include think time in the transaction duration measurement.
Once Only Controller
Runs its children exactly once per thread, on the first iteration only. Every subsequent iteration skips it.
Canonical use case: login. A user logs in once and reuses the session across all iterations.
Thread Group (10 users, 10 loops)
├── Once Only Controller
│ └── HTTP Request — "POST /api/auth/login" ← runs once per VU
└── HTTP Request — "GET /api/dashboard" ← runs 10 times per VU
Without Once Only Controller, the login request runs on every iteration — 10 × 10 = 100 login requests for a 10-user, 10-loop test. With it: 10 login requests (once per user) and 100 dashboard requests.
Module Controller and Include Controller
Module Controller references a Test Fragment defined elsewhere in the test plan. Test Fragments are reusable blocks of samplers that do not execute on their own — they are only executed when referenced by a Module Controller. Useful for extracting common flows (authentication, teardown) into one definition and reusing them from multiple Thread Groups.
Include Controller loads an external .jmx file at runtime and executes its contents. This enables modular test plan composition — maintain separate .jmx files for each user flow and compose them in a master test plan.
⚠️ Common mistakes
- Infinite While Controller with no exit condition. A While Controller whose condition never becomes false runs forever, holding one thread hostage for the rest of the test. Always set a maximum iteration guard:
${__groovy("${job_status}" != "completed" && ${__counter(FALSE,)} < 20)}— the counter ensures the loop exits after 20 iterations regardless. - Using Loop Controller to set the Thread Group loop count. Loop Controller and Thread Group's Loop Count are different things. Loop Controller repeats a subset of the sampler tree; Thread Group's Loop Count repeats the entire sampler tree. Nesting a Loop Controller inside a Thread Group multiplies iterations: 5-loop Thread Group × 3-loop Loop Controller = 15 executions of the inner sampler.
- Forgetting Transaction Controller only measures — it does not control. All samplers inside a Transaction Controller always execute. If you want conditional execution, use an If Controller inside the Transaction Controller, not the Transaction Controller itself as a conditional gate.
🎯 Practice task
Build a test plan that uses three different logic controllers.
- Start from
first-test.jmx. Add a Loop Controller with count 3 around the product page request. Run with 1 user and confirm the product request appears 3 times in View Results Tree. - Add a Once Only Controller containing a "POST /api/auth/login" request (targeting
test.k6.io/auth/token/login/with credentials). Set Thread Group to 1 user, 5 loops. Confirm the login request appears only once in results (not 5 times). - Add a Transaction Controller named "Browse and Buy" wrapping two samplers. Check "Generate parent sample". Run and confirm "Browse and Buy" appears as a single entry in the Summary Report listener, alongside the individual sampler entries.
- Add a Throughput Controller in percentage mode set to 50%. Put an HTTP request inside it. Run with 1 user, 10 loops. Count how many times the inner request ran — it should be approximately 5 (50% of 10 iterations).