Without Logic Controllers, your test is a flat list of requests executed top to bottom. That works for simple API tests, but real user scenarios have branches, loops, conditions, and transactions. Logic Controllers give you control flow -- the same if/else, for-loop, and grouping constructs you use in programming, but applied to HTTP requests.
The Simple Controller does absolutely nothing to the execution flow. It is a folder for organizing related requests. Think of it as a comment block in code -- it groups things visually but does not change behavior. I use it to separate concerns: "Login Flow", "Product Browsing", "Checkout" sections within a Thread Group.
Thread Group
├── Simple Controller: "1. Authentication"
│ ├── GET /login-page
│ ├── POST /login
│ └── JSON Extractor: authToken
├── Simple Controller: "2. Product Browsing"
│ ├── GET /products
│ ├── GET /products/${productId}
│ └── GET /products/${productId}/reviews
├── Simple Controller: "3. Checkout"
│ ├── POST /cart
│ ├── POST /checkout
│ └── GET /order-confirmation
└── ListenersThis is the most important Logic Controller for performance testing. A Transaction Controller groups multiple requests into a single "transaction" and reports the combined time. Instead of seeing "GET /login took 200ms and POST /login took 300ms", you see "Login Flow took 500ms". This maps to business operations, which is how stakeholders think about performance.
Thread Group
├── Transaction Controller: "Login Flow" ← reports combined time
│ ├── GET /login-page (200ms)
│ ├── POST /login (300ms)
│ └── GET /dashboard (150ms)
│ # Transaction "Login Flow" = 650ms total
│
├── Transaction Controller: "Purchase Flow" ← reports combined time
│ ├── GET /products (180ms)
│ ├── POST /cart (220ms)
│ ├── POST /checkout (400ms)
│ └── GET /confirmation (100ms)
│ # Transaction "Purchase Flow" = 900ms total
│
└── Summary Report
# Shows BOTH individual requests AND transaction totals
# Login Flow: avg 650ms
# Purchase Flow: avg 900ms
# GET /login-page: avg 200ms
# etc.Check "Generate parent sample" in the Transaction Controller to show ONLY the transaction in the report (hiding individual requests). This gives a cleaner report for stakeholders. Uncheck it during debugging so you can see which request within the transaction is slow.
The Loop Controller repeats its child elements N times. This is different from the Thread Group loop count -- a Thread Group loop repeats the ENTIRE scenario, while a Loop Controller repeats only a specific section. Use case: a user logs in once, then browses 5 different products.
Thread Group (10 users, 1 loop)
├── POST /login ← runs once per user
├── Loop Controller (5 iterations)
│ ├── GET /products/random ← runs 5 times per user
│ └── Gaussian Random Timer
├── POST /logout ← runs once per user
# Total: Each user does 1 login + 5 product views + 1 logout = 7 requests
# Total requests: 10 users × 7 = 70 requests
# Compare with Thread Group loop count = 5:
# Each user does (login + product + logout) × 5 = 15 requests
# Very different behavior!The If Controller executes its children only when a condition is true. This is how you create branching scenarios -- 70% of users browse products, 30% go directly to their account page. Or: only proceed to checkout if the cart is not empty.
# Condition syntax (check "Interpret Condition as Variable Expression"):
# Check if variable equals a value:
${__groovy(vars.get("userType") == "premium")}
# Check if variable is not empty:
${__groovy(vars.get("authToken") != null && vars.get("authToken") != "NOT_FOUND")}
# Check if random number for traffic distribution:
${__groovy(new Random().nextInt(100) < 70)}
# 70% chance of executing children
# Check if response code was 200:
${__groovy(prev.getResponseCode() == "200")}Thread Group (100 users)
├── POST /login
├── JSR223 PreProcessor:
│ └── vars.put("scenario", new Random().nextInt(100).toString())
│
├── If Controller: ${__groovy(vars.get("scenario").toInteger() < 50)}
│ └── Transaction: "Browse & Purchase" ← 50% of users
│ ├── GET /products
│ ├── POST /cart
│ └── POST /checkout
│
├── If Controller: ${__groovy(vars.get("scenario").toInteger() >= 50 && vars.get("scenario").toInteger() < 80)}
│ └── Transaction: "Browse Only" ← 30% of users
│ ├── GET /products
│ └── GET /products/${productId}
│
├── If Controller: ${__groovy(vars.get("scenario").toInteger() >= 80)}
│ └── Transaction: "Account Management" ← 20% of users
│ ├── GET /account
│ └── PUT /account/settings
│
└── POST /logoutAlways check "Interpret Condition as Variable Expression" in the If Controller and use ${__groovy(...)} for conditions. The default JavaScript interpreter is slow and deprecated. Groovy conditions are compiled and cached, making them much faster in high-thread tests.
The While Controller repeats its children as long as a condition is true. Classic use case: polling an API until a job completes. "Keep checking /api/job/status until status is COMPLETED or we have checked 10 times."
Thread Group
├── POST /api/reports/generate ← start report generation
│ └── JSON Extractor: jobId
│
├── Counter: pollCount (start=0, increment=1)
├── While Controller: ${__groovy(
│ vars.get("jobStatus") != "COMPLETED" &&
│ vars.get("pollCount").toInteger() < 10
│ )}
│ ├── Constant Timer (2000ms) ← wait 2 seconds between polls
│ ├── GET /api/reports/status/${jobId}
│ └── JSON Extractor: jobStatus ← extract current status
│
├── If Controller: ${__groovy(vars.get("jobStatus") == "COMPLETED")}
│ └── GET /api/reports/download/${jobId}
# This polls every 2 seconds, up to 10 times (20 seconds max)
# Then downloads the report if it completedThe Module Controller references another controller in your test plan and executes its children. It is like calling a function -- define the login flow once, then call it from multiple places. If the login flow changes, you update it in one place.
Test Plan
├── Thread Group: "Purchase Scenario"
│ ├── Module Controller → points to "Login Module" ← reuse!
│ ├── GET /products
│ ├── POST /cart
│ └── POST /checkout
│
├── Thread Group: "Account Scenario"
│ ├── Module Controller → points to "Login Module" ← reuse!
│ ├── GET /account
│ └── PUT /account/settings
│
└── Test Fragment (disabled -- not executed directly)
└── Simple Controller: "Login Module" ← defined once
├── GET /login-page
├── RegEx Extractor: csrfToken
├── POST /login
└── JSON Extractor: authToken| Controller | Purpose | Real-World Use |
|---|---|---|
| Simple Controller | Organize (no logic) | Group related requests for readability |
| Transaction Controller | Group as one metric | Report "Login Flow" instead of individual requests |
| Loop Controller | Repeat N times | Browse 5 products after one login |
| If Controller | Conditional execution | Split traffic: 50% buy, 30% browse, 20% account |
| While Controller | Loop until condition | Poll API until job completes |
| Module Controller | Reuse a section | Define login flow once, call from multiple scenarios |
| Random Controller | Execute one random child | Randomly browse different product categories |
| Interleave Controller | Rotate through children | Alternate between search, browse, and filter on each loop |
Q: How do you simulate realistic user scenarios with different behaviors in JMeter?
A: I use a combination of Logic Controllers and weighted randomization. First, I wrap related requests in Transaction Controllers so I get business-level metrics -- "Login Flow" and "Checkout Flow" rather than individual request times. Then I use If Controllers with Groovy conditions to split traffic: typically 50-60% of users browse products, 20-30% complete a purchase, and 10-20% manage their account. I generate a random number in a JSR223 Pre-Processor and route each user to a scenario based on their number. Within each scenario, I use Loop Controllers for repeated actions like browsing multiple products. For polling scenarios (like waiting for a report to generate), I use While Controllers with a maximum iteration limit to prevent infinite loops. I also use Module Controllers to define reusable flows -- the login module is defined once and referenced by all scenarios.
Key Point: Transaction Controller is essential for business metrics. If Controller for traffic splitting. Loop for repeated sections. While for polling. Module for reuse. Always use Groovy for conditions, not JavaScript.
Key Point: Transaction Controller for business metrics, If Controller for branching, Loop for repetition, While for polling, Module for reuse. Use Groovy conditions for performance.