Modbus TCP vs OPC UA: Polling Latency Trade-offs
Choosing between Modbus TCP and OPC UA is rarely a feature checklist exercise — it is a latency-budget decision that determines whether a microstop lands in the right one-second bucket or gets smeared across two. This page sits under PLC tag standardization because the acquisition protocol you pick constrains the timestamp fidelity every canonical tag inherits downstream. Modbus TCP is a request-response register protocol with no concept of a source timestamp; OPC UA is a session-oriented information model with report-by-exception subscriptions and a server-stamped SourceTimestamp on every value change. Both can hit sub-100 ms acquisition latency on a healthy LAN, but they get there through opposite mechanisms, and each degrades differently once tag counts and network jitter climb — which is exactly the failure mode that corrupts Availability windows in OEE formula validation.
Modbus TCP register polling Permalink to this section
Modbus TCP has no session concept beyond the TCP connection itself and no native timestamp field. Every acquisition cycle is a synchronous request-response transaction: the client issues a function code (typically 0x03 Read Holding Registers or 0x04 Read Input Registers), the slave replies with raw 16-bit words, and the client must stamp the arrival time itself. That stamped time is a receipt timestamp, not a measurement timestamp — the actual sample inside the PLC may be up to one scan cycle older than the packet that carried it.
Latency is dominated by the number of discrete transactions, not the byte count. A gateway that reads each tag with its own request pays a full round trip per tag; the fix is to pack contiguous registers into a single block read whenever the tag map allows it, which is one reason ISA-95 equipment hierarchy tag naming should track register contiguity, not just semantic grouping.
import asyncio
import time
from dataclasses import dataclass
from pymodbus.client import AsyncModbusTcpClient
@dataclass(frozen=True)
class ModbusPollResult:
tag_id: str
raw_registers: list[int]
receipt_ts: float # acquisition-side timestamp; NOT a source timestamp
round_trip_ms: float
async def poll_block(
client: AsyncModbusTcpClient,
start_address: int,
count: int,
tag_id: str,
) -> ModbusPollResult:
"""Read a contiguous register block in one transaction to amortize round trips."""
t0 = time.monotonic()
result = await client.read_holding_registers(address=start_address, count=count)
round_trip_ms = (time.monotonic() - t0) * 1000.0
if result.isError():
raise ConnectionError(f"Modbus read failed at {start_address}: {result}")
# Receipt time stands in for a source timestamp; document the substitution
# so downstream consumers know this is not measurement-time accurate.
return ModbusPollResult(
tag_id=tag_id,
raw_registers=result.registers,
receipt_ts=time.time(),
round_trip_ms=round_trip_ms,
)
async def scan_cycle(
client: AsyncModbusTcpClient,
blocks: list[tuple[int, int, str]],
scan_interval_s: float = 0.25,
) -> list[ModbusPollResult]:
"""Serial poll of register blocks; total latency scales with block count."""
cycle_start = time.monotonic()
results = [await poll_block(client, addr, cnt, tid) for addr, cnt, tid in blocks]
elapsed = time.monotonic() - cycle_start
if elapsed > scan_interval_s:
# Scan overrun: the next cycle will start late, compounding jitter.
raise TimeoutError(f"scan cycle took {elapsed:.3f}s, budget was {scan_interval_s:.3f}s")
await asyncio.sleep(max(0.0, scan_interval_s - elapsed))
return results
On a switched LAN with a native TCP slave, round trips run 1–5 ms; when the slave is a Modbus RTU-to-TCP gateway bridging serial devices, every transaction pays the serial link’s baud-rate turnaround (commonly 20–100 ms at 9600–19200 baud), and that cost is paid per transaction, so a poll list of forty ungrouped tags against a gateway can push scan-cycle latency into the seconds. Because the scan interval is a hard client-side sleep, any overrun compounds: cycle two starts late, and the reported “current” value can trail the physical event by more than one full scan period.
OPC UA subscriptions Permalink to this section
OPC UA separates how often the server checks a value from how often the client is told about it. A MonitoredItem has a sampling interval — how frequently the server evaluates the underlying source, subject to the server’s own scan-cycle floor. A Subscription has a publishing interval — how often the server is allowed to send a PublishResponse to the client. The server samples continuously at the sampling interval but only queues a notification when the value changes by more than its configured deadband (report-by-exception), so a static tag consumes essentially no bandwidth between changes. Critically, every reported DataValue carries a SourceTimestamp set by the server at the moment of measurement, independent of when the client’s TCP stack happens to deliver the packet.
import asyncio
import logging
from dataclasses import dataclass
from asyncua import Client, ua
logger = logging.getLogger(__name__)
@dataclass
class SubscriptionConfig:
endpoint: str
node_ids: list[str]
sampling_interval_ms: float = 100.0 # server-side evaluation cadence
publishing_interval_ms: float = 250.0 # client notification cadence
queue_size: int = 10 # per-item change backlog before overflow
class LatencyAwareHandler:
"""Captures SourceTimestamp for jitter analysis vs client arrival time."""
def datachange_notification(self, node, val, data):
source_ts = data.monitored_item.Value.SourceTimestamp
arrival_ts = asyncio.get_event_loop().time()
logger.debug("node=%s value=%s source_ts=%s", node, val, source_ts)
async def run_subscription(cfg: SubscriptionConfig) -> None:
async with Client(url=cfg.endpoint) as client:
handler = LatencyAwareHandler()
sub = await client.create_subscription(cfg.publishing_interval_ms, handler)
nodes = [client.get_node(nid) for nid in cfg.node_ids]
# Each item can carry its own sampling interval; publishing interval
# is the shared floor for when the server is allowed to flush queues.
await sub.subscribe_data_change(
nodes,
attr=ua.AttributeIds.Value,
sampling_interval=cfg.sampling_interval_ms,
queuesize=cfg.queue_size,
)
await asyncio.Event().wait() # run until cancelled by the caller
The floor on end-to-end latency is max(sampling_interval, time_to_next_publish_cycle) + network_rtt. If the sampling interval is set below the server’s actual internal scan rate, the server silently clamps it to the achievable minimum — always confirm the negotiated RevisedSamplingInterval in the response rather than trusting the requested value. A queue_size of 1 with discardOldest=true behaves like the newest-value-wins semantics used for high-frequency analog metrics in MQTT QoS selection; a larger queue preserves every transition for discrete state tags at the cost of a burstier publish.
Head-to-head latency and jitter under load Permalink to this section
The two protocols diverge sharply as tag count and network conditions degrade. Modbus TCP’s latency is linear in the number of read transactions because each is a blocking round trip; OPC UA’s latency is largely flat in tag count because sampling happens server-side in one internal loop and only changed values leave the wire. Where OPC UA pays a cost is subscription setup overhead (session establishment, security handshake, CreateMonitoredItems calls) and the fact that a burst of simultaneous changes across many items still has to fit inside one publishing interval’s PublishResponse, which can itself queue if the server is under load.
Jitter sources also differ in kind. Modbus jitter comes from OS scheduler slop on the polling thread, TCP retransmits, and — most severely — serial-gateway turnaround variance when RTU devices sit behind a protocol converter. OPC UA jitter comes from publishing-interval alignment (a change that occurs 1 ms after a publish cycle closes waits almost a full interval) and from keep-alive counts inflating the interval when nothing has changed. Neither protocol eliminates jitter; OPC UA bounds the timestamp error because SourceTimestamp is measured, not inferred, which matters more for OEE than raw wire latency once you are correcting residual skew with clock drift correction.
For safety-relevant or count-critical tags — production counters, fault edges — prefer OPC UA subscriptions with a queue deep enough to never drop a transition, and treat Modbus polling as acceptable only when the signal is slow-changing (temperatures, pressures) relative to the scan interval, or when legacy hardware leaves no alternative.
Gotchas & anti-patterns Permalink to this section
- Trusting the requested sampling interval. OPC UA servers routinely revise it upward under load; log
RevisedSamplingIntervaland alert if it diverges from what was requested, or downstream windowing assumptions silently break. - One Modbus transaction per tag. Ungrouped reads turn a forty-tag poll list into forty round trips. Pack registers into contiguous blocks at the tag-registry level so the poller can batch them.
- Using receipt time as source time on Modbus. Without an explicit correction, a scan-cycle overrun makes stale data look fresh. Timestamp at read completion, log round-trip time alongside it, and treat the uncertainty as a bound, not zero.
- Undersized OPC UA queues on bursty discrete tags. A
queue_sizeof 1 during a rapid RUN/IDLE/RUN flap silently drops the middle transition — exactly the kind of gap that corrupts state-machine reconstruction. - Ignoring publishing-interval alignment. A subscription with a 1 s publishing interval cannot report latency better than ~1 s even if sampling is 10 ms; match the publishing interval to the tightest downstream requirement, not the loosest.
Quick reference Permalink to this section
| Dimension | Modbus TCP | OPC UA subscription |
|---|---|---|
| Timestamp source | Client receipt time only | Server SourceTimestamp per value |
| Latency scaling with tag count | Linear (one round trip per read) | Near-flat (server-side sampling loop) |
| Bandwidth on static values | Full poll every cycle | Near-zero (report-by-exception) |
| Typical LAN floor | 1–5 ms/transaction (native slave) | max(sampling, publish interval) + RTT |
| Serial-gateway penalty | 20–100 ms per transaction | N/A (server absorbs serial layer) |
| Best fit | Slow analog, legacy-only devices | Discrete state, counters, mixed rates |
Related Permalink to this section
- PLC Tag Standardization — the canonical tag contract both acquisition paths must populate
- How to Map Siemens S7 Tags to OPC UA — a concrete OPC UA server-side mapping recipe
- ISA-95 Equipment Hierarchy Tag Naming Conventions — naming rules that keep register blocks and semantic groups aligned
- Clock Drift Correction — correcting the residual timestamp error neither protocol fully removes