Contiguous Modbus Register Reads: How to Optimize PLC Polling for Maximum Throughput [2026]
If you're polling a PLC with Modbus and reading one register at a time, you're wasting 80% of your bus time on protocol overhead. Every Modbus transaction carries a fixed cost — framing bytes, CRC calculations, response timeouts, and turnaround delays — regardless of whether you're reading 1 register or 120. The math is brutal: reading 60 holding registers individually means 60 request/response cycles. Coalescing them into a single read means one cycle that returns all 60 values.
This article breaks down the mechanics of contiguous register optimization, shows you exactly how to implement it, and explains why it's the single highest-impact change you can make to your IIoT data collection architecture.

The Hidden Cost of Naive Polling
Let's do the math on a typical Modbus RTU link at 9600 baud.
A single Modbus RTU read request (function code 03) for one holding register looks like this:
| Field | Bytes |
|---|---|
| Slave Address | 1 |
| Function Code | 1 |
| Starting Address | 2 |
| Quantity of Registers | 2 |
| CRC | 2 |
| Request Total | 8 |
The response for a single register:
| Field | Bytes |
|---|---|
| Slave Address | 1 |
| Function Code | 1 |
| Byte Count | 1 |
| Register Value | 2 |
| CRC | 2 |
| Response Total | 7 |
That's 15 bytes on the wire for 2 bytes of actual data — 13.3% payload efficiency.
Now add the silent interval between frames. Modbus RTU requires a minimum 3.5-character gap (roughly 4ms at 9600 baud) between transactions. Plus a typical slave response time of 5–50ms. For a conservative 20ms response delay:
- Wire time per transaction: ~15.6ms (15 bytes × 1.04ms/byte at 9600 baud)
- Turnaround + gap: ~24ms
- Total per register: ~39.6ms
- 60 registers individually: ~2,376ms (2.4 seconds!)
Now, reading those same 60 registers in one contiguous block:
- Request: 8 bytes (unchanged)
- Response: 1 + 1 + 1 + 120 + 2 = 125 bytes
- Wire time: ~138.5ms
- One turnaround: ~24ms
- Total: ~162.5ms
That's 14.6x faster for the same data. On a serial bus where you might have multiple slave devices and hundreds of tags, this is the difference between a 1-second polling cycle and a 15-second one.
Understanding Modbus Address Spaces
Before you can coalesce reads, you need to understand that Modbus defines four distinct address spaces, each requiring a different function code:
| Address Range | Register Type | Function Code | Read Operation |
|---|---|---|---|
| 0–65,535 | Coils (discrete outputs) | FC 01 | Read Coils |
| 100,001–165,536 | Discrete Inputs | FC 02 | Read Discrete Inputs |
| 300,001–365,536 | Input Registers (16-bit, read-only) | FC 04 | Read Input Registers |
| 400,001–465,536 | Holding Registers (16-bit, read/write) | FC 03 | Read Holding Registers |
Critical rule: you cannot coalesce reads across function codes. A request using FC 03 (holding registers) cannot include addresses that belong to FC 04 (input registers). They're physically different memory areas in the PLC. Any optimization algorithm must first partition tags by their function code, then optimize within each partition.
The address encoding convention (where 400001 maps to holding register 0) is a common source of bugs. When you see address 404002 in a tag configuration, the leading 4 indicates holding registers (FC 03), and the actual Modbus address sent on the wire is 4002. Your coalescing logic needs to strip the prefix for wire protocol but keep it for function code selection.
The Coalescing Algorithm
The core idea is simple: sort tags by address within each function code group, then merge adjacent tags into single read operations. Here's the logic:
Step 1: Sort Tags by Address
Your tag list must be ordered by Modbus address. If tags arrive in arbitrary order (as they typically do from configuration files), sort them first. This is a one-time cost at startup.
Tag: "Delivery Temp" addr: 404002 type: float ecount: 2
Tag: "Mold Temp" addr: 404004 type: float ecount: 2
Tag: "Return Temp" addr: 404006 type: float ecount: 2
Tag: "Flow Value" addr: 404008 type: float ecount: 2
Tag: "System Standby" addr: 404010 type: float ecount: 2
All five tags use FC 03 (holding registers). Their addresses are contiguous: 4002, 4004, 4006, 4008, 4010. Each occupies 2 registers (32-bit float = 2 × 16-bit registers).
Step 2: Walk the Sorted List and Build Read Groups
Starting from the first tag, accumulate subsequent tags into the same group as long as:
- Same function code — The address prefix maps to the same Modbus command
- Contiguous addresses — The next tag's address equals the current head address plus accumulated register count
- Same polling interval — Tags with different intervals should be in separate groups (a 1-second tag shouldn't force a 60-second tag to be read every second)
- Register count limit — Modbus protocol limits a single read to 125 registers (for FC 03/04) or 2000 coils (for FC 01/02). In practice, keeping it under 50–120 registers per read improves reliability on noisy links
When any condition fails, finalize the current group, issue the read, and start a new group with the current tag as head.
Step 3: Dispatch the Coalesced Read
For our five temperature tags:
Single coalesced read:
Function Code: 03
Starting Address: 4002
Quantity: 10 registers (5 tags × 2 registers each)
One transaction returns all 10 registers. The response buffer contains the raw bytes in order — you then walk through the buffer, extracting values according to each tag's type and element count.
Step 4: Unpack Values from the Response Buffer
This is where data types matter. The response is a flat array of 16-bit words. For each tag in the group, you consume the correct number of words:
- uint16/int16: 1 word, direct assignment
- uint32/int32: 2 words, combine as
(word[1] << 16) | word[0](check your PLC's byte order!) - float32: 2 words, requires IEEE 754 reconstruction —
modbus_get_float()in libmodbus or manual byte swapping - bool/int8/uint8: 1 word, mask with
& 0xFF
Response buffer: [w0, w1, w2, w3, w4, w5, w6, w7, w8, w9]
|-------| |-------| |-------| |-------| |-------|
Tag 0 Tag 1 Tag 2 Tag 3 Tag 4
float float float float float
Handling Gaps in the Address Space
Real-world tag configurations rarely have perfectly contiguous addresses. You'll encounter gaps:
Tag A: addr 404000, ecount 2
Tag B: addr 404004, ecount 2 ← gap of 2 registers at 404002
Tag C: addr 404006, ecount 2
You have two choices:
-
Read through the gap — Issue one read from 4000 to 4007 (8 registers), and simply ignore the 2 garbage registers at offset 2–3. This is usually optimal if the gap is small (< 10 registers). The cost of reading extra registers is almost zero.
-
Split into separate reads — If the gap is large (say, 50+ registers), two smaller reads are more efficient than one bloated read full of data you'll discard.
A good heuristic: if the gap is less than the per-transaction overhead expressed in registers, read through it. At 9600 baud, a transaction costs ~24ms of overhead, equivalent to reading about 12 extra registers. So gaps under 12 registers should be read through.
Handling Mixed Polling Intervals
Not all tags need the same update rate. Temperature setpoints might only need reading every 60 seconds, while pump status flags need 1-second updates. Your coalescing algorithm must handle this.
The approach: partition by interval before coalescing by address. Tags with interval: 1 form one pool, tags with interval: 60 form another. Within each pool, apply address-based coalescing normally.
During each polling cycle, check whether enough time has elapsed since a tag's last read. If a tag isn't due for reading, skip it — but this means breaking the contiguous chain:
Cycle at T=30s:
Tag A (interval: 1s) → READ addr: 404000
Tag B (interval: 60s) → SKIP addr: 404002 ← breaks contiguity
Tag C (interval: 1s) → READ addr: 404004
Tags A and C can't be coalesced because Tag B sits between them and isn't being read. The algorithm must detect the break and issue two separate reads.
Optimization: If Tag B is cheap to read (1–2 registers), consider reading it anyway and discarding the result. The overhead of an extra 2 registers in a contiguous block is far less than the overhead of a separate transaction.
Modbus RTU vs TCP: Different Optimization Priorities
Modbus RTU (Serial)
- Bottleneck: Baud rate and turnaround time
- Priority: Minimize transaction count at all costs
- Flush between reads: Always flush the serial buffer before starting a new poll cycle to clear any stale or corrupted data
- Retry logic: Implement 2–3 retries per read with short delays — serial links are noisy, but a retry is still cheaper than dropping data
- Response timeout: Configure carefully. Too short (< 50ms) causes false timeouts; too long (> 500ms) kills throughput. 100–200ms is typical for most PLCs
- Byte/character timeout: Set to ~5ms at 9600 baud. This detects mid-frame breaks
Modbus TCP
- Bottleneck: Connection management, not bandwidth
- Priority: Keep connection alive and reuse it
- Connection recovery: Detect ETIMEDOUT, ECONNRESET, ECONNREFUSED, EPIPE, and EBADF — these all mean the connection is dead and needs reconnecting
- No inter-frame gaps: TCP handles framing, so back-to-back transactions are fine
- Default port: 502, but some PLCs use non-standard ports — make this configurable
Real-World Configuration Example
Here's a practical tag configuration for a temperature control unit using Modbus RTU, with 32 holding registers read as IEEE 754 floats:
{
"protocol": "modbus-rtu",
"batch_timeout": 60,
"link": {
"port": "/dev/rs232",
"base_addr": 1,
"baud": 9600,
"parity": "N",
"data_bits": 8,
"stop_bits": 2,
"byte_timeout_ms": 5,
"response_timeout_ms": 200
},
"tags": [
{"name": "Delivery Temp", "addr": 404002, "type": "float", "ecount": 2, "interval": 60},
{"name": "Mold Temp", "addr": 404004, "type": "float", "ecount": 2, "interval": 60},
{"name": "Return Temp", "addr": 404006, "type": "float", "ecount": 2, "interval": 60},
{"name": "Flow Value", "addr": 404008, "type": "float", "ecount": 2, "interval": 60},
{"name": "Heater Output %", "addr": 404054, "type": "float", "ecount": 2, "interval": 60},
{"name": "Cooling Output %", "addr": 404056, "type": "float", "ecount": 2, "interval": 60},
{"name": "Pump Status", "addr": 404058, "type": "float", "ecount": 2, "interval": 1, "immediate": true},
{"name": "Heater Status", "addr": 404060, "type": "float", "ecount": 2, "interval": 1, "immediate": true},
{"name": "Vent Status", "addr": 404062, "type": "float", "ecount": 2, "interval": 1, "immediate": true}
]
}
The coalescing algorithm would produce:
- Group 1 (interval: 60s): Read 404002–404009 → 4 tags, 8 registers, one read
- Group 2 (interval: 60s): Read 404054–404057 → 2 tags, 4 registers, one read (non-contiguous with Group 1, separate read)
- Group 3 (interval: 1s): Read 404058–404063 → 3 tags, 6 registers, one read
Three transactions instead of nine. At 9600 baud, that saves ~240ms per polling cycle — which adds up to minutes of saved bus time per hour.
Common Pitfalls
1. Ignoring Element Count
A float tag occupies 2 registers, not 1. If your coalescing logic treats every tag as 1 register, your contiguity check will be wrong and you'll read corrupt data. Always use addr + elem_count when calculating the next expected address.
2. Byte Order Confusion
Different PLCs use different byte orders for 32-bit values. Some use big-endian word order (ABCD), others use little-endian (DCBA), and some use mid-endian (BADC or CDAB). If your float values come back as NaN or astronomically wrong numbers, byte order is almost certainly the issue. Test with a known value (like a temperature setpoint you can verify on the HMI) and adjust.
3. Not Handling Read Failures Gracefully
When a coalesced read fails, the entire group fails. Don't panic — just flush the serial buffer, log the error, and retry up to 3 times. If the error is a connection-level failure (timeout, connection reset), close and reopen the Modbus context rather than hammering a dead link.
4. Exceeding the Register Limit
The Modbus specification allows up to 125 registers per read (FC 03/04) and 2000 coils (FC 01/02). In practice, many PLCs choke at lower limits — some only handle 50–60 registers per request reliably. Cap your coalesced read size at a conservative number (50 is safe for virtually all PLCs) and test with your specific hardware.
5. Mixing Immediate and Batched Tags
Some tags (like alarm states, emergency stops, or pump status changes) need to be sent to the cloud immediately — not held in a batch for 60 seconds. These "do not batch" tags should be delivered to the MQTT layer as soon as they're read, bypassing the batching pipeline. But they can still participate in coalesced reads; the immediate/batched distinction is about delivery, not reading.
How machineCDN Handles This
The machineCDN edge daemon implements all of these optimizations natively. Tags are automatically sorted by address at configuration load time, coalesced into contiguous read groups respecting function code boundaries and interval constraints, and read with retry logic tuned for industrial serial links. Both Modbus RTU and Modbus TCP are supported with automatic protocol detection — the daemon probes the PLC at startup to determine the device type, serial number, and communication protocol before configuring the polling loop.
The result: a single edge gateway on a Teltonika RUT9 industrial router can poll hundreds of tags from multiple devices with sub-second cycle times, even on 9600 baud serial links.
Conclusion
Contiguous register optimization is not optional for production IIoT deployments. The performance difference between naive per-register polling and properly coalesced reads is an order of magnitude. The algorithm is straightforward — sort by address, group by function code and interval, cap at the register limit, and handle gaps intelligently. Get this right, and your serial bus goes from bottleneck to barely loaded.
The tags are just data points. How you read them determines whether your IIoT system is a science project or production infrastructure.