Performance testing REST endpoints with Gatling and Scala
A REST API can appear healthy in functional tests and still struggle when real users arrive concurrently. Response times rise, connection pools fill, downstream services queue requests, and a small number of slow calls can make an otherwise acceptable journey feel broken. Performance testing exposes these behaviours before they become production incidents.
Gatling is a useful fit for teams that want readable, version-controlled load tests. Its Scala DSL describes HTTP requests, virtual-user behaviour, checks, pauses, and workload models as code. This approach works well for Java and Scala teams, while the resulting reports give test engineers clear evidence about latency, throughput, failures, and percentile response times.
Start with a realistic REST workload
A useful simulation begins with a business journey rather than an arbitrary collection of endpoints. An online retail API might authenticate a customer, retrieve a product list, inspect stock, add an item to a basket, and submit an order. A banking service could load accounts, retrieve recent transactions, and initiate a payment. Each journey should reflect the proportion of traffic expected in production.
Gatling simulations commonly define a scenario and attach it to an injection profile. A simple example might look like this:
class BrowseProductsSimulation extends Simulation {
val httpProtocol = http
.baseUrl("https://api.example.com")
.acceptHeader("application/json")
.contentTypeHeader("application/json")
val browse = scenario("Browse products")
.exec(
http("List products")
.get("/products?category=coffee")
.check(status.is(200))
.check(jsonPath("$.items").exists)
)
.pause(1, 3)
.exec(
http("View product")
.get("/products/42")
.check(status.is(200))
)
setUp(
browse.inject(rampUsers(100).during(60.seconds))
).protocols(httpProtocol)
}
The request checks confirm correctness while Gatling measures performance. A successful HTTP status alone is insufficient: validate important fields, content types, identifiers, and business outcomes. Failed checks should count as failed requests, preventing a fast but incorrect API from appearing healthy.
Model users, arrival rates, and pauses
There are two broad ways to inject load. Open workload models introduce users independently, such as a steady arrival rate or a gradual ramp. Closed workload models maintain a target number of active users, adding users as earlier ones finish. Choose the model that resembles how traffic reaches the service.
For a public REST API, open arrivals often represent reality more closely. Requests can continue arriving even while the application is slowing down. In Gatling, constantUsersPerSec, rampUsersPerSec, and stressPeakUsers help model these patterns. A sudden spike might be represented with a short ramp, while a morning trading or booking period may need a sustained rate followed by a higher peak.
Pauses matter because users do not call endpoints in a tight loop. Use fixed pauses for a predictable test or random pauses for a more natural distribution. A script with no pauses can generate an unrealistic request rate and overload a service in a way production users never would. For an Australian service, consider time-zone demand: Sydney and Melbourne traffic may rise together, while Perth introduces a different daily pattern.
A workload should include warm-up, steady-state, peak, and recovery periods where appropriate. Ramping too quickly can measure container creation, cache misses, or autoscaling activity rather than normal capacity. A longer steady state gives connection pools, garbage collection, caches, and message consumers time to reveal their behaviour.
Prepare authentication and test data safely
REST performance tests often need OAuth2 tokens, API keys, cookies, or signed requests. Avoid requesting a new token for every business action unless that is the production behaviour. Authenticate once per virtual user or use a realistic token lifetime. Store the token in the session and reuse it in subsequent requests.
Dynamic data is essential when the system rejects duplicate identifiers or when shared records create lock contention. Gatling feeders can read CSV, JSON, or custom generated data. A feeder can provide customer IDs, search terms, product codes, or order references, while session variables connect one response to the next request.
.feed(csv("customers.csv").circular)
.exec(
http("Get customer")
.get("/customers/${customerId}")
.check(status.is(200))
)
Use data that is representative without exposing personal information. Australian organisations handling personal information need to consider the Privacy Act 1988 and the Australian Privacy Principles. The Notifiable Data Breaches scheme also makes careless handling of leaked test data a serious operational risk. Use synthetic names, masked identifiers, isolated accounts, and short-lived credentials. Check whether data may leave an approved region when load generators or observability platforms are hosted overseas.
Test data can affect results as much as code does. A single popular product may produce cache hits and database hot spots, whereas a broad distribution may test indexes and storage more realistically. Define the distribution explicitly, document it, and reset or replenish state between runs so that one test does not contaminate the next.
Build scenarios that expose bottlenecks
A single endpoint test is useful for a baseline, but it rarely describes production behaviour. Combine several scenarios with proportions that reflect expected usage. For example, 70 percent of users might browse, 20 percent search, and 10 percent place orders. The percentages should come from access logs, product analytics, capacity assumptions, or agreed business forecasts.
Correlated values make a scenario realistic. Capture an order ID from a response, then use it for payment and fulfilment calls. Extract a pagination cursor, follow a link, or pass an ETag into a conditional request. Gatling checks and session variables support this flow while ensuring that later requests operate on valid data.
Use assertions to express service-level expectations:
setUp(
browse.inject(rampUsersPerSec(2).to(20).during(5.minutes))
).protocols(httpProtocol)
.assertions(
global.responseTime.percentile3.lt(800),
global.failedRequests.percent.lt(1)
)
Percentiles are more informative than averages. The 50th percentile describes a typical result, while the 95th and 99th percentiles reveal the slower experience affecting a meaningful minority of users. Define thresholds for important journeys and endpoints, then distinguish a genuine regression from a noisy environment.
Do not add retries casually. Retries can conceal failures and multiply load during an incident. If production clients retry, model that policy deliberately in a separate scenario or as part of the client journey. Record whether failures come from connection errors, timeouts, HTTP status codes, failed checks, or assertion breaches.
Run tests in an environment you can observe
Load generation and system observation need to be planned together. Gatling reports tell you what the client experienced; server-side telemetry helps explain why. Capture CPU, memory, garbage collection, thread pools, database connections, query latency, cache hit rates, queue depth, network throughput, and downstream response times.
Keep the load generators separate from the application under test. A generator with insufficient CPU, network bandwidth, or open file descriptors can become the bottleneck and produce misleading results. For larger tests, distribute generators across suitable regions and compare their resource usage with the target system.
Network location changes the meaning of latency. A test launched from a laptop in Brisbane will not represent a customer in Adelaide, Singapore, or regional New South Wales. Measure from locations that match the audience and document DNS, TLS, CDN, firewall, and cloud routing conditions. NBN connection quality and mobile networks can also create different user experiences from a low-latency corporate test environment.
Coordinate with operations before generating traffic. Use a dedicated test tenant, identifiable headers, a published test window, and rate limits for third-party dependencies. Avoid uncontrolled tests against shared services, payment providers, emergency systems, or public endpoints. A performance test should be authorised, reversible, and easy to stop.
Analyse Gatling results beyond the average
Gatling’s HTML reports help compare request counts, successes, failures, response-time distributions, and active users. Begin by checking whether the expected workload actually ran. Confirm arrival rates, user counts, duration, and scenario proportions before interpreting latency.
Look for a rising response-time curve as concurrency grows. A sharp change often indicates a saturation point: a database pool is exhausted, a queue is growing, a CPU limit is reached, or a downstream dependency has started throttling. A flat median with a rapidly worsening 99th percentile can indicate intermittent contention, garbage collection, lock waits, or a small set of slow database operations.
Compare endpoint performance with business journeys. A fast health check does not prove that checkout is healthy. A product request may be quick while an order workflow waits on inventory and payment services. Correlate timestamps across Gatling, application logs, traces, and database monitoring, using request IDs where possible.
The following comparison helps select a test style for a specific question:
| Test style | Main question | Typical Gatling model | Useful evidence |
|---|---|---|---|
| Baseline | How fast is the service under light load? | Small, steady arrival rate | Median latency and error-free behaviour |
| Load | Can the system handle expected demand? | Gradual ramp to normal traffic | Percentiles, throughput, resource use |
| Stress | Where does capacity break down? | Increasing rate beyond forecast | Saturation point and failure modes |
| Spike | How does the service react to sudden demand? | Rapid arrival-rate jump | Queueing, autoscaling, recovery |
| Soak | Does performance degrade over time? | Long steady-state run | Memory, pools, leaks, drift |
Make performance testing part of delivery
A practical workflow starts with a baseline simulation stored beside application code. Run a small test on every meaningful performance change, then schedule larger tests in a representative environment. Gatling can run through sbt, Maven, Gradle, or Gatling’s enterprise tooling, allowing teams to select the integration that fits their build pipeline.
Keep assertions strict enough to detect regressions but stable enough to avoid false alarms. A development laptop may be unsuitable for absolute latency gates, whereas a controlled CI environment can support reliable thresholds. Separate functional correctness checks from capacity gates when their purposes differ.
Track results over time. Store the simulation version, application commit, environment configuration, dataset, generator location, workload profile, and dependency versions with each run. A dashboard showing p95 latency, p99 latency, throughput, and error rate can reveal gradual degradation long before customers report it.
Use test findings to guide engineering work. If latency grows because of a saturated connection pool, increasing the pool may simply move the bottleneck to the database. If a cache improves median latency but leaves p99 unchanged, investigate the misses and slow path. Repeat the same workload after each change, then compare distributions rather than relying on a single headline number.
Performance testing REST endpoints with Gatling and Scala is most valuable when it connects realistic traffic to observable system behaviour. The key result is not the largest number of virtual users a test can create; it is a defensible understanding of capacity, limits, and user-facing reliability. Remember to validate the workload, protect real data, read the percentiles, and trace every important result back to the system component that caused it.