MLLP framing bytes: 0x0B, 0x1C, 0x0D
MLLP uses three bytes to frame HL7 messages over TCP. How start block, end block, and carriage return work together, and the edge cases that break implementations.
MLLP frames an HL7 message by wrapping it in three bytes: 0x0B to mark the
start, 0x1C followed by 0x0D to mark the end. The receiver reads from the
TCP stream, finds these delimiters, and extracts the payload between them.
0x0B <payload> 0x1C 0x0D
That’s the entire protocol. No length prefix, no headers, no version negotiation. The framing is defined in the HL7 v2 standard, Appendix C.
The three bytes
| Byte | Hex | ASCII name | Role |
|---|---|---|---|
0x0B | 11 | Vertical Tab (VT) | Start Block |
0x1C | 28 | File Separator (FS) | End Block |
0x0D | 13 | Carriage Return (CR) | Frame terminator |
These are control characters from the ASCII table. They were chosen because they
don’t appear in HL7 message content. HL7 v2 uses 0x0D as a segment delimiter
inside the message, but the End Block is the two-byte sequence 0x1C 0x0D –
the receiver looks for both bytes together, not 0x0D alone.
This is a common source of confusion. A bare 0x0D inside the payload is a
segment separator (end of MSH, end of PID, etc.). The 0x1C 0x0D pair at the
end is the frame terminator. The 0x1C byte disambiguates them.
Reading a frame
A receiver processes the TCP byte stream like this:
- Read bytes until
0x0Bappears. Everything before it is discarded. - After
0x0B, accumulate bytes into a buffer. - Scan the buffer for the two-byte sequence
0x1C 0x0D. - When found, everything between
0x0Band0x1Cis the HL7 message. - Return to step 1 for the next frame.
In pseudocode:
while connection is open:
skip bytes until 0x0B
buffer = []
while true:
byte = read()
if byte == 0x1C:
next = read()
if next == 0x0D:
yield buffer as message
break
else:
buffer.append(0x1C)
buffer.append(next)
else:
buffer.append(byte)
The pseudocode is simplified. A production implementation uses buffered I/O and scans for the two-byte end sequence in blocks rather than byte-by-byte.
A complete frame
An ADT^A01 (patient admission) message in MLLP framing. Hex values shown for the framing bytes, with the HL7 content in plain text:
[0x0B]
MSH|^~\&|HIS|CENTRAL|LAB|PATHOLOGY|20260403120000||ADT^A01|MSG00001|P|2.3[0x0D]
PID|||123456^^^MRN||DOE^JOHN||19800101|M|||123 MAIN ST^^HELSINKI^^00100^FI[0x0D]
PV1||I|ICU^001^01||||12345^SMITH^ANNA[0x0D]
[0x1C][0x0D]
Each [0x0D] inside the message is a segment separator. The final [0x1C][0x0D]
is the frame terminator. The receiver strips all framing bytes before parsing
the HL7 content.
On the wire, this is a continuous byte stream. The line breaks above are for
readability. The actual TCP payload has no newlines. Segments are separated
by 0x0D and the message is one unbroken sequence of bytes between 0x0B and
0x1C 0x0D.
Sending a test frame
The simplest way to test an MLLP receiver is with printf and nc:
printf '\x0bMSH|^~\\&|SENDER|FAC|RECV|FAC|20260403||ADT^A01|12345|P|2.3\r\x1c\x0d' \
| nc localhost 2575
\x0b is 0x0B, \r is 0x0D (segment separator), \x1c is 0x1C, and
the final \x0d is 0x0D (frame terminator). The response will be an
MLLP-framed ACK message.
For scripted testing or when you need to send multiple messages, a dedicated
MLLP client is more practical than printf pipelines.
Edge cases
Garbage before the start block
The HL7 spec allows data before 0x0B. The receiver should skip bytes until it
finds the start block. This handles cases where the TCP stream contains noise
from a previous broken connection or a misconfigured sender.
In practice, garbage before the start block usually means something is wrong – a sender that isn’t speaking MLLP, or a connection that was reused without proper cleanup. Implementations should skip the garbage but may choose to log it.
There is a limit to patience. If a receiver accumulates megabytes of data
without finding 0x0B, it should close the connection rather than buffer
indefinitely. A reasonable limit is the maximum frame size.
Incomplete frames
A start block arrives but the end sequence never does. This happens when:
- The sender crashes mid-message
- A network device drops the connection after the start block was sent
- A firewall or NAT timeout closes the TCP session
The receiver needs a frame timeout. If 0x0B arrives but 0x1C 0x0D doesn’t
follow within a configurable period (typically 30-60 seconds), close the
connection and discard the partial buffer.
Without a frame timeout, a sender that writes 0x0B and then stalls will hold
the connection and its buffer memory indefinitely. This is the MLLP equivalent
of a slow-loris attack.
Oversized frames
HL7 messages are usually small, a few hundred bytes to a few kilobytes. But some message types (ORU with embedded base64 content, or messages with many OBX segments) can be large.
Implementations set a maximum frame size to prevent memory exhaustion. When the payload exceeds this limit, the connection is closed. The default in most implementations is 1-2 MB.
The size check needs to happen during buffering, not just after the frame is
complete. Otherwise, a sender could write a continuous stream of bytes after
0x0B without ever sending 0x1C 0x0D, forcing the receiver to buffer until
memory runs out.
Empty payloads
A frame of 0x0B 0x1C 0x0D (three bytes, no payload) is technically valid
MLLP framing. The payload is zero bytes. Most receivers treat this as a no-op
or return an AR because the payload can’t be parsed as an HL7 message.
Multiple frames on one connection
MLLP connections are persistent. After the receiver sends an ACK, the sender
can immediately send the next frame on the same connection. The receiver loops
back to step 1 (scan for 0x0B) and processes the next message.
Frames are serial, not concurrent. The sender must wait for the ACK before sending the next frame. Pipelining (sending multiple frames without waiting for ACKs) is not part of the MLLP specification and will break most receivers.
0x1C inside the payload
0x1C (File Separator) should not appear inside an HL7 message. If it does,
and is not followed by 0x0D, a correct implementation will not treat it as
the end of the frame. It only matches the two-byte sequence 0x1C 0x0D.
If 0x1C 0x0D appears inside the payload (unlikely but possible with binary
content), the frame will be truncated. MLLP has no escaping mechanism. This is
a fundamental limitation of delimiter-based framing. In practice, HL7 v2
messages are text-based and don’t contain these byte sequences in their content.
Why not use a length prefix
Length-prefixed framing (send the message size first, then the content) avoids
the delimiter ambiguity problem entirely. HTTP uses Content-Length. Most modern
protocols use length prefixes.
MLLP uses delimiters because it was designed in the 1980s for simple implementations on hardware with limited resources. A length prefix requires the sender to know the message size before sending. With delimiters, the sender can stream bytes as they’re generated.
The trade-off is that delimiter-based framing requires scanning every byte for the end sequence. Length-prefixed framing just reads N bytes. For the message sizes typical in HL7 (under 10 KB), the performance difference is negligible.
For the protocol that wraps these frames, see What is MLLP and how does it work. For what happens after the frame is parsed, see HL7 ACK and NAK codes: AA, AR, AE explained.