How to test API concurrency

Send the same write to one endpoint many times at once with curl --parallel --parallel-immediate, then read the resource back. Twenty sequential requests moved the counter to 20. Twenty parallel requests moved it by 1. That gap is lost updates, and a test that sends one request at a time never sees it.

Why check this

Concurrency bugs pass every functional test, because functional tests are sequential. Run this check on any endpoint that reads a value, decides something, then writes it back: stock counters, wallet balances, invite codes, slot booking. Run it before release and again after any change to the storage layer. The failure it prevents is two customers holding the last item in stock, with both orders confirmed and only one unit in the warehouse.

Prerequisites

const http = require('node:http');

let balance = 0;
let inFlight = 0;
let peak = 0;
const orders = [];

http.createServer(async (req, res) => {
  inFlight += 1;
  peak = Math.max(peak, inFlight);
  res.setHeader('content-type', 'application/json');
  if (req.url.startsWith('/state')) {
    res.end(JSON.stringify({ balance, orders: orders.length, peak }));
  } else if (req.url.startsWith('/order')) {
    const key = new URL(req.url, 'http://local').searchParams.get('key');
    const current = balance;
    await new Promise((done) => setTimeout(done, 20));
    balance = current + 1;
    orders.push(key);
    res.end(JSON.stringify({ balance }));
  } else {
    res.statusCode = 404;
    res.end('{}');
  }
  inFlight -= 1;
}).listen(8080, '127.0.0.1', () => console.log('listening on 127.0.0.1:8080'));

Steps

  1. Step 1.

    Start the target in its own terminal and leave it running.

    node race-server.js
    
    listening on 127.0.0.1:8080
  2. Step 2.

    Send 20 orders one after another to establish what correct looks like.

    for i in $(seq 1 20); do curl -s "http://127.0.0.1:8080/order?key=seq-$i"; echo; done | tail -3
    
    {"balance":18}
    {"balance":19}
    {"balance":20}

    Each request saw the previous one's write, so the balance climbed by exactly 20.

  3. Step 3.

    Send the next 20 at the same time and read what each one reports.

    curl -s --parallel --parallel-immediate --parallel-max 20 "http://127.0.0.1:8080/order?key=par-[1-20]" | tail -c 60
    
    :21}{"balance":21}{"balance":21}{"balance":21}{"balance":21}

    Every one of the 20 answered 21. They all read the same starting value of 20 and all wrote 21 over each other.

  4. Step 4.

    Read the stored state and compare it with the 40 requests that have now been accepted.

    curl -s http://127.0.0.1:8080/state
    
    {"balance":21,"orders":40,"peak":20}

    The side effect list holds 40 entries while the balance holds 21. Nineteen writes were accepted with a 200 and then lost. peak confirms 20 requests were genuinely inside the handler together.

  5. Step 5.

    Repeat the burst with plain shell background jobs, which needs no curl flags.

    (for i in $(seq 1 20); do curl -s "http://127.0.0.1:8080/order?key=amp-$i" & done; wait) 2>/dev/null | tail -c 60
    
    :28}{"balance":28}{"balance":28}{"balance":29}{"balance":29}

    The replies are no longer identical. Starting 20 processes takes long enough that some finish before others begin, so this loop applies more of the writes than --parallel does.

  6. Step 6.

    Read the state again to score the second burst.

    curl -s http://127.0.0.1:8080/state
    
    {"balance":29,"orders":60,"peak":20}

    Twenty more requests moved the balance by 8. A weaker burst hides more of the bug, which is why the flag-based method belongs in the regression suite.

  7. Step 7.

    Stop the target. Press Ctrl+C in its terminal, or kill whatever holds the port.

    taskkill //F //PID $(netstat -ano | grep ':8080' | grep LISTENING | awk '{print $5}' | head -1)
    
    SUCCESS: The process with PID 29168 has been terminated.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | Stored value equals the request count | Writes are serialised correctly | Rerun with a higher --parallel-max before calling it safe | | Stored value is below the request count | Lost updates, a read-modify-write race | Move the arithmetic into the database or take a row lock | | Side effect count is above the request count | The handler ran twice for one request | Look for a retry in the client or the proxy, and add an idempotency key | | peak stays at 1 under a burst | Nothing ran concurrently | The requests queued somewhere, so raise the burst or check the connection limit | | Some requests return 5xx under the burst | A pool or a lock ran out | Read the server log before reading the data, the error is the finding |

Thresholds

curl runs 50 transfers at a time unless --parallel-max says otherwise Source: https://curl.se/docs/manpage.html#--parallel-max

Sending 100 URLs without that flag produced peak 50 on the target above, so the ceiling is the client's, not the server's. Raise --parallel-max to the burst size you mean to send, or the number you report is a curl setting.

Common mistakes

Sign: All the parallel requests return 200, so the endpoint is declared safe.Cause: Every lost update is a successful response. The evidence is in the stored resource, never in the reply. Step 3 returned twenty 200s and step 4 showed nineteen of those writes gone.
Sign: The burst runs and the stored value is exactly right.Cause: The requests did not overlap. A sequential shell loop, a single-connection client or a low --parallel-max serialises them. Instrument the handler with a peak counter, as the target above does, and confirm the number is above 1.
Sign: A shell loop with & finds nothing and curl --parallel finds a bug.Cause: Each & spawns a process, and process startup staggers the arrivals. The same 20 writes lost 19 updates under --parallel and 12 under the loop. Treat the loop as the weaker instrument.

What to check next

FAQ

How many parallel requests are enough?

Enough to put more than one request inside the handler at the same time, which the peak counter proves. Twenty was enough here against a 20 ms handler. A faster handler needs a larger burst or a slower dependency.

Does this replace a load test?

No. A load test measures behaviour under sustained traffic. This check looks for a correctness bug that appears the moment two requests overlap, and it needs 20 requests rather than 20 minutes.

Why does curl report the same value from every request?

They all read the stored value before any of them wrote. The response is generated from what the handler saw, so identical replies are the signature of a read-modify-write race.

Can this run against a staging API?

Yes, when the team owns it and the data is disposable. The burst writes real records. Never point it at a host outside your own estate, where a parallel write burst is indistinguishable from an attack.

What fixes a lost update?

Do the arithmetic where the data lives: a conditional update, a database counter, a row lock, or a version column that rejects a stale write. Retrying in the client moves the race, it does not close it.

Verified

Verified by Maks Vernycurl 8.21.0node 22.23.2

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

advanced10 minpublished updated Maks Verny