Skip to content

IO In Loop

Rule details

Rule ID io-in-loop
Severity WARNING — likely performance problem
Confidence HIGH (unambiguous IO) · MEDIUM (candidate match)
Category Loop amplifiers
Complexity O(n·IO) → O(1·IO + n)

The problem

A network, database, or file system call inside a loop body. Each iteration incurs a full I/O round-trip — network latency, disk seek, query execution — so the total wall-clock time scales linearly with the loop size. The computational cost of the loop might be trivial, but the wall-clock cost is anything but.

A loop that makes 100 HTTP calls at 50ms each takes 5 seconds minimum, regardless of how fast the server responds. A single batch call takes 50ms + a bit of extra payload processing. The gap only widens with more iterations.

Unlike CPU-bound performance problems, this one doesn't show up in profiler flame graphs the way you'd expect. The thread is waiting, not working. You see it in latency metrics, not CPU utilization.

Where this shows up

  • In Spring services that call RestTemplate.getForObject() per item in a list
  • In data migration scripts that write files or make API calls one record at a time
  • In JavaScript backends that await fetch() sequentially inside a for loop
  • In Kotlin coroutines that call httpClient.get() inside a forEach without parallelism
  • In JDBC code that prepares and executes a statement per ID instead of batching

The pattern

for (Order order : orders) {
    OrderDetails d = restTemplate.getForObject( // (1)!
        "/api/details/" + order.getId(), OrderDetails.class);
    details.add(d);
}
  1. HTTP round-trip per iteration — 500 orders = 500 HTTP calls
for (String id : orderIds) {
    PreparedStatement ps = conn.prepareStatement(
        "SELECT * FROM orders WHERE id = ?");
    ps.setString(1, id);
    ResultSet rs = ps.executeQuery(); // DB round-trip per iteration
    result.add(mapRow(rs));
}
for (order in orders) {
    val details = httpClient.get("/api/details/${order.id}") // HTTP per iteration
        .body<OrderDetails>()
    results.add(details)
}
for (const id of orderIds) {
    const response = await fetch(`/api/orders/${id}`); // HTTP per iteration
    orderDetails.push(await response.json());
}

The fix

// Single batch request
List<OrderDetails> details = restTemplate.postForObject(
    "/api/details/batch", orderIds, List.class);
String placeholders = String.join(",", Collections.nCopies(orderIds.size(), "?"));
PreparedStatement ps = conn.prepareStatement(
    "SELECT * FROM orders WHERE id IN (" + placeholders + ")");
for (int i = 0; i < orderIds.size(); i++) {
    ps.setString(i + 1, orderIds.get(i));
}
ResultSet rs = ps.executeQuery(); // single query

Use coroutineScope with async and awaitAll to run all calls concurrently:

val results = coroutineScope {
    orders.map { order ->
        async { httpClient.get("/api/details/${order.id}").body<OrderDetails>() }
    }.awaitAll() // parallel, not sequential
}

Use Promise.all to fire all requests concurrently:

const orderDetails = await Promise.all(
    orderIds.map(id =>
        fetch(`/api/orders/${id}`).then(r => r.json())
    )
); // parallel requests — total time ≈ max(individual times)
const response = await fetch('/api/orders/batch', {
    method: 'POST',
    body: JSON.stringify(orderIds),
});
const orderDetails = await response.json(); // single request

Suggestion variants

Batch the IO operation outside the loop, or use a bulk API

The suggestion text adapts to the detected I/O pattern — HTTP calls get batch endpoint advice, database calls get bulk query guidance, file operations get batching suggestions.

When to ignore this

Some I/O has to happen per iteration. Rate-limited APIs that only accept one item at a time, file-per-record output requirements, or explicit per-item transaction boundaries are all valid reasons to suppress. Also fine when the loop is bounded to a small constant (processing 3 config files, not 3,000 records).

// algorilla:ignore io-in-loop — rate-limited API, one call per item required
for (Order order : orders) {
    rateLimiter.acquire();
    externalApi.submit(order);
}

Covered IO categories

The rule detects I/O methods from YAML semantics files. Coverage includes:

  • Database: JDBC (executeQuery, prepareStatement), Groovy SQL (eachRow, rows)
  • HTTP: Spring RestTemplate (getForObject, exchange, etc.), Ktor Client, fetch() in JS
  • File system: Kotlin file extensions (readText, writeText), Node.js sync fs operations

Framework overlays (Spring, Ktor, Node) extend the base language methods with framework-specific I/O calls.

I/O cluster — each rule catches a specific flavor of "too many I/O calls":

Loop overhead cluster — when the cost inside the loop is CPU rather than I/O:

Language-specific examples:

  • JavaScript — sequential await fetch() in a loop
  • Kotlin — Ktor HTTP client calls per iteration

Configuration

Disable globally:

rules:
  io-in-loop:
    enabled: false

Suppress inline:

// algorilla:ignore io-in-loop
for (Order order : orders) {
    externalService.notify(order);
}