Modbus TCP Transaction IDs: Match Responses Across Timeouts and Reused IDs
A late Modbus response can be more dangerous than a missing one. If a client reuses its transaction identifier too soon, an old register value may satisfy a new request and appear completely valid. The remedy is a lifecycle for request identities, not simply a larger socket buffer or a longer timeout.
The wire rules here follow Modbus TCP/IP Implementation Guide V1.0b and Application Protocol V1.1b3. The request-table design is an engineering recommendation. All timelines are synthetic examples; no gateway performance or production reliability result is claimed.

Use a connection-scoped request table
The MBAP transaction identifier is a two-byte value chosen by the client and copied into the response. The TCP implementation guide, sections 3.1.3 and 4.4.1.3, uses it to associate a response with a pending request on the same connection. An identifier with no pending transaction is discarded.
Key the table by a connection-instance token plus transaction ID. Do not use only the remote IP, file descriptor number or transaction ID: each can recur while an old callback still exists. Each entry should retain the requested unit, function, expected response shape, deadline, application request token and completion state. Allocate and insert the entry before a fast response can reach the receive path.
Make completion a single state transition
A response handler and a deadline handler can race. Both must compete for the same pending-to-terminal transition, with only one allowed to publish completion. If the timer wins, a later response cannot update the measurement cache through a separate convenience path. If the response wins, the timer must not report a second result or release the identifier again.
Use a deterministic scheduler in unit tests to exercise both orders at the deadline boundary. Count completed application operations, not merely decoded packets. A test that sees one response and one timeout but ignores the second callback has missed the actual ownership defect.
Keep timed-out IDs out of immediate reuse
Consider request A, ID 17, reading one register. A times out locally. The client immediately assigns ID 17 to request B, also reading one register from the same unit. A’s response then arrives. Its identifier, unit, function and byte count can all match B, so ordinary validation cannot distinguish the two.
A local generation counter cannot repair this ambiguity because the response carries no generation field. Quarantine timed-out identifiers. If a documented upper bound on outstanding response lifetime does not exist, a finite waiting period alone is not proof that reuse is safe. A fresh TCP connection provides a new association boundary; the implementation must also reject callbacks belonging to the old connection instance.
Plan for wraparound and finite resources
The identifier space contains 65,536 values. A counter wrapping to zero is ordinary arithmetic, but assigning a value still pending or quarantined is a logic error. Search an availability structure rather than assuming the next counter value is free. If no safe identifier is available, apply backpressure or rotate the connection under a defined policy.
Connection rotation is not a recommendation to reconnect after every successful transaction. It is a bounded response to association uncertainty or identifier exhaustion. Decide how to drain or terminate remaining requests, preserve their individual outcomes, and prevent unsent work from being mistaken for transmitted work. Keep memory bounded even if a peer never answers.
Validate the response after finding its owner
Association is necessary but insufficient. Check Protocol Identifier zero, MBAP length, the expected function or corresponding exception function, and the function-specific response structure. For gateway routes, verify the configured Unit Identifier expectation; native TCP endpoint handling must follow its documented nonsignificant-unit behavior.
For function 03, compare the byte count with the requested register quantity. Notice that its normal response does not echo the requested starting address, as specified in Application Protocol V1.1b3, section 6.3. Therefore, a plausible value and correct length are not independent evidence that the right address was read. Treat a valid exception as a completed protocol response with a reported failure, not as an absent response.
Build a synthetic association test matrix
Use a controllable TCP peer that records requests and releases responses on command. Start two requests with different IDs, then release their responses in reverse order. Delay a third beyond its deadline and verify that its late response is discarded without changing a newer result. Attempt allocation at counter wrap and while every permissible table entry is occupied.
Then close the connection while a response callback is queued, create a new connection instance, and run the old callback. It must not complete a request in the new table. Add wrong-unit, wrong-function, malformed-length and valid-exception cases. Finally demonstrate the same-ID ambiguity with two identical response shapes; the expected fix is preventing unsafe reuse, not guessing from register values.
Log correlation decisions and preserve uncertainty
Record connection instance, transaction ID, application token, request function, deadline, terminal state and rejection reason. Keep payload logging bounded and appropriate for the deployment. Distinguish “unknown ID,” “expired ID,” “old connection” and “response structure mismatch” so each failure points to a different check.
- No ID is allocated twice while pending or quarantined.
- Each application request completes at most once.
- Late and old-connection responses cannot publish data.
- Resource exhaustion produces bounded backpressure or a documented connection transition.
- A timeout preserves execution uncertainty for writes; correlation logic never authorizes automatic replay.
These checks establish ownership of responses. They do not authenticate the server, prove measurement freshness or guarantee that a timed-out command was never executed. Those are separate application contracts.