Skip to main content
Back to Blog
API Testing
2026-08-08

API Testing Batch Request Ordering Without False Greens

Design api testing batch request ordering checks that catch out-of-order applies, partial failures, and dependency races in multi-call and bulk endpoints.

API Testing Batch Request Ordering Without False Greens

API testing for batch request ordering proves whether a multi-operation request (or a deliberate series of calls) applies side effects in the order clients must rely on. That includes bulk endpoints that accept arrays of commands, GraphQL selection sets with dependent mutations when your server documents sequential execution, message-style batch sockets, and test-controlled sequences of REST calls that simulate the client SDK. You assert both the response order and the durable system state order: ids, versions, timestamps, ledger lines, and read-after-write visibility.

Ordering bugs are easy to miss because many batches look commutative. Creating two independent tags can succeed in any order. Creating a project and then adding a member to that project cannot. Partial failure makes it worse: item 2 fails, item 3 still runs, and clients that assumed stop-on-error now see a state that no single-step tutorial ever showed. Parallel application inside the server can also reorder effects while still returning an array aligned to request indices, which is a response-shape success and a business-order failure.

This guide builds a practical approach for QA and automation engineers: classify batch semantics, design order-sensitive fixtures, write SuperTest and contract-friendly checks, diagnose reordering under concurrency, and avoid the classic mistake of asserting only HTTP 200 on the envelope. Use SuperTest Node API testing for executable HTTP examples and contract testing with Pact when consumer SDKs depend on documented batch order guarantees.

Name the ordering contract before you test it

If the API docs never promise order, your test may be asserting an accident. Pull the promise into a short matrix and test only what the product commits to (or force the product to commit when clients already depend on behavior).

Batch styleTypical endpoint shapeOrder promise to clarifyState risk if unordered
Ordered command listPOST /batch with [{op},{op}]Apply in array order; stop or continue on errorDependent ops see missing parents
Parallel mapPOST /items:batchGetNo apply order; response matches request indexUsually read-only; weaker concern
Bulk create independentPOST /tags:batchCreateOften unordered; may parallelizeDuplicate name races
Transactional unitPOST /transfers/batch all-or-nothingLogical order inside one transactionPartial ledger lines
Client-side sequenceMultiple REST calls in testClient order only; server may interleave other writersLost updates without versions

Write the promise in the test title. Bad title: batch works. Good title: member add after project create in one batch sees new project.

Separate response order from apply order

Servers often return results in request-index order even when work ran concurrently. Index-aligned responses are necessary for client mapping; they are not proof of sequential apply.

CheckProvesDoes not prove
results[i] corresponds to requests[i]Client can zip arraysOps ran sequentially
Each result has stable correlation_idTraceabilityApply order
Server logs show worker timestampsRough schedulingDocumented guarantee
Dependent op succeeds referencing prior createEffective ordering or shared transactionPerformance characteristics
Intermediate read mid-batchVisibility between steps (if API allows)Other tenants' isolation

Design at least one dependency pair in every ordered-batch suite:

  1. Op A creates entity id client generates or server returns mid-batch.
  2. Op B references that entity.
  3. If the server only returns ids at the end, use client-generated UUIDs so B can reference A without a round trip.

Prefer client-generated ids for order tests

Server-generated ids that appear only in the final response force B to guess. Client-generated ids encode the dependency graph in the request body.

// tests/api/batch-ordering.test.ts
import request from 'supertest';
import { randomUUID } from 'node:crypto';
import { app } from '../../src/app';

describe('POST /v1/batch ordered commands', () => {
  it('creates a project then adds a member using the same project id', async () => {
    const projectId = randomUUID();
    const userId = randomUUID();

    const res = await request(app)
      .post('/v1/batch')
      .set('Authorization', `Bearer ${process.env.E2E_API_TOKEN}`)
      .send({
        mode: 'sequential',
        operations: [
          {
            method: 'POST',
            path: '/v1/projects',
            body: { id: projectId, name: 'Order Probe' },
          },
          {
            method: 'POST',
            path: `/v1/projects/${projectId}/members`,
            body: { userId, role: 'editor' },
          },
        ],
      })
      .expect(200);

    expect(res.body.results).toHaveLength(2);
    expect(res.body.results[0]).toMatchObject({ status: 201 });
    expect(res.body.results[1]).toMatchObject({ status: 201 });

    const members = await request(app)
      .get(`/v1/projects/${projectId}/members`)
      .set('Authorization', `Bearer ${process.env.E2E_API_TOKEN}`)
      .expect(200);

    expect(members.body.map((m: { userId: string }) => m.userId)).toContain(userId);
  });
});

If your API forbids client ids, split into two sequential HTTP calls in the test and treat that as client-side ordering, or use a batch dialect that allows response templating (rare; only test if documented).

Encode stop-on-error versus continue-on-error

Partial failure policies dominate batch correctness.

PolicyWhen op 2 failsOps 3..nTest assertions
Stop on errorApplied or rolled back per docsMust not applyNo side effects for later ops
Continue on errorRecorded as failed resultStill applyLater side effects present; failed index marked
All-or-nothing transactionWhole batch rolls backNoneZero net side effects

Stop-on-error example:

it('does not run later ops when sequential batch hits a validation error', async () => {
  const projectId = randomUUID();
  const orphanName = `should-not-exist-${randomUUID()}`;

  const res = await request(app)
    .post('/v1/batch')
    .set('Authorization', `Bearer ${process.env.E2E_API_TOKEN}`)
    .send({
      mode: 'sequential',
      onError: 'stop',
      operations: [
        {
          method: 'POST',
          path: '/v1/projects',
          body: { id: projectId, name: 'Partial Fail Parent' },
        },
        {
          method: 'POST',
          path: `/v1/projects/${projectId}/members`,
          body: { userId: 'not-a-uuid', role: 'editor' },
        },
        {
          method: 'POST',
          path: '/v1/labels',
          body: { name: orphanName },
        },
      ],
    })
    .expect(200);

  expect(res.body.results[0].status).toBe(201);
  expect(res.body.results[1].status).toBeGreaterThanOrEqual(400);
  expect(String(res.body.results[2].status)).toMatch(/^(skipped|cancelled|0)$/);

  const labels = await request(app)
    .get('/v1/labels')
    .query({ name: orphanName })
    .set('Authorization', `Bearer ${process.env.E2E_API_TOKEN}`)
    .expect(200);

  expect(labels.body).toEqual([]);
});

Adjust the skipped-result assertion to match your real API document. Some APIs omit skipped entries; then assert results.length === 2 and label absence still holds.

Continue-on-error counterpart:

it('applies later independent ops when onError is continue', async () => {
  const labelName = `kept-${randomUUID()}`;

  const res = await request(app)
    .post('/v1/batch')
    .set('Authorization', `Bearer ${process.env.E2E_API_TOKEN}`)
    .send({
      mode: 'sequential',
      onError: 'continue',
      operations: [
        {
          method: 'POST',
          path: '/v1/members',
          body: { userId: 'not-a-uuid', role: 'editor' },
        },
        {
          method: 'POST',
          path: '/v1/labels',
          body: { name: labelName },
        },
      ],
    })
    .expect(200);

  expect(res.body.results[0].status).toBeGreaterThanOrEqual(400);
  expect(res.body.results[1].status).toBe(201);

  const labels = await request(app)
    .get('/v1/labels')
    .query({ name: labelName })
    .set('Authorization', `Bearer ${process.env.E2E_API_TOKEN}`)
    .expect(200);

  expect(labels.body.some((l: { name: string }) => l.name === labelName)).toBe(true);
});

Detect illegal reordering with versioned resources

Monotonic versions expose apply order for updates to the same entity.

it('applies sequential patches to one document in request order', async () => {
  const docId = randomUUID();

  await request(app)
    .post('/v1/docs')
    .set('Authorization', `Bearer ${process.env.E2E_API_TOKEN}`)
    .send({ id: docId, title: 'v0', version: 0 })
    .expect(201);

  const res = await request(app)
    .post('/v1/batch')
    .set('Authorization', `Bearer ${process.env.E2E_API_TOKEN}`)
    .send({
      mode: 'sequential',
      operations: [
        {
          method: 'PATCH',
          path: `/v1/docs/${docId}`,
          body: { title: 'first', expectedVersion: 0 },
        },
        {
          method: 'PATCH',
          path: `/v1/docs/${docId}`,
          body: { title: 'second', expectedVersion: 1 },
        },
      ],
    })
    .expect(200);

  expect(res.body.results.map((r: { status: number }) => r.status)).toEqual([200, 200]);

  const doc = await request(app)
    .get(`/v1/docs/${docId}`)
    .set('Authorization', `Bearer ${process.env.E2E_API_TOKEN}`)
    .expect(200);

  expect(doc.body).toMatchObject({ title: 'second', version: 2 });
});

If the server reorders patches, the second expectedVersion fails or the final title is wrong. That is a sharper signal than timestamp comparisons alone.

Failure mode: green envelope, reordered side effects

Symptom: Batch returns 200 with per-item 201s. Mobile clients intermittently show a member list without the project header resource, or webhooks fire in the wrong sequence.

Diagnosis steps:

  1. Log server-side apply timestamps per operation id.
  2. Re-run the dependent-pair test under CPU load or with a deliberately slow create handler.
  3. Check whether mode: sequential is honored or silently treated as parallel.
  4. Inspect whether the response builder sorts by input index after a parallel Promise.all.
  5. Verify webhook or outbox ordering separately from HTTP result arrays.

Reproducing harness with parallel pressure (illustrative):

it('holds project-before-member order under concurrent batches', async () => {
  const token = process.env.E2E_API_TOKEN!;

  const attempts = Array.from({ length: 20 }, async () => {
    const projectId = randomUUID();
    const userId = randomUUID();
    const res = await request(app)
      .post('/v1/batch')
      .set('Authorization', `Bearer ${token}`)
      .send({
        mode: 'sequential',
        operations: [
          {
            method: 'POST',
            path: '/v1/projects',
            body: { id: projectId, name: 'Load' },
          },
          {
            method: 'POST',
            path: `/v1/projects/${projectId}/members`,
            body: { userId, role: 'viewer' },
          },
        ],
      });

    expect(res.status).toBe(200);
    expect(res.body.results[1].status).toBe(201);
  });

  await Promise.all(attempts);
});

Twenty is an illustrative storm size, not a statistical proof. Use it as a bug magnifier in CI nightly jobs, not as a flaky PR gate without retries policy.

Compare multi-call client sequences to true batch endpoints

Not every product has POST /batch. Ordering still matters when the official SDK issues chained calls.

it('SDK-style sequence: create invoice then attach payment', async () => {
  const invoice = await request(app)
    .post('/v1/invoices')
    .set('Authorization', `Bearer ${process.env.E2E_API_TOKEN}`)
    .send({ customerId: 'cust_1', totalCents: 1000 })
    .expect(201);

  const payment = await request(app)
    .post(`/v1/invoices/${invoice.body.id}/payments`)
    .set('Authorization', `Bearer ${process.env.E2E_API_TOKEN}`)
    .send({ amountCents: 1000, method: 'card' })
    .expect(201);

  const finalInvoice = await request(app)
    .get(`/v1/invoices/${invoice.body.id}`)
    .set('Authorization', `Bearer ${process.env.E2E_API_TOKEN}`)
    .expect(200);

  expect(finalInvoice.body.status).toMatch(/^(paid|settled)$/i);
  expect(payment.body.invoiceId).toBe(invoice.body.id);
});

Add a negative ordering test: payment before invoice must fail:

it('rejects payment for unknown invoice id', async () => {
  await request(app)
    .post('/v1/invoices/does-not-exist/payments')
    .set('Authorization', `Bearer ${process.env.E2E_API_TOKEN}`)
    .send({ amountCents: 1000, method: 'card' })
    .expect(404);
});

True batch endpoints need both styles: in-batch dependency and cross-request dependency under concurrent clients.

Contract tests for documented order guarantees

Consumer-driven contracts should include order-sensitive examples when the consumer SDK serializes batches.

Pact-style conceptual interaction (structure illustrative of intent; align with your Pact major version APIs when implementing):

// consumer pact snippet (conceptual)
await provider
  .addInteraction()
  .given('account can write projects')
  .uponReceiving('a sequential batch that creates project then member')
  .withRequest({
    method: 'POST',
    path: '/v1/batch',
    headers: { 'Content-Type': 'application/json' },
    body: {
      mode: 'sequential',
      operations: [
        { method: 'POST', path: '/v1/projects', body: { id: 'p1', name: 'N' } },
        { method: 'POST', path: '/v1/projects/p1/members', body: { userId: 'u1', role: 'editor' } },
      ],
    },
  })
  .willRespondWith({
    status: 200,
    body: {
      results: [
        { status: 201, body: { id: 'p1' } },
        { status: 201, body: { projectId: 'p1', userId: 'u1' } },
      ],
    },
  });

Provider verification must use a sequential implementation. If the provider verifies with a stub that ignores order, contracts become theater. Pair Pact with at least one state-based integration test for the same graph.

Idempotency keys and batch order

Clients retry batches. Idempotency keys can interact with order:

ApproachRetry riskTest idea
One key for whole batchSafer atomic retryReplay same key; no duplicate children
Per-operation keysPartial replay complexityReplay after mid-fail; ensure stop policy holds
No keysDuplicate creates on timeoutDouble submit creates two projects unless server dedupes
it('replaying the same batch idempotency key does not duplicate projects', async () => {
  const key = randomUUID();
  const projectId = randomUUID();
  const body = {
    mode: 'sequential',
    operations: [
      {
        method: 'POST',
        path: '/v1/projects',
        body: { id: projectId, name: 'Idempotent' },
      },
    ],
  };

  const first = await request(app)
    .post('/v1/batch')
    .set('Authorization', `Bearer ${process.env.E2E_API_TOKEN}`)
    .set('Idempotency-Key', key)
    .send(body)
    .expect(200);

  const second = await request(app)
    .post('/v1/batch')
    .set('Authorization', `Bearer ${process.env.E2E_API_TOKEN}`)
    .set('Idempotency-Key', key)
    .send(body)
    .expect(200);

  expect(second.body).toEqual(first.body);

  const list = await request(app)
    .get('/v1/projects')
    .query({ id: projectId })
    .set('Authorization', `Bearer ${process.env.E2E_API_TOKEN}`)
    .expect(200);

  expect(list.body).toHaveLength(1);
});

Only assert header names your API documents. If the product uses a different idempotency mechanism, adapt the test rather than inventing headers.

Observability hooks for order debugging

When a batch test fails intermittently, pure HTTP asserts are not enough. Require operation-level trace ids:

{
  "results": [
    {
      "status": 201,
      "operationId": "op_0",
      "traceId": "abc",
      "appliedAt": "2026-08-08T12:00:00.010Z"
    },
    {
      "status": 201,
      "operationId": "op_1",
      "traceId": "abc",
      "appliedAt": "2026-08-08T12:00:00.040Z"
    }
  ]
}

Test that sequential mode produces non-decreasing appliedAt when the server exposes it:

function assertNonDecreasing(timestamps: string[]): void {
  for (let i = 1; i < timestamps.length; i += 1) {
    expect(Date.parse(timestamps[i])).toBeGreaterThanOrEqual(Date.parse(timestamps[i - 1]));
  }
}

Clock resolution can make equal timestamps normal; non-decreasing is the honest assertion. Dependency success remains the stronger business check.

What people get wrong: testing only commutative batches

Teams batch three independent creates, see three 201s, and declare ordering done. Independent creates rarely fail under reordering. Always include:

  1. A create/use dependency pair.
  2. Two ordered updates to one versioned entity.
  3. A partial failure case for the documented onError policy.
  4. One concurrency storm in nightly CI.
  5. One idempotent replay if retries exist.

Another mistake: sorting arrays in the test before assert, which hides response permutation bugs for APIs that promise index alignment.

GraphQL and multi-mutation operations

Top-level mutation fields are executed serially in document order by the GraphQL specification (https://spec.graphql.org/), so that ordering is safe to assert. Nested selections and query fields carry no such guarantee and may resolve in any order, so do not assert ordering across them.

Safer pattern: one mutation field that accepts an ordered list input, tested like REST batch:

mutation ApplyCommands($commands: [CommandInput!]!) {
  applyCommands(commands: $commands) {
    results {
      status
      message
    }
  }
}
it('applyCommands runs grant after role create', async () => {
  const roleId = randomUUID();
  const res = await request(app)
    .post('/graphql')
    .set('Authorization', `Bearer ${process.env.E2E_API_TOKEN}`)
    .send({
      query: `
        mutation ($commands: [CommandInput!]!) {
          applyCommands(commands: $commands) {
            results { status message }
          }
        }
      `,
      variables: {
        commands: [
          { type: 'CREATE_ROLE', payload: { id: roleId, name: 'auditor' } },
          { type: 'GRANT_ROLE', payload: { roleId, userId: 'user_1' } },
        ],
      },
    })
    .expect(200);

  expect(res.body.data.applyCommands.results).toEqual([
    { status: 'ok', message: expect.any(String) },
    { status: 'ok', message: expect.any(String) },
  ]);
});

Authorization and batch order

Authorization failures mid-batch must follow the same onError policy. A dangerous bug runs later ops as elevated internal users after a failed auth on earlier ops.

it('stops batch when an operation is forbidden and onError is stop', async () => {
  const res = await request(app)
    .post('/v1/batch')
    .set('Authorization', `Bearer ${process.env.E2E_READONLY_TOKEN}`)
    .send({
      mode: 'sequential',
      onError: 'stop',
      operations: [
        { method: 'GET', path: '/v1/projects' },
        { method: 'DELETE', path: '/v1/projects/any' },
        { method: 'POST', path: '/v1/labels', body: { name: 'x' } },
      ],
    })
    .expect(200);

  expect(res.body.results[0].status).toBe(200);
  expect(res.body.results[1].status).toBe(403);

  // Stop-on-error must skip the third operation, so assert both the reported
  // status and that the label was never persisted. Asserting only the status
  // would still pass if the server created the label and then reported a skip.
  expect(String(res.body.results[2]?.status ?? 'skipped')).toMatch(/^(skipped|cancelled|0)$/);

  const labels = await request(app).get('/v1/labels?name=x').expect(200);
  expect(labels.body.items).toHaveLength(0);
});

CI strategy for order suites

SuiteWhenContents
PR smokeEvery API PROne dependency pair + one stop-on-error
NightlyScheduledConcurrency storm + idempotent replay + version patches
ContractPR for consumer/providerPact order example
Chaos (optional)WeeklyInject latency in op handlers

Fail closed if the environment variable that enables sequential mode is missing in production-like configs. A misconfig that switches default mode to parallel should break the dependency-pair test immediately.

AI agents writing batch tests

Agents tend to spawn N independent creates and assert length. Repository rules:

## Batch API tests

- Include at least one dependent operation pair per batch endpoint.
- Assert final resource state, not only envelope HTTP status.
- Cover onError stop and continue when both exist.
- Do not sort result arrays before index assertions.
- Use client-generated UUIDs when the API allows them.

Ready-made QA skills install from qaskills.sh with the qaskills CLI if you want those rules packaged for coding agents across services.

Reference implementation notes for Node services under test

If you control the server, sequential mode should not be Promise.all. Pseudocode for a correct skeleton:

type Op = { method: string; path: string; body?: unknown };
type OpResult = { status: number; body?: unknown };

async function runSequential(
  ops: Op[],
  onError: 'stop' | 'continue',
  exec: (op: Op) => Promise<OpResult>,
): Promise<OpResult[]> {
  const results: OpResult[] = [];
  for (const op of ops) {
    const result = await exec(op);
    results.push(result);
    if (result.status >= 400 && onError === 'stop') {
      break;
    }
  }
  return results;
}

Tests against this skeleton should fail if someone "optimizes" to parallel map without changing the documented mode. Keep the mode name in metrics so you can alert on unexpected parallel volume for sequential routes.

Cross-service batches and outbox order

When op A writes to service A and op B calls service B, HTTP batch order does not guarantee downstream consumers see A before B unless you share a transaction or a single ordered event stream. Extend tests:

  1. Assert primary DB state order.
  2. Assert outbox/event sequence for the batch id.
  3. Optionally assert eventual consumer state with a bounded wait.
it('emits ProjectCreated before MemberAdded for sequential batch', async () => {
  const projectId = randomUUID();
  const userId = randomUUID();

  await request(app)
    .post('/v1/batch')
    .set('Authorization', `Bearer ${process.env.E2E_API_TOKEN}`)
    .send({
      mode: 'sequential',
      operations: [
        { method: 'POST', path: '/v1/projects', body: { id: projectId, name: 'Evt' } },
        {
          method: 'POST',
          path: `/v1/projects/${projectId}/members`,
          body: { userId, role: 'editor' },
        },
      ],
    })
    .expect(200);

  const events = await readOutboxForProject(projectId);
  const types = events.map((e) => e.type);
  const createdAt = types.indexOf('ProjectCreated');
  const addedAt = types.indexOf('MemberAdded');
  // indexOf returns -1 when absent, and -1 < anything, so presence must be asserted first.
  expect(createdAt).toBeGreaterThanOrEqual(0);
  expect(addedAt).toBeGreaterThanOrEqual(0);
  expect(createdAt).toBeLessThan(addedAt);
});

readOutboxForProject is your test double or DB query helper; implement it against your real outbox table rather than inventing a fake framework API.

Putting it together: a minimal required checklist

Before marking a batch endpoint "tested for order":

  1. Docs state sequential vs parallel and onError policy.
  2. Dependency pair integration test exists.
  3. Partial failure test matches policy.
  4. Index alignment test exists for the response array.
  5. Versioned double-patch test exists when updates are supported.
  6. Nightly concurrency probe exists.
  7. Contract example exists if external consumers build batches.
  8. Idempotent replay covered when keys exist.

Skip any item only with a written reason in the test plan, not by silence.

Frequently Asked Questions

How is batch request ordering different from ordinary sequential API tests?

Ordinary tests issue one HTTP call after another from the client, so the client controls order. Batch endpoints accept multiple operations in one request, and the server decides whether to apply them sequentially, in parallel, or transactionally. You must assert the server's apply semantics and partial-failure policy, not only that each independent call works in isolation. Response arrays aligned by index prove mapping, not necessarily apply order, so include dependent operations and durable state checks.

What is the smallest high-value ordering test I should add first?

Add a two-operation sequential batch where the second operation references an entity created or addressed by the first, using a client-generated id when allowed. Assert both per-item statuses and a follow-up read that shows the dependency succeeded. That single test catches the common failure of parallel execution behind a sequential facade. Next, add a stop-on-error case so later operations cannot silently apply after a failure.

Can contract tests alone prove batch apply order?

No. Contract tests excel at fixing request and response shapes consumers rely on, including example order of results in the JSON body. They do not fully prove durable side-effect order under concurrency, nor do they exercise database constraints and webhooks unless provider verification is deeply integrated. Use contracts to lock the envelope and consumer expectations, and use integration tests for dependency graphs, partial failures, and outbox ordering.

Why do my batch tests pass locally but fail under CI load?

CI load surfaces races when the server parallelizes work, when shared fixtures collide across shards, or when clocks and database visibility lag. Dependency-pair tests may start failing if sequential mode is not actually serialized under resource contention. Stabilize by using unique ids per attempt, isolating tenant data per test, asserting state rather than only timestamps, and running a controlled concurrency storm in nightly jobs while keeping PR smoke deterministic.