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
- curl 7.66 or later.
--parallelarrived in 7.66, and--parallel-immediatein 7.68. See the curl manual on --parallel. - Node 18 or later to run the target below.
- An endpoint you own. A burst of parallel writes against someone else's host is an attack, not a test. The server here runs on
127.0.0.1. - Save this as
race-server.js. It carries a read, a 20 ms pause, then a write, which is the shape of any handler that loads a row, decides, and saves it.
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
- Step 1.
Start the target in its own terminal and leave it running.
node race-server.jslistening on 127.0.0.1:8080 - 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.
- 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.
- 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.
peakconfirms 20 requests were genuinely inside the handler together. - 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
--paralleldoes. - 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.
- 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
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
What to check next
- How to test API idempotency: the fix for the duplicate side effects this burst exposes.
- How to test API rate limiting: the same burst, scored against the quota instead of the data.
- How to test API timeout handling: timeouts plus retries are how a burst becomes duplicate writes in production.
- How to check API response time: compare the single-request latency with what the endpoint does under 20 at once.
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.
Related on this site
advanced10 minpublished updated Maks Verny