Serial Communication App Pitfalls - Through Reconnection and Log Design

· Updated: · · Serial Communication, RS-232, C#, .NET, Windows Development, Device Integration

Revision history (1 updates, last updated Sep 1, 2026)

A log of the changes made to this article. Where a pre-update version was archived, it stays readable at a permanent DOI link.

Retranslated as a full translation of the Japanese original. The previous English version was an abridgement that carried only part of the source, so sections, tables, Mermaid diagrams, figure captions and FAQ entries were missing. All of them have been restored to match the Japanese original, and the technical claims are the same as in the Japanese version. Read the version before this update (DOI: 10.5281/zenodo.21614550)
First published
Cite this article(DOI: 10.5281/zenodo.21614549)

This article is archived on Zenodo. Below are both the DOI that always resolves to the latest version and the DOI pinned to the version you are reading.

Go Komura (2026). Serial Communication App Pitfalls - Through Reconnection and Log Design. KomuraSoft LLC. https://doi.org/10.5281/zenodo.21614549 https://comcomponent.com/en/blog/2026/03/19/001-serial-communication-app-pitfalls/

DOI (latest version)
10.5281/zenodo.21614549
DOI (this version)
10.5281/zenodo.22217178

Device integration, measuring instruments, PLCs, barcode readers, USB-to-serial adapters. Serial communication looks like old technology, yet it is still entirely commonplace in real Windows application work.

The slightly dangerous part is that serial communication can be started with nothing more than a single COM port and a single Read / Write. The connectivity check passes immediately, but once in production, symptoms like the following tend to appear.

  • Commands and responses occasionally get out of sync
  • It freezes exactly once a day
  • It fails to recover only after a USB unplug/replug
  • The UI sometimes stalls
  • The logs contain nothing but “Timeout”

What is genuinely hard in serial communication apps is not the send/receive API itself, but boundaries, timeouts, state transitions, reconnection, and observability.

Connectivity passes but production breaksA diagram showing that serial communication can be started with a single COM port and a single Read and Write and that the connectivity check passes immediately, yet in production the symptoms are mismatched responses, freezes and failure to recover, and that the real difficulty lies in boundaries, timeouts, state transitions, reconnection and observability rather than the send and receive API itself.Connectivity check passes immediatelyBreaks occasionally in productionResponses drift, it freezes, it does not recoverThe hard part is not the send/receive API itselfBoundaries, timeouts, state transitions, reconnection, observability

Figure 1: The real difficulties of a serial communication app lie beyond the connectivity check.

Who This Article Is For, and What It Assumes

Item Details
Intended readers Developers building Windows applications that talk to devices or instruments over serial. Written for those whose connectivity check already passes but who want to reduce the failures that show up only occasionally in production
Assumed knowledge The ability to write applications in C#. No prior experience with serial communication is assumed
Assumed environment Written around System.IO.Ports.SerialPort in .NET, but the thinking about boundaries, timeouts, and state transitions is language independent
Out of scope Electrical wiring, and the protocol specification of any particular device

Terms Used in This Article

Term Meaning in one line
PLC Programmable Logic Controller. An industrial controller used to control production equipment
RS-232 / RS-485 Electrical standards for serial communication. RS-232 is point to point, while RS-485 lets several devices hang off the same pair. On RS-485 you have to decide who transmits when, or they collide
8N1 Shorthand for port settings. Eight data bits, no parity (None), one stop bit
DTR / RTS Control lines. Originally they signal readiness for communication and a request to send, but real devices sometimes read transitions on these lines as a cue to boot or to switch modes
Flow control A mechanism that prevents sending too fast. RTS/CTS uses control lines, while XON/XOFF signals pause and resume with special characters inside the data
keepalive A lightweight command sent periodically to confirm that the peer is still alive
Frame The byte sequence that makes up one message. Where one frame starts and ends is decided by the protocol
single writer A design that consolidates transmission into a single worker. It means never allowing a state in which anything can Write

1. The Conclusions First

Summarized up front, in practice-oriented terms.

  • Serial communication is an ordered byte stream; message boundaries do not appear on their own
  • Calling Read(100) does not guarantee exactly 100 bytes back
  • .NET’s DataReceived is not guaranteed to fire per received byte, and moreover it is not on the UI thread
  • ReadLine() / WriteLine() behave nicely only when the peer truly speaks a line-based text protocol
  • One timeout is not enough. Separating the meanings - open, inter-byte, response, reconnect - gives more stability
  • Rather than allowing Write from anywhere, leaning toward a single writer is harder to break
  • With USB-serial, it is more peaceful to assume from the start: unplug/replug, re-enumeration, COM number changes, and reconnection failures

In short, the hard part of a serial communication app is not “can you open the port,” but how you turn the byte stream into meaningful messages, and how you manage the time and state around it.

Knowledge map for this article

This article is a design guide for preventing the defects that break only occasionally in C# serial communication apps used for work such as device integration. Serial communication is nothing more than an ordered byte stream and carries no message boundaries, so treating the DataReceived event as a notification that one message has arrived is not recommended, and the recommended design accumulates the received bytes first and then cuts messages out with a frame parser. Transmission should be concentrated into a single worker in a single writer design, and timeouts should be designed separately by meaning: open, inter-byte, response, and reconnect backoff. In protocols whose frames carry no request ID, responses can be mismatched after a response timeout, so what is needed is not a simple reconnect but session re-creation that rebuilds the receive buffer and the parser state as well.

Pitfalls in serial communication appsDiagram that starts from serial communication being a byte stream with no message boundaries and shows how a frame parser and a single writer design handle those boundaries and the order of transmissions, and how splitting timeouts by kind and the presence or absence of a request ID lead to mismatched responses and to session re-creation.usesrequiresimplementsnot recommended forrecommended forrecommended forusesusesusesusesusesmay causepreventsrecommended forusesrequiresrecommended forrequiresSerial CommunicationFrame Parser (Accumulate Then Extract)Single Writer (One Write Point)Byte Stream (Ordered Byte Sequence)Frame BoundarySerialPort.DataReceived eventCRC frame validationInter-Byte TimeoutTimeout Taxonomy by PurposeResponse TimeoutReconnect with BackoffResponse Mismatch RiskRequest IDSession Regeneration on ReconnectSerial Flow Control (DTR/RTS)Send/Receive Logging with Hex Dumps

In the diagram a solid line marks a relation that always holds and a dashed line marks a conditional one (the conditions are given per relation on the detail page). The full list of relations (18 in total, with evidence and certainty) and the definitions of the main concepts are collected on the knowledge map detail page (in Japanese). Data: JSON-LD / Turtle

2. Serial Communication Is an “Ordered Byte Stream,” Not “Messages”

From the application’s point of view, serial communication looks like “send one command, receive one response.” But at the layer below, what actually flows is just an ordered sequence of bytes.

What you sent with one Write may appear to the other side as:

  • Arriving in one Read
  • Arriving split into two
  • Arriving concatenated with other data

Drop this premise and the app starts assuming “this Read must be this response.” That assumption is often the first land mine in serial communication apps.

Three ways one Write can arriveA diagram showing that what you send with a single Write does not necessarily arrive in a single Read on the other side, but may arrive split into two or concatenated with other data.One WriteArrives in one ReadArrives split into twoArrives concatenated with other dataThis Read is not necessarily this response

Figure 2: How one Write looks on the other side is unknowable until it arrives.

Common assumption Reality
Read(16) returns exactly 16 bytes Depending on arrival and timeouts, you may only get part of it
DataReceived = one message arrived The event is not guaranteed per byte, and it is not on the UI thread
Write returned = the peer finished processing In most cases it is closer to “the sender queued it into a buffer”
The COM list = the current truth of what is connected Enumeration order is unspecified, and results can be stale

For this reason, in serial communication you must define message boundaries yourself, as a protocol. Fixed-length frames, delimiter-based, length + payload + checksum - any shape is fine, but entering implementation with this left vague almost guarantees pain later.

Define the boundaries yourselfA diagram showing that serial communication requires you to define message boundaries yourself as a protocol, that fixed-length frames, delimiter-based framing, or length plus payload plus checksum are all acceptable shapes, and that entering implementation with this left vague leads to pain.Define message boundaries yourselfFixed-length framesDelimiter basedLength + payload + checksumLeaving it vague hurts later

Figure 3: The layer below adds no message boundaries, so decide them first, as a protocol.

3. What to Decide First

Before building a serial communication app, decide at least the items listed here up front.

3.1 Frame Boundaries

Decide which byte sequences count as one message. Fixed length? Newline-delimited? Length-prefixed? Is there a checksum / CRC? If this is vague, the receiver cannot tell “not enough yet” from “corrupted.”

3.2 Text, Binary, or a Mix

Decide up front whether it is an ASCII / UTF-8 line protocol, pure binary, or a mixture. Especially with mixtures like “the command part is a string, the payload is binary, only the tail has a newline,” the boundary collapses quickly unless you make explicit what gets decoded and from where bytes are treated as raw.

3.3 The Meaning of Each Timeout

Timeouts are safer thought of not as one value but separated by meaning.

  • open timeout: until the port opens
  • inter-byte timeout: time with no byte arriving mid-frame
  • response timeout: from command issue to response completion
  • reconnect backoff: the wait interval between reconnection attempts

Timeouts are stable when held not as “insurance against slowness” but as rules that advance the state machine.

Separate timeouts by meaningA diagram showing that timeouts split into open until the port opens, inter-byte for a gap with no byte arriving mid-frame, response from command issue to response completion, and reconnect backoff as the wait between attempts, and that they are held as rules that advance the state machine.One timeout is not enoughopen: until the port opensinter-byte: silenceresponse: response completedreconnect backoffRules that advance the state machine

Figure 4: Separating the four kinds of timeout lets you treat them as state machine rules.

3.4 Flow Control and Line State

The settings you want to make explicit are around here.

  • BaudRate
  • DataBits
  • Parity
  • StopBits
  • Handshake
  • DTR / RTS

Settle this with “8N1 is roughly right” and, depending on the peer device, things will simply stop.

3.5 Separation of Responsibilities

Divide who is responsible for what.

  • Who reads
  • Who writes
  • Who parses
  • Who applies results to business state

Serial communication becomes more fragile the more the UI and the communication are mixed together.

Split responsibilities and keep the UI out of the communicationA diagram showing the split between who reads, who writes, who parses, and who applies results to business state, and that mixing the UI with the communication makes the application more fragile.Separation of responsibilitiesReader and writerParserApplies to business stateMixing UI and communication is fragile

Figure 5: Split reading, writing, parsing, and applying, and keep the UI out of the communication.

3.6 Start / Stop / Reconnect State Transitions

At minimum, states like Closed, Opening, Ready, WaitingResponse, Fault, and Reconnecting should be part of the design. Right after an unplug/replug, the peer may still be booting, and there are times when you must not drag along the previous pending request.

Open requestedopen succeeded + initialization sequence doneopen failed / access error / initialization timeoutcommand sentmatching response frame receivedresponse timeoutI/O error / disconnect detectedfail pending requests and start backoffbackoff elapsedretry limit reached / stopped manuallyClose requestedClosedOpeningReadyFaultWaitingResponseReconnecting

Figure 6: State transitions of a connection session. No edge runs straight from Fault back to Ready.

What matters in this diagram is that there is no edge going directly from Fault back to Ready. After a failure the path always runs through Reconnecting and Opening, rebuilding the receive buffer, parser state, pending requests, and the initialization sequence before returning to Ready. Take a shortcut here and you land in 4.7, believing that a repeated Open() counts as reconnection.

3.7 Logging and Investigability

This is where the worst trouble comes later, almost always. At minimum, you want to record: open / close / reopen times, the port settings used, hex dumps of sent and received frames, checksum / CRC errors, frame timeouts / response timeouts, and the reason for each reconnect.

4. Common Pitfalls

4.1 Believing “One Read = One Message”

This is the most common. Say the peer returns a frame consisting of a header, length, payload, and CRC. If you call Read(buffer, 0, expectedLength) once and assume the return is one whole frame, partial reception breaks it easily.

The three usual breakage modes:

  • Only the length arrived; the payload has not come yet
  • One and a half frames arrived; the second half rolls into the next Read
  • Two frames arrived together; only the first is processed and the rest is discarded

Drawn out, it is nothing more than this: the order the device sent does not line up with the order Read returns.

What the device sent
    [--- frame 1 ---][--- frame 2 ---]

Pattern 1: only part of it arrives
    1st Read -> [ STX ][ LEN ]                      <- the payload has not arrived yet
    2nd Read -> [ payload ][ CRC ][--- frame 2 ---]

Pattern 2: one and a half frames arrive
    1st Read -> [--- frame 1 ---][ first half of frame 2 ]
    2nd Read -> [ second half of frame 2 ]

Pattern 3: two frames arrive together
    1st Read -> [--- frame 1 ---][--- frame 2 ---]   <- easy to process one and drop the rest

In all three patterns, nothing is corrupted; the boundary positions simply do not line up with the number of Read calls. Mistake one for the other, write an implementation that treats a byte count different from what was expected as an error, and it will start counting healthy traffic as errors.

The countermeasure is simple: split the work so that received bytes are accumulated first and a parser carves frames out of the buffer. Skeleton code is in 5.1.

Accumulate first, then carveA diagram showing that treating what Read returns as one whole frame breaks easily on partial reception, so received bytes are accumulated into a buffer first and a parser carves frames out of that buffer.insteadAssuming what Read returns is one framePartial reception breaks it easilyAccumulate received bytes into a bufferThe parser carves out frames

Figure 7: Decouple what Read returns from the frame: accumulate first, then carve.

4.2 Treating DataReceived Directly as a Business Event

.NET’s SerialPort.DataReceived looks convenient, but treating it as “a message has arrived” is dangerous. In practice, regard DataReceived as merely “something seems to have arrived,” do no heavy work inside the handler, and always marshal UI updates back to the UI thread.

4.3 Believing Anyone May Write from Anywhere

A configuration where the UI button, the monitoring timer, the reconnection logic, and the keepalive each call Write directly is fragile. Serial is a byte stream, so depending on the design, command interleaving or follow-up sends while awaiting a response can occur. Especially for request-response protocols and RS-485-style buses, leaning toward a single writer is substantially more stable.

Consolidate transmission into a single writerA diagram showing that letting the UI button, the monitoring timer, keepalive and reconnection each call Write directly causes command interleaving and follow-up sends while a response is still awaited, and that consolidating into a single writer is more stable.insteadUI buttonEach one writes directlyMonitoring timerkeepalive and reconnectInterleaving and follow-up sends occurConsolidate into a single writer

Figure 8: Do not add places that write directly; consolidate transmission into one worker.

4.4 Pushing Everything Through ReadLine() / WriteLine()

For a line-based text protocol, ReadLine() / WriteLine() are convenient. But they are convenient only when it truly is a line protocol. NewLine mismatches, newlines inside the payload, character-encoding differences, or mixed binary will break the boundary quickly.

4.5 Leaving Timeouts Undesigned, at Their Defaults

Drop in a careless synchronous read and you get a plain infinite wait. Worse, a configured timeout does not necessarily apply to every way of reading. Implementations that do synchronous reads on the UI thread, try to express everything with a single timeout, or just add retries, tend to wedge.

4.6 Taking RTS/CTS, XON/XOFF, and DTR/RTS Lightly

Handshaking and control lines matter a great deal against real hardware. With mismatched settings, the symptoms tend to be: transmission occasionally stalls, data is dropped beyond a certain volume, or behavior differs only right after opening. Some devices even read DTR/RTS transitions as boot or mode-switch signals.

4.7 Believing a Repeated Open() Equals Reconnection

Especially with USB-serial, it is entirely normal for the port to disappear temporarily, the old handle to become invalid, and the previous pending request to lose its meaning. Reconnection is safer handled as a bundle covering at least: invalidating the session, failing pending requests, stopping the reader / writer, reopening after backoff, and re-running device initialization.

Reconnection is not a repeated OpenA diagram showing that with USB-serial the port can disappear and the old handle become invalid, so reconnection bundles invalidating the session, failing pending requests, stopping the reader and writer, reopening after a backoff, and re-running device initialization.Invalidate the sessionFail pending requestsStop the reader / writerReopen after backoffRe-run device initializationRepeating Open alone is not enough

Figure 9: Treat reconnection as rebuilding the session, doing this whole sequence together.

4.8 Treating COM Port Enumeration as Truth

GetPortNames() is convenient, but appearing in the list and being openable are not the same. Blindly trusting last time’s COM7, auto-selecting the first enumeration result, or treating presence in the list as validity - these implementations cause operational trouble.

4.9 Thin Send/Receive Logs

TimeoutException, IOException, and Port closed alone tell you almost nothing. If you record send/receive timestamps, the port profile, hex dumps of traffic, parser errors, which request a response belongs to, and the trigger for each reconnect, triage advances considerably.

Decide the format up front and you can grep it and diff it later. For example, settle on a one-line format like this.

2026-03-19T10:23:41.512+09:00  COM3  TX  req=00A7  len=5   02 01 10 3F 9C
2026-03-19T10:23:41.518+09:00  COM3  RX  req=00A7  len=3   02 01
2026-03-19T10:23:41.531+09:00  COM3  RX  req=00A7  len=6   10 00 4B 02 01 11
2026-03-19T10:23:41.532+09:00  COM3  PARSE req=00A7  frame=02 01 10 00 4B  result=OK
2026-03-19T10:23:41.532+09:00  COM3  PARSE req=-     frame=02 01 11        result=INCOMPLETE  need=2
2026-03-19T10:23:43.540+09:00  COM3  ERR req=00A8  reason=response-timeout  elapsed=2008ms
2026-03-19T10:23:43.541+09:00  COM3  STATE Ready -> Fault  reason=response-timeout

There are three goals here.

  • Keep RX lines and PARSE lines separate. RX says how many bytes arrived; PARSE says how many frames could be carved out. In the example above, one frame arrives across the second and third RX lines, and the remainder becomes the start of the next frame. Record the two kinds mixed together and you cannot tell afterwards whether the split described in 4.1 is happening
  • Make sends and receives pairable through req=. Which response belongs to which command cannot be reconstructed from the logs alone after the fact
  • Record each state transition on one line. With a transition such as Ready -> Fault and its reason in the log, the trigger for a reconnect can be traced directly

Hex dumps eat storage, so the realistic arrangement has two tiers: a bounded amount of raw log in a ring buffer, and the summary log retained long term.

Three goals for the traffic logA diagram showing three goals, keeping RX lines separate from PARSE lines, pairing sends and receives through req, and recording each state transition on one line, together with the two-tier arrangement of a bounded raw log in a ring buffer and a summary log retained long term.Keep RX and PARSE lines separateA log you can triage from laterPair sends and receives through reqRecord state transitions on one lineRaw log in a ring buffer, summary log kept long term

Figure 10: Recording bytes arrived and frames carved separately makes the slip traceable later.

5. Best Practices

What pays off most is separating responsibilities.

  • reader: only reads bytes from the port
  • writer: only writes, in order, from the outbound queue
  • parser: only carves frames out of the byte stream
  • protocol: handles request-response pairing and checksums
  • app state: only updates business state

For reception, rather than treating each Read return as a business unit, the stable configuration accumulates into a buffer first and lets the parser carve out frames. Consolidating transmission into one worker - pushing the actual Write toward a single writer - reduces ordering slips.

For timeouts too, rather than settling on one number, separating them by meaning - open, inter-byte, response, reconnect - makes root-cause triage easier. Hold the port settings as a profile rather than ad-hoc code values, and log them at startup; on-site investigation becomes much easier.

Think of reconnection not as a mere reopen but as session regeneration. Rebuild everything - receive buffer, parser state, pending requests, the initialization sequence, and the readiness check - and you reduce the “breaks only occasionally” class of reconnection bugs.

Finally, we recommend keeping both raw logs and summary logs. Raw hex dumps and open / close history are strong for investigation; summaries of request IDs and retry counts are strong for operations.

A pipeline per responsibilityA diagram showing the separation where the reader reads bytes from the port, the parser carves frames out of the accumulated buffer, the protocol handles pairing and checksums, app state updates business state, and every caller only enqueues while the writer alone writes.Portreader: only readsparser: carves out framesprotocol: pairing and checksumapp state: updates business stateCallers only enqueuewriter: only writes, in order

Figure 11: The receive and send pipelines. Each role does exactly one job.

From here, skeleton code for just the two places that pay off the most. It assumes .NET 8 / C# 12 with a reference to the System.IO.Ports package.

5.1 Receiving: Accumulate First, Then Carve

As an example, assume a frame of STX(0x02), LEN(1 byte), payload(LEN bytes), and CRC16(2 bytes, little endian). Any shape works; the point is that frames are cut according to this definition, not according to what a Read happens to return.

using System;
using System.Buffers.Binary;
using System.Collections.Generic;
using System.Diagnostics;

public static class Crc16Modbus
{
    // CRC-16/MODBUS: initial value 0xFFFF, right shift with polynomial 0xA001
    public static ushort Compute(ReadOnlySpan<byte> data)
    {
        ushort crc = 0xFFFF;
        foreach (var b in data)
        {
            crc ^= b;
            for (var i = 0; i < 8; i++)
            {
                crc = (crc & 1) != 0 ? (ushort)((crc >> 1) ^ 0xA001) : (ushort)(crc >> 1);
            }
        }

        return crc;
    }
}

public static class Frame
{
    public const byte Stx = 0x02;
    public const int HeaderLength = 2;   // STX + LEN
    public const int CrcLength = 2;

    public static byte[] Build(ReadOnlySpan<byte> payload)
    {
        // LEN is 1 byte, so anything from 256 bytes up wraps around on the cast.
        // The payload is still copied in full, so the receiver cuts the frame at
        // the truncated length and reads the middle of the payload as the CRC.
        // Every frame boundary after that collapses as well. Whether to split the
        // payload or widen LEN to 2 bytes is a protocol decision, so this code
        // only rejects the input
        if (payload.Length > byte.MaxValue)
        {
            throw new ArgumentOutOfRangeException(
                nameof(payload),
                $"A single frame payload can be at most {byte.MaxValue} bytes because LEN is 1 byte.");
        }

        var frame = new byte[HeaderLength + payload.Length + CrcLength];
        frame[0] = Stx;
        frame[1] = (byte)payload.Length;
        payload.CopyTo(frame.AsSpan(HeaderLength));

        var body = frame.AsSpan(0, frame.Length - CrcLength);
        BinaryPrimitives.WriteUInt16LittleEndian(frame.AsSpan(frame.Length - CrcLength), Crc16Modbus.Compute(body));
        return frame;
    }
}

public sealed class FrameParser
{
    private readonly List<byte> _buffer = new();

    /// <summary>The inter-byte timeout from 3.3. How long to keep assembling a frame before giving up.</summary>
    private static readonly TimeSpan AssemblyTimeout = TimeSpan.FromMilliseconds(200);

    /// <summary>When the candidate currently being assembled started waiting (a monotonic value).</summary>
    private long _pendingSince;

    /// <summary>Reports a frame discarded because the CRC did not match. Always subscribe so it reaches the log.</summary>
    public event Action<byte[]>? FrameDiscarded;

    /// <summary>Reports that assembly was abandoned and the parser resynchronized. A steadily rising count points at the wiring or the settings.</summary>
    public event Action<int>? Resynchronized;

    /// <summary>Accumulates received bytes and returns only the frames that could be carved out.</summary>
    public IReadOnlyList<byte[]> Append(ReadOnlySpan<byte> received)
    {
        foreach (var b in received)
        {
            _buffer.Add(b);
        }

        var frames = new List<byte[]>();

        while (true)
        {
            // 1. Discard bytes until STX is at the head. Noise and leftovers from the previous frame are absorbed here
            var stxIndex = _buffer.IndexOf(Frame.Stx);
            if (stxIndex < 0)
            {
                _buffer.Clear();
                _pendingSince = 0;   // no candidate left, so stop timing the wait as well
                break;
            }

            if (stxIndex > 0)
            {
                // The head of the candidate changed, which means assembly of a different frame starts
                _buffer.RemoveRange(0, stxIndex);
                _pendingSince = 0;
            }

            // 2. Has enough arrived to read the length?
            if (_buffer.Count < Frame.HeaderLength)
            {
                if (GiveUpOnStaleCandidate()) { continue; }
                break;   // not corrupted, just not enough yet
            }

            int payloadLength = _buffer[1];
            int frameLength = Frame.HeaderLength + payloadLength + Frame.CrcLength;

            // 3. Is one whole frame present?
            if (_buffer.Count < frameLength)
            {
                // At this point, not enough yet and LEN corrupted by noise cannot be
                // told apart. If noise or a false STX turns LEN into 255, the parser
                // keeps swallowing the correct frames that arrive afterwards as
                // payload, and nothing surfaces until 259 bytes are in and the CRC
                // check fails. On a device with light traffic this looks like minutes
                // of no response. Cap the wait, and past the cap drop the candidate
                // and look for STX again
                if (GiveUpOnStaleCandidate()) { continue; }
                break;   // leave here and wait for the next receive
            }

            var frame = _buffer.GetRange(0, frameLength).ToArray();
            _buffer.RemoveRange(0, frameLength);
            _pendingSince = 0;

            // 4. Discard anything whose CRC does not match, and always report the discard
            var expected = BinaryPrimitives.ReadUInt16LittleEndian(frame.AsSpan(frame.Length - Frame.CrcLength));
            if (expected == Crc16Modbus.Compute(frame.AsSpan(0, frame.Length - Frame.CrcLength)))
            {
                frames.Add(frame);
            }
            else
            {
                // Whether to drop a whole frame here or drop just one STX byte and re-read is a design decision.
                // The former is simpler, the latter is stronger when LEN itself was noise. Pick one and write it down.
                FrameDiscarded?.Invoke(frame);
            }
        }

        return frames;
    }

    /// <summary>
    /// If the candidate being assembled has exceeded AssemblyTimeout, discard just the leading STX byte.
    /// Returns true after discarding, and the caller re-reads from the next STX.
    /// A whole frame is not discarded because the real STX may sit inside this candidate.
    /// </summary>
    private bool GiveUpOnStaleCandidate()
    {
        if (_pendingSince == 0)
        {
            // The moment the wait begins. A wall clock can jump on NTP sync, so measure with a monotonic value
            _pendingSince = Stopwatch.GetTimestamp();
            return false;
        }

        if (Stopwatch.GetElapsedTime(_pendingSince) < AssemblyTimeout)
        {
            return false;
        }

        _buffer.RemoveAt(0);
        _pendingSince = 0;
        Resynchronized?.Invoke(_buffer.Count);
        return true;
    }
}

GiveUpOnStaleCandidate is the actual implementation of the inter-byte timeout from 3.3. Without it, when noise or a false STX corrupts LEN into a large value (255, say), the parser keeps treating the situation as “not enough yet.” It then swallows even the correct frames that arrive afterwards as part of the corrupted payload, and nothing surfaces until 259 bytes have accumulated and the CRC check fails. On a device with light traffic, that looks like several minutes of no response. Only one STX byte is discarded because the real STX may be buried inside the candidate.

Two premises are worth stating. This timeout is only evaluated when Append is called. If the line goes completely silent, nothing happens on the parser side, so that case is caught by the caller’s response timeout (5.2). Second, derive the value of AssemblyTimeout from the baud rate and the frame length. The lower bound is the transmission time of one byte multiplied by the largest frame you expect, plus some margin; anything shorter throws away healthy frames midway. Logging how often Resynchronized fires gives you grounds for suspecting the wiring or the baud rate setting.

A corrupted LEN swallows correct framesA diagram showing that when noise or a false STX corrupts LEN into a large value the parser keeps treating it as not enough yet and swallows even the correct frames that follow as payload, so nothing surfaces until the CRC check fails, and that a timeout which discards one STX byte restores synchronization.fixNoise or a false STX corrupts LENThe parser keeps waiting for moreIt swallows the correct frames tooLooks unresponsive until the CRC failsOn timeout drop one STX byte and resync

Figure 12: Without an inter-byte timeout, a corrupted LEN keeps swallowing the frames that follow.

Keep the read side to reading from the port and handing bytes to the parser, nothing more. Start writing business logic here and whatever Read happens to return turns into a business unit.

using System;
using System.IO.Ports;
using System.Threading;
using System.Threading.Tasks;

public sealed class SerialReader
{
    private readonly SerialPort _port;
    private readonly FrameParser _parser;
    private readonly byte[] _readBuffer = new byte[4096];

    public SerialReader(SerialPort port, FrameParser parser)
    {
        _port = port;
        _parser = parser;
    }

    public event Action<byte[]>? FrameReceived;

    public async Task RunAsync(CancellationToken token)
    {
        while (!token.IsCancellationRequested)
        {
            int count;
            try
            {
                count = await _port.BaseStream.ReadAsync(_readBuffer.AsMemory(), token);
            }
            catch (OperationCanceledException)
            {
                break;
            }

            if (count <= 0)
            {
                continue;
            }

            foreach (var frame in _parser.Append(_readBuffer.AsSpan(0, count)))
            {
                FrameReceived?.Invoke(frame);
            }
        }
    }
}

Not using DataReceived is deliberate. As covered in 4.2, it carries no more meaning than “something seems to have arrived,” so owning the read loop yourself makes state and timeouts easier to manage.

5.2 Sending: Consolidate into a Single Writer

On the send side, the key is to never create a state in which anything can Write. Anyone may call the code that enqueues; only one worker performs the actual Write.

using System;
using System.IO.Ports;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;

public sealed class SingleWriter
{
    private sealed record Outbound(byte[] FrameBytes, TaskCompletionSource<byte[]> Completion);

    /// <summary>Upper bound of the send queue. Derive it from one device round trip multiplied by the queue depth you accept.</summary>
    private const int QueueCapacity = 64;

    private readonly SerialPort _port;
    private readonly TimeSpan _responseTimeout;

    // Do not leave this unbounded. If the UI, timers, and workers enqueue faster
    // than the device turns requests around, frames and TaskCompletionSource
    // instances pile up without limit and memory keeps growing even though the
    // device is responding. Set a bound, and hand overflow back to the sender
    private readonly Channel<Outbound> _queue = Channel.CreateBounded<Outbound>(
        new BoundedChannelOptions(QueueCapacity)
        {
            // When it is full, TryWrite returns false and the caller learns
            // immediately that the queue is backed up. DropOldest is not used:
            // whoever enqueued is awaiting a Task, so a silent drop means it
            // never returns
            FullMode = BoundedChannelFullMode.Wait,
            SingleReader = true,
        });

    private Outbound? _inFlight;

    public SingleWriter(SerialPort port, TimeSpan responseTimeout)
    {
        _port = port;
        _responseTimeout = responseTimeout;
    }

    /// <summary>Safe to call from the UI or from a timer. Only one worker performs the actual Write.</summary>
    public Task<byte[]> SendAsync(ReadOnlySpan<byte> payload)
    {
        var item = new Outbound(
            Frame.Build(payload),
            new TaskCompletionSource<byte[]>(TaskCreationOptions.RunContinuationsAsynchronously));

        if (!_queue.Writer.TryWrite(item))
        {
            // Either the queue is full or the worker has already stopped. In both
            // cases report back to the caller that the item could not be enqueued.
            // Drop it silently and the Task being awaited never returns
            item.Completion.TrySetException(new InvalidOperationException(
                $"Could not enqueue into the send queue (capacity {QueueCapacity}, or the worker has stopped)."));
        }

        return item.Completion.Task;
    }

    /// <summary>Call this when the parser carves out a frame. It binds the frame to the one request awaiting a response.</summary>
    public void OnFrameReceived(byte[] frame)
    {
        var pending = Interlocked.Exchange(ref _inFlight, null);
        pending?.Completion.TrySetResult(frame);
    }

    public async Task RunAsync(CancellationToken token)
    {
        try
        {
            await foreach (var item in _queue.Reader.ReadAllAsync(token))
            {
                Interlocked.Exchange(ref _inFlight, item);
                try
                {
                    await _port.BaseStream.WriteAsync(item.FrameBytes.AsMemory(), token);
                }
                catch (Exception ex)
                {
                    // A pulled cable, a closed port, or a cancellation lands here.
                    // Leave without completing this item and the caller awaiting
                    // SendAsync waits forever
                    Interlocked.Exchange(ref _inFlight, null);
                    item.Completion.TrySetException(ex);
                    throw;
                }

                // Waiting for the response here is what keeps the next command from cutting in
                var timeout = Task.Delay(_responseTimeout, token);
                var finished = await Task.WhenAny(item.Completion.Task, timeout);

                if (finished != item.Completion.Task)
                {
                    // On a stop request, Task.Delay is cancelled and finishes first too.
                    // Without this check, an orderly shutdown is treated as a timeout
                    // and the caller receives a TimeoutException
                    token.ThrowIfCancellationRequested();

                    // The response can arrive after WhenAny has already picked the
                    // timeout. In that case OnFrameReceived has taken _inFlight and
                    // completed this item successfully. Failing to take it means the
                    // response won, and TrySetException here would be a no-op. Miss
                    // that and continue as far as the throw, and the caller has its
                    // result while only the worker dies. Settle the race with
                    // CompareExchange
                    if (Interlocked.CompareExchange(ref _inFlight, null, item) != item)
                    {
                        // The response won. OnFrameReceived sets the completion, right after taking it
                        await item.Completion.Task;
                        continue;
                    }

                    item.Completion.TrySetException(new TimeoutException("No response was received."));

                    // Once this times out, the connection can no longer be trusted.
                    // The reason give up and send the next one is not good enough is
                    // written below: this protocol carries no request ID, so a late
                    // response to A ends up bound to the next command B. Following
                    // the state diagram in 3.6, drop to Fault and rebuild the session
                    throw new TimeoutException("No response, so the session will be rebuilt.");
                }
            }
        }
        finally
        {
            // Whatever stopped the worker, always complete everything left waiting:
            // both the item awaiting a response and whatever is still queued unsent
            var stopped = new OperationCanceledException("The send worker has stopped.");
            Interlocked.Exchange(ref _inFlight, null)?.Completion.TrySetException(stopped);
            _queue.Writer.TryComplete();
            while (_queue.Reader.TryRead(out var pending))
            {
                pending.Completion.TrySetException(stopped);
            }
        }
    }
}

Stopping the whole worker on a timeout looks heavy-handed, but it is a necessary measure. This frame carries no request ID. The receiver therefore cannot tell which command the frame that just arrived answers, and OnFrameReceived has no choice but to bind it mechanically to the one request awaiting a response.

Keep sending the next command after a timeout here and this is what happens.

  1. Command A is sent. No response arrives within the configured time, so it is treated as a timeout
  2. Command B is sent next
  3. The late response to A is handed to the caller as the response to B

From the caller’s point of view, it sent B and got A’s value back. The value is well formed, so validation passes too, which makes this the hardest breakage of all to find. The state diagram in 3.6 has no edge running directly from Fault back to Ready precisely because this path exists. A timeout is not “one failed request” but the judgement that “this connection can no longer be trusted,” and recovery from a state where you do not know what is left in the receive buffer can only be guaranteed by session regeneration: closing the port and opening it again.

If you can reach into the protocol, the more fundamental fix is to carry a request ID in the frame and match responses against it. Then a late response is simply discarded as an unknown ID, and there is no need to rebuild the connection on every single timeout.

A late response bound to the wrong commandA diagram showing that in a protocol without request IDs, sending command B after command A has timed out lets the late response to A reach the caller as the response to B, which is why the session is rebuilt through Fault after a timeout.DeviceWorkerCallerDeviceWorkerCallerNo response in time, so it times outThis is why a timeout drops to FaultSend command ATransmit ASend command BTransmit BLate response to AHanded over as the response to B

Figure 13: Without a request ID, a late response gets bound to the next command.

Finally, wire it all together. Section 3.4 runs all the way through holding the port settings as a profile in one place and logging them at startup.

using System;
using System.IO.Ports;
using System.Threading;
using System.Threading.Tasks;

// Keep the settings decided in 3.4 together in one place, not as ad-hoc values
using var port = new SerialPort("COM3", 115200, Parity.None, 8, StopBits.One)
{
    Handshake = Handshake.None,
    DtrEnable = true,
    RtsEnable = true,
    ReadTimeout = 500,
    WriteTimeout = 500,
};

Console.WriteLine($"open {port.PortName} baud={port.BaudRate} data={port.DataBits} parity={port.Parity} " +
                  $"stop={port.StopBits} handshake={port.Handshake} dtr={port.DtrEnable} rts={port.RtsEnable}");
port.Open();

var parser = new FrameParser();
var writer = new SingleWriter(port, TimeSpan.FromSeconds(2));
var reader = new SerialReader(port, parser);

// Always subscribe to the events you defined. Forget this and neither discarded frames nor responses ever surface
parser.FrameDiscarded += frame => Console.Error.WriteLine($"crc error: {Convert.ToHexString(frame)}");
reader.FrameReceived += writer.OnFrameReceived;

using var cts = new CancellationTokenSource();
var readerTask = reader.RunAsync(cts.Token);
var writerTask = writer.RunAsync(cts.Token);

var request = new byte[] { 0x10, 0x00 };
try
{
    var response = await writer.SendAsync(request);
    Console.WriteLine($"response: {Convert.ToHexString(response)}");
}
finally
{
    // Even when the send fails, always stop the workers before leaving. Skip this
    // and the reader/writer keep touching the stream after the using disposal has
    // closed the SerialPort, and nobody observes the exceptions that result
    cts.Cancel();
    try
    {
        await Task.WhenAll(readerTask, writerTask);
    }
    catch (OperationCanceledException)
    {
        // Shutdown caused by the stop request. Treat this as the normal path
    }
    catch (Exception ex)
    {
        // A worker-side failure. Rethrowing here would mask the original reason
        // for the failure (the exception from SendAsync), so only record it
        Console.Error.WriteLine($"worker stopped with error: {ex.Message}");
    }
}

Putting cts.Cancel() and Task.WhenAll inside finally is not a matter of style. In serial communication, a failing SendAsync is routine rather than exceptional: the device does not answer, the cable comes loose, the write times out. Let that propagate straight up and you reach the using disposal without having stopped the workers. The reader and writer keep touching the stream after SerialPort has been closed, and nobody observes the exceptions raised there. In a long-running application this piles up gradually, as a leftover worker for every failed operation. The exception from the finally block is not rethrown, so that it does not mask the original reason for the failure (the exception from SendAsync).

Always stop the workers, even on failureA diagram showing that a failing SendAsync is routine in serial communication and that letting it propagate straight up disposes the SerialPort without stopping the workers, so the reader and writer keep touching a closed stream and nobody observes the exceptions, which is why cancellation and awaiting belong in finally.SendAsync fails, which is routineCancel and await inside finallyStop the workers before disposalSkip it and a closed stream keeps being touchedA long-running app keeps a worker per failure

Figure 14: A failed send is exactly when finally has to stop the workers before leaving.

With this shape in place, whatever you want to add later - retry, keepalive, reconnect - all fits either on the enqueueing side or on the worker side. The number of places that call Write directly does not grow, so neither do the causes of ordering slips.

6. The Checklist to Run First

  • Are message boundaries written down explicitly?
  • Is reception structured as byte accumulation -> frame extraction?
  • Are you treating DataReceived as message arrival?
  • Is there synchronous I/O on the UI thread?
  • Is transmission a single writer?
  • Are timeouts split by meaning rather than being one value?
  • Are Handshake / DTR / RTS explicit?
  • Does reconnection rebuild the session?
  • Are raw hex dumps recorded?
  • Have you tested physical unplug/replug and mid-stream disconnection?

If several of these items look shaky, it is worth pausing once before going to production.

7. Summary

Finally, the key points one more time.

  • Serial communication is a byte stream, not messages
  • Read units and message units do not coincide
  • Boundaries must be defined as a protocol
  • Treating DataReceived directly as a business event is fragile
  • Separate send/receive responsibilities, and push transmission toward a single writer
  • Split timeouts by meaning, and design reconnection at the session level
  • Logs that include raw hex dumps make later investigation much easier

In other words, in a serial communication app, how you interpret the byte stream and how you control time and state matters far more than opening the port. Just separating these concerns in the initial design substantially reduces the “breaks only occasionally” class of communication defects.

Interpretation and control matter more than openingA diagram showing that in a serial communication app how you interpret the byte stream and how you control time and state matters more than opening the port, and that separating these concerns in the initial design reduces the defects that break only occasionally.Opening the portThis is not the hard partInterpreting the byte streamSeparate them in the initial designControlling time and stateFewer defects that break only occasionally

Figure 15: What matters is not opening the port but designing the interpretation and the control.

8. References

Recent articles sharing the same tags. Deepen your understanding with closely related topics.

These topic pages place the article in a broader service and decision context.

This article connects naturally to the following service pages.

Windows App Development

Windows applications that include serial communication are more stable when designed end to end, covering receive handling, state transitions, reconnection, and UI separation.

Frequently Asked Questions

Common questions about the topic of this article.

If I call Read(16) in serial communication, do I receive exactly 16 bytes?
Not necessarily. Serial communication is an ordered byte stream, and message boundaries do not appear on their own. What you sent with a single Write may arrive at the other side split into two, or concatenated with other data. Typical breakage: the length arrives but the payload has not, one and a half frames arrive, or two frames arrive together. The fix is to split the work so that received bytes are accumulated into a buffer first and a parser carves frames out of that buffer.
What should I watch out for when using the .NET SerialPort.DataReceived event?
DataReceived is not guaranteed to fire once per received byte, and it does not run on the UI thread. Treating it as a notification that one message has arrived is dangerous. In practice, regard it as no more than a hint that something seems to have arrived, do no heavy work inside the handler, and always marshal UI updates back to the UI thread. Accumulating received bytes first and letting a parser carve frames out of them is the stable structure.
How should timeouts be designed for serial communication?
One timeout is not enough; separating them by meaning is more stable. Use an open timeout until the port opens, an inter-byte timeout for the gap with no byte arriving mid-frame, a response timeout from command issue to response completion, and a reconnect backoff as the wait interval between reconnection attempts. Timeouts are more stable when held as rules that advance the state machine rather than as insurance against slowness. Note as well that carelessly leaving a synchronous read at its defaults gives you a plain infinite wait.
Why does a USB-to-serial adapter fail to recover after the cable is unplugged and plugged back in?
Because with USB-serial it is entirely normal for the port to disappear temporarily, the old handle to become invalid, the COM number to change, and the previous pending request to lose its meaning. Calling Open() again is not enough to count as reconnection. Design it as session regeneration that bundles invalidating the session, failing pending requests, stopping the reader and writer, reopening after a backoff, and re-running the device initialization sequence; that reduces the reconnection bugs that break only occasionally.

Author Profile

Profile page for the article author.

Go Komura

Representative of KomuraSoft LLC

Focused on Windows software development, technical consulting, and investigations into failures that are difficult to reproduce.

Back to the Blog