Tutorial · Beginner · 30 min

Why delay() Breaks Robots: Timing With millis()

A blocked loop cannot see anything. What delay() costs a robot in millimetres, the millis() pattern that replaces it, and the rollover bug in the obvious fix.

Introduction

Almost every robot on this site runs a loop that has to do several things at once: read a sensor, update a controller, drive the motors, maybe answer a radio. delay() is the first timing tool anyone learns, and it is the one that makes all of that impossible.

The problem is not that delay() is slow. It is that during a delay() the robot is not doing anything at all — not reading its sensors, not steering, not noticing that it is about to hit a wall. It is a program that has voluntarily stopped.

This page is about what that actually costs, the pattern that replaces it, and the one bug that the obvious replacement still has.

What delay() actually does

delay(50) spins in a tight loop watching a timer until 50 ms have passed. Your code does not advance. The only things that still happen are interrupts — the timer that drives millis(), the serial receive interrupt, anything you attached with attachInterrupt().

That exception matters and it is also a trap: an encoder counted in an interrupt keeps counting during a delay(), so the number stays right while the robot’s behaviour stops. Which makes the fault harder to see, not easier.

What it costs, in millimetres

A control loop can only react at the rate it samples. Something that appears just after a read is not noticed until the next one.

Two stacked timelines over 220 milliseconds. The upper one, labelled delay of 50 milliseconds between reads, shows sensor reads as tall marks every 50 milliseconds with the gaps shaded and marked as blocked with nothing else running. The lower one, labelled millis with a 5 millisecond loop, shows reads packed every 5 milliseconds. A dotted red line marks an obstacle appearing at 51 milliseconds; the upper timeline notices it 49 milliseconds later, the lower one 4 milliseconds later.
The obstacle appears just after a read, which is close to the worst case for both loops — the number that matters, because a control loop is judged on its worst latency, not its average. At 0.5 m/s the blocked robot travels 24 mm before it knows; the polled one travels 2 mm. Download SVG

Twenty-four millimetres is most of a line-follower’s sensor array. It is also more than enough to leave a maze cell, overshoot a junction, or tip a balancing robot past recovery — that one needs a 200 Hz inner loop, which is a 5 ms budget for everything, and a single delay(50) spends ten loops’ worth of it. Latency is always a distance — how far the robot travels before it can act is what decides whether it stops in time or drives off the edge.

The pattern that replaces it

Instead of stopping until it is time, ask each pass whether it is time yet:

unsigned long lastRead = 0;
const unsigned long READ_INTERVAL = 50;

void loop() {
  unsigned long now = millis();

  if (now - lastRead >= READ_INTERVAL) {
    lastRead = now;
    readSensor();
  }

  // everything else keeps running, every pass
  updateMotors();
  checkBumper();
}

loop() now runs thousands of times a second and the sensor read happens on schedule inside it. Nothing is blocked, and adding a second timed job is four more lines rather than a rewrite.

The rollover bug

millis() returns an unsigned long — 32 bits on an AVR — counting milliseconds since boot. It runs out at 2³² ms and wraps back to zero, which is 49.7 days.

Two ways to write the same comparison, and only one survives that:

if (now - lastRead >= READ_INTERVAL)   // correct
if (now >= lastRead + READ_INTERVAL)   // wrong

Take lastRead = 4294967290 with a 100 ms interval, moments before the wrap. lastRead + 100 overflows to 94, and now >= 94 is immediately true — so the timer stops waiting and fires every pass until the counter catches up. The subtraction form has no such problem, because unsigned subtraction wraps in exactly the way that makes the difference come out right.

Three rules follow:

  • Always subtract, never add. now - last >= interval.
  • Keep every timestamp unsigned long. Storing millis() in an int or a signed long reintroduces the bug immediately, and int on an AVR wraps after 32 seconds.
  • Never test equality. now == target is a coin flip; a busy pass steps straight over it and the event never fires.

micros() deserves its own warning. It wraps at 2³² microseconds, which is 71.6 minutes — a completely ordinary runtime for a robot. Same rules, much sooner.

Several things, at different rates

Once there are more than two or three timed jobs, give them a table instead of a pile of variables:

struct Task {
  unsigned long last;
  unsigned long interval;
  void (*run)();
};

Task tasks[] = {
  { 0,   5, readSensors   },
  { 0,  10, updateControl },
  { 0, 200, reportStatus  },
};

void loop() {
  unsigned long now = millis();
  for (Task &t : tasks) {
    if (now - t.last >= t.interval) {
      t.last = now;
      t.run();
    }
  }
}

That is a cooperative scheduler, and it is all most robots ever need. Two notes on it:

t.last = now restarts the clock from when the task actually ran, so a late pass makes the next one late too and the cadence slowly drifts. t.last += t.interval holds the cadence instead — better for a control loop, worse if a task ever overruns its interval, because then it tries to catch up and runs back to back. Use += for the loop whose timing matters and = now for everything else.

And the whole thing depends on no task blocking. One delay() inside reportStatus() and every other task is late.

When blocking is still the right answer

Not every wait is a mistake. The test is whether it is shorter than a loop pass.

  • delayMicroseconds(10) to make an HC-SR04’s trigger pulse is fine. Ten microseconds is nothing, and there is no non-blocking way to produce it.
  • pulseIn() is not fine. It blocks until the echo returns or the timeout expires. An HC-SR04 at its 4 m limit is an 8 m round trip, which at 343 m/s is 23 ms — and a missed echo costs you the full timeout you set. This is why that tutorial insists on setting one.
  • Serial.print() hides a delay. It returns immediately while the 64-byte buffer has room, and blocks once it does not. At 9600 baud the buffer takes 67 ms to drain, so printing a 40-character line every pass at 100 Hz will absolutely stall the loop. Use 115200, print less, and print on a slow timer.

Your loop time is a real number

Stop guessing at it:

unsigned long worst = 0;

void loop() {
  unsigned long t0 = micros();

  // ... the whole loop body ...

  unsigned long dt = micros() - t0;
  if (dt > worst) worst = dt;   // report this on a slow timer, not here
}

Track the worst pass, not the average. The average tells you the loop is comfortable; the worst tells you what the control loop actually has to survive, and it is usually a Serial.print, a pulseIn timeout, or an I²C read that had to wait.

This matters most for a PID controller. The derivative term divides an error change by the time between samples, so jitter in that interval turns straight into noise on D — and gains tuned at one loop rate stop being right at another. Either run the loop at a fixed interval and treat dt as constant, or measure dt each pass and use the real value. Doing neither is why a controller that behaved on the bench misbehaves once the radio code is added.

What to use instead

Instead of Use
delay() to pace a sensor read millis() interval check
delay() to blink an LED A timer in the same loop
delay() to wait for a servo to arrive Estimate the travel time and check millis()
delay() to debounce a button Timestamp the edge, ignore changes for 20 ms
delay() between motor speed steps A motion profile driven off elapsed time
delay() to slow the whole loop down Nothing — run fast and time the jobs individually
pulseIn() with no timeout pulseIn(pin, HIGH, 30000) and a “no echo” branch

When it goes wrong

Symptom Usually
Robot ignores its bumper while turning A delay() inside the turn
Sensor readings arrive in bursts Everything sharing one timer instead of separate intervals
Fine on the bench, sluggish with logging on Serial.print blocking once the buffer fills
Timer fires continuously after ~50 days The now >= last + interval form
Timer misbehaves after about an hour micros() rollover at 71.6 minutes
Timer never fires at all Timestamp stored in int, or an equality test
PID tuned well, then behaves differently Loop rate changed; dt is not what the gains assumed
Encoder counts right but robot reacts late Interrupts still run during delay() — the counts are not the problem

The habit worth building is to treat loop() as something that must always return quickly. Once that is true, adding a feature is adding a task, and the robot keeps its reflexes. Once it is not, every new feature makes the robot slightly blinder, and the cause is never where the symptom is.

Explore the graph

Part of these builds

Projects and learning paths that include this tutorial.

Further reading

References