← All posts

Blog

readString() betrays you in four ways, and only one of them looks like a bug

Troniction

A teal stream of data meeting a coral cut-off, with the last fragments falling away

You send a message over Bluetooth, the Arduino reads it with readString(), and sometimes it works. Not always. Not reliably. And nothing in the sketch says which times were which.

It is not one fault. It is four, and only the fragmenting is visible. The other three are a number that lies, a setting that reaches further than you think, and a buffer that throws away the end of your message rather than the start.

The worst of them is this: parseInt() returns 0 when it times out — the same value a working sensor sends when it reads zero. That one never looks like a failure at all.

First, what is actually happening

readString() and its relatives are timeout-driven. They do not wait for a message; they wait for a clock. The default is one second, and the timeout counts as success — you get back whatever arrived, with no indication that anything is missing.

That much is covered in the article on a link that connects and carries nothing. What follows is what that leaves out.

Betrayal 1 — parseInt() returns 0, and you cannot tell

This is the one to fix first, because it is the only one that fails silently and permanently.

Read the timeout path in the Arduino core's Stream.cpp and it is a plain return 0, with a comment sitting next to it that says, in so many words, zero returned if timeout.

There is no error value. No -1, no sentinel, no way to ask afterwards whether it worked. So:

What happenedWhat parseInt() gives you
The phone sent 00
The phone sent nothing at all0
The phone sent abc0
The link dropped mid-message0

A throttle that reads 0 when the message is lost is a throttle that returns to idle every time the radio hiccups, and your logs will show a perfectly plausible zero. Nothing in the sketch can distinguish these four cases, which is why this survives testing: on the bench, messages arrive, and the bug never fires.

readString() has the same shape — an empty String for "nothing arrived" — but an empty string is at least obviously empty. A zero is a number, and numbers get used.

Betrayal 2 — it is eight functions, not one

setTimeout() does not belong to Serial. It comes from the Stream class that Serial, SoftwareSerial and every similar object inherit from, and it governs all eight of these:

What it doesThe functions
Search the streamfind() · findUntil()
Parse a numberparseInt() · parseFloat()
Read raw bytesreadBytes() · readBytesUntil()
Read textreadString() · readStringUntil()

This matters in one specific way, and it has a name: you fix the function you were looking at, and leave the other one bleeding.

A sketch that reads a command with readStringUntil('\n') and a value with parseInt() has two timeout-driven calls on the same port. Rewrite the first one to be safe and the second is still there, still waiting a second, still returning 0 when it gives up. The symptom improves just enough to look fixed.

⚠️ setTimeout() is per port, not per call. Set it once and every one of those eight functions on that object uses the new value, including the ones in library code you did not write.

Betrayal 3 — the 64-byte cliff, and it eats the end

Here is the part almost nobody mentions, and it produces a symptom identical to fragmentation.

The receive buffer is 64 bytes. That is SERIAL_RX_BUFFER_SIZE on any Arduino with at least 1 KB of RAM, and SoftwareSerial uses a 64-byte buffer too. Bytes arrive by interrupt and sit there until your sketch reads them.

Now the part that matters. When the buffer is full and another byte arrives, the library does not make room by dropping the oldest. It discards the newest byte and keeps what it already has:

  • The beginning of your message survives.
  • The end of it does not.
  • Nothing is reported unless you go and ask.

So a 90-byte message becomes a clean-looking 64-byte message. It is not corrupted, it is not garbled — it is simply shorter, and it looks exactly like the fragmentation everyone blames on the radio. Garbled characters are a different symptom with a single, much simpler cause.

SoftwareSerial will at least tell you, if you ask:

#include <SoftwareSerial.h>
SoftwareSerial bt(10, 11);   // RX, TX

void setup() {
  Serial.begin(9600);
  bt.begin(9600);
}

void loop() {
  if (bt.overflow()) {
    Serial.println("overflow: bytes were dropped");
  }

  while (bt.available()) {
    bt.read();                // your real reader goes here
  }

  delay(50);                  // deliberately too slow, to provoke it
}

Run that, send something long, and watch it print. The delay(50) is there to cause the problem on purpose — which brings us to the advice you will find everywhere.

The popular fix that makes it worse

Search this problem and the most common answer is "add a delay so the data has time to arrive". It appears to work, and that is the trap.

A delay() before reading does let the buffer fill, so more messages happen to be complete when you finally look, and the fault seems to go away. What you have actually built is a sketch that:

  • Stops responding to anything else for the whole delay.
  • Sits closer to the 64-byte limit, where bytes are lost for real rather than merely late.
  • Fails again the instant something sends faster than you guessed.

It converts an intermittent bug into a slower intermittent bug. The fix is not to wait longer before reading — it is to stop reading on a timer at all.

Betrayal 4 — String on a 2 KB heap

readString() returns a String, and it builds it one character at a time.

On an Uno that is more expensive than it sounds. In the AVR core's WString.cpp, growing a String calls reserve() with the exact new length — there is no rounding up, no spare capacity kept in hand. Every appended character therefore reallocates the buffer.

A forty-character message is forty realloc calls, on a chip with 2,048 bytes of RAM in total. That is not a crash; it is the kind of slow heap churn that makes a sketch fine for ten minutes and strange after an hour.

A fixed char array does none of this. It is allocated once, at a size you chose, and it cannot grow into memory you needed for something else.

The pattern that does not betray you

None of the eight functions appear here. Nothing waits on a clock, so there is no timeout to expire and no fragment to mistake for a message:

#include <SoftwareSerial.h>
SoftwareSerial bt(10, 11);   // RX, TX

const byte MAX = 32;
char buf[MAX];
byte n = 0;

void setup() {
  Serial.begin(9600);
  bt.begin(9600);
}

void loop() {
  while (bt.available()) {
    char c = bt.read();

    if (c == '\n') {          // the sender says: that is the whole message
      buf[n] = '\0';
      handle(buf);
      n = 0;
    } else if (n < MAX - 1) {
      buf[n++] = c;
    } else {                  // MAX-1 bytes and still no terminator
      n = 0;                  // drop it and resync, rather than truncate silently
    }
  }

  // Everything else you want to do keeps running, because nothing above waits.
}

void handle(char *msg) {
  Serial.print("got: ");
  Serial.println(msg);
}

available() reports how many bytes are waiting. read() takes exactly one. The message is complete when the sender says it is, not when a second has elapsed.

Both sketches on this page compile for an Arduino Uno — checked with arduino-cli on 2026-09-28, at 3,176 and 3,302 bytes of flash.

Reading a number, without parseInt()

Once a whole message is in buf, a number is atoi(buf) — and the ambiguity is gone, because you now know something parseInt() could never tell you: whether a message arrived at all.

void handle(char *msg) {
  if (msg[0] == '\0') return;      // empty line: nothing was sent
  int value = atoi(msg);
  Serial.println(value);
}

atoi() does not wait, does not time out and does not read the port. It converts text you already have. If handle() was called, a terminator arrived, so a 0 here is a real zero that somebody sent — which is exactly the distinction that was missing.

⚠️ atoi() returns 0 for text that is not a number, so "abc" is still 0. If that matters, check the characters are digits before converting, or use strtol(), which reports where it stopped parsing. The difference from parseInt() is that you are now deciding what a bad message means instead of being handed a number with its history erased.

What the terminator actually costs you

This pattern is better, not free, and it is worth knowing the price before you commit to it.

  1. The sender must end every single message with the terminator. Miss one and that message never completes.
  2. A missed terminator makes the next message join the previous one, so one dropped byte corrupts two messages rather than one.
  3. That is what the else branch is for. Without it a sender that never sends a newline fills the buffer and then writes past the end of the array, which is a far worse bug than the one you started with.
  4. You have to pick MAX. Too small silently discards long messages; too large wastes RAM you do not have much of.

⛔ The n < MAX - 1 check is not optional. It is the difference between dropping a message and corrupting memory. A radio link is an easy way to trigger that by accident.

If you can send a single character per command, do that instead. One byte is never split, there is nothing to accumulate, and every problem on this page disappears — no timeout, no terminator, no buffer to overflow at realistic speeds.

What if that was not it

If the pattern above still misbehaves, the fault is probably not in the reading:

  1. Check the baud rates match on both ends, because a mismatch produces wrong bytes rather than missing ones.
  2. Check you are reading the object the module is wired to, not the USB port.
  3. Check whether SoftwareSerial itself is the limit — it is a software imitation of a UART, and it has less timing margin than the real one.
  4. Check the sender. Load an echo sketch, press the app's buttons, and see exactly what it transmits — including any trailing newline you did not know about.

Every symptom in this subject sorts the same way, by which part of the chain is misbehaving, on the Arduino Bluetooth fixes index.

Common questions

Why does parseInt() return 0 when nothing arrived?
Because that is what the code does. In the Arduino core's Stream.cpp the timeout path is a plain return 0, with the comment zero returned if timeout beside it. There is no error value and no way to ask whether it succeeded, so a message that never arrived and a sensor genuinely reporting zero produce exactly the same integer. This is the one failure on this page that never looks like a failure.
Does setTimeout() only affect readString?
No, and this catches people who think they have fixed it. setTimeout comes from the Stream class, and it governs eight functions: find, findUntil, parseInt, parseFloat, readBytes, readBytesUntil, readString and readStringUntil. Setting it for one sets it for all of them on that port, so a sketch can be fixed in the place you were looking at and still broken in the place you were not.
How much data can arrive before the Arduino loses some?
64 bytes. Both the hardware serial port and SoftwareSerial use a 64-byte receive buffer on an Uno, and when it is full the newest byte is discarded while the older data is kept. That is the wrong way round for a message: you lose the end, not the beginning, so a truncated message looks exactly like fragmentation and gets blamed on the radio.
Should I add a delay to give the data time to arrive?
No, though it will appear to work. A delay lets the buffer fill before you read, so more messages arrive whole and the problem seems to go away. What you have actually done is make the sketch unresponsive for that whole period, and moved yourself closer to the 64-byte limit where bytes are dropped for real. It fails again the moment anything sends faster.
Is String actually a problem on an Uno?
It can be, and the reason is specific. readString builds its result by appending one character at a time, and in the AVR core's WString.cpp reserve allocates the exact size asked for with no rounding up — so every single character reallocates the buffer. An Uno has 2,048 bytes of RAM in total. A char array of a known size does not do any of that.

Still not connecting?

Arduino Bluetooth — Make It Connect is 62 pages of every way the link fails, why, and the fix — HC-05, HC-06 and HM-10 BLE, including the clone family almost nothing covers. $9.