millis() Problems: Timers That Work for a While and Then Fail

Using millis() instead of delay() is one of the most important steps in writing better Arduino, ESP32, ESP8266, RP2040 and CANABLOX projects. It allows the sketch to keep running while it waits for a timed event.

But many users run into a second problem: they replace delay() with millis(), and the project still does not work correctly. Timers fire too often, stop after a while, behave strangely after many days, or become impossible to manage when several tasks are added.

The issue is usually not millis() itself. The problem is how the timing comparison is written, which variable types are used, or how the sketch handles states and repeated events.

Typical Symptoms

  • A timer works at first, then stops after some time.
  • A timed action runs continuously instead of once.
  • An LED blinks irregularly.
  • A button press starts a timer, but the timer restarts every loop.
  • A display update happens too often or too rarely.
  • A project fails after running for many days.
  • Several timers interfere with each other.
  • The sketch becomes harder to understand than the original delay() version.

What millis() Does

millis() returns the number of milliseconds since the board started running the sketch.

It does not stop the sketch. It simply gives you the current time value.

unsigned long now = millis();

The common idea is:

  • Store the time when something happened.
  • Keep running the main loop.
  • Check whether enough time has passed.
  • If yes, perform the action and update the stored time.

The Correct Basic Pattern

The safest basic pattern is this:

unsigned long previousMillis = 0;
const unsigned long interval = 1000;

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

  if (currentMillis - previousMillis >= interval) {
    previousMillis = currentMillis;

    // Timed action here
  }
}

The important part is the subtraction:

currentMillis - previousMillis >= interval

This works even when millis() rolls over, as long as the variables are the correct unsigned type.

Common Mistake: Comparing Future Times Directly

A common beginner pattern is:

if (millis() > previousMillis + interval) {
  previousMillis = millis();
}

This may appear to work at first, but it is not the best pattern. It can fail or behave strangely when millis() rolls over.

Use subtraction instead:

if (millis() - previousMillis >= interval) {
  previousMillis = millis();
}

This is the standard rollover-safe method.

Common Mistake: Using int Instead of unsigned long

On many Arduino boards, int is only 16 bits. It cannot hold large millisecond values.

This is wrong:

int previousMillis = 0;
int interval = 1000;

Use unsigned long for values related to millis():

unsigned long previousMillis = 0;
const unsigned long interval = 1000;

This matters because millis() itself returns an unsigned long value.

Common Mistake: Resetting the Timer Every Loop

If the stored start time is updated every time through loop(), the interval may never expire.

Problem example:

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

  if (millis() - startTime >= 5000) {
    Serial.println("Five seconds passed");
  }
}

This does not work because startTime is reset to the current time every loop cycle.

The start time must be stored in a variable that keeps its value between loop cycles:

unsigned long startTime = 0;

void loop() {
  if (millis() - startTime >= 5000) {
    Serial.println("Five seconds passed");
    startTime = millis();
  }
}

Common Mistake: Action Runs Over and Over

Sometimes you want an action to happen once after a delay, not repeatedly every loop after the delay has passed.

Problem example:

if (millis() - startTime >= 5000) {
  Serial.println("Done");
}

After five seconds, this prints “Done” on every loop cycle.

Use a flag if the action should happen only once:

unsigned long startTime = 0;
bool actionDone = false;

void loop() {
  if (!actionDone && millis() - startTime >= 5000) {
    Serial.println("Done");
    actionDone = true;
  }
}

Common Mistake: Timer Starts Before the Event

Sometimes a timer should begin when a button is pressed, a sensor changes, a relay turns on, or a state begins. If the timer starts too early, the timed action may happen immediately or at the wrong time.

A good pattern is to store the start time when the event begins.

if (buttonWasPressed) {
  timerStart = millis();
  timerRunning = true;
}

Then check the timer separately:

if (timerRunning && millis() - timerStart >= 5000) {
  timerRunning = false;
  Serial.println("Timer finished");
}

Common Mistake: Not Detecting State Changes

If a button is held down, the condition may be true for many loop cycles. This can restart the timer again and again.

Instead of checking only whether the button is currently pressed, detect the moment it changes from not pressed to pressed.

That moment is called an edge or state change.

bool lastButtonState = HIGH;

void loop() {
  bool currentButtonState = digitalRead(BUTTON_PIN);

  if (lastButtonState == HIGH && currentButtonState == LOW) {
    Serial.println("Button was just pressed");
    timerStart = millis();
    timerRunning = true;
  }

  lastButtonState = currentButtonState;
}

This starts the timer once when the button is first pressed, not continuously while the button is held.

Common Mistake: One Timer Variable Used for Everything

A project may need several independent timed tasks:

  • Blink an LED every 500 ms.
  • Read a sensor every 2 seconds.
  • Update a display every 250 ms.
  • Send data every 60 seconds.
  • Turn off a relay after 10 seconds.

Each independent task should usually have its own timestamp variable.

unsigned long lastLedToggle = 0;
unsigned long lastSensorRead = 0;
unsigned long lastDisplayUpdate = 0;
unsigned long lastDataSend = 0;

Trying to control all tasks with one previousMillis variable usually creates confusing timing bugs.

Multiple Timers Example

This example shows several independent timed tasks in one loop.

unsigned long lastLedToggle = 0;
unsigned long lastSensorRead = 0;
unsigned long lastDisplayUpdate = 0;

const unsigned long ledInterval = 500;
const unsigned long sensorInterval = 2000;
const unsigned long displayInterval = 250;

bool ledState = false;

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

  if (now - lastLedToggle >= ledInterval) {
    lastLedToggle = now;
    ledState = !ledState;
    digitalWrite(LED_BUILTIN, ledState);
  }

  if (now - lastSensorRead >= sensorInterval) {
    lastSensorRead = now;
    readSensor();
  }

  if (now - lastDisplayUpdate >= displayInterval) {
    lastDisplayUpdate = now;
    updateDisplay();
  }

  checkButtons();
}

Each task has its own timing. The loop keeps running, and buttons can still be checked frequently.

Common Mistake: Forgetting millis() Rollover

millis() eventually rolls over back to zero. On many Arduino-style systems using a 32-bit unsigned long millisecond counter, this happens after about 49.7 days.

This is normal and expected.

Code written with the subtraction pattern continues working:

if (millis() - previousMillis >= interval) {
  previousMillis = millis();
}

Code that compares future timestamps directly may fail around rollover.

Common Mistake: Using signed long

Use unsigned long, not long, for millis() timing variables.

This is best:

unsigned long now = millis();
unsigned long previousMillis = 0;
const unsigned long interval = 1000;

Mixing signed and unsigned values can create confusing compiler warnings and timing bugs.

Common Mistake: Interval Is Too Small for the Task

If a task takes longer to run than the interval, the timer can never keep up.

For example, if a sensor read takes 300 ms but you try to read it every 100 ms, the project will spend too much time in that sensor function.

Non-blocking timing does not make slow hardware fast. It only prevents unnecessary waiting.

Common Mistake: Hidden Blocking Code

A sketch may use millis() correctly in one place but still contain blocking code somewhere else.

Look for:

  • Long delay() calls.
  • while loops that wait forever.
  • WiFi connection loops without timeout.
  • Sensor reads that block too long.
  • Display animations inside long loops.
  • Serial input code that waits forever.

If the main loop is blocked elsewhere, the millis() timers cannot be checked on time.

Common Mistake: Not Using a State Machine

Some tasks have several steps. For example:

  • Turn relay on.
  • Wait 5 seconds.
  • Turn relay off.
  • Wait for next trigger.

This is not just a simple repeating timer. It is a state sequence.

A small state machine is often clearer:

enum State {
  IDLE,
  RELAY_ON
};

State state = IDLE;
unsigned long stateStart = 0;

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

  if (state == IDLE) {
    if (triggerDetected()) {
      digitalWrite(RELAY_PIN, HIGH);
      stateStart = now;
      state = RELAY_ON;
    }
  }

  if (state == RELAY_ON) {
    if (now - stateStart >= 5000) {
      digitalWrite(RELAY_PIN, LOW);
      state = IDLE;
    }
  }
}

This avoids blocking delays and makes the project behavior easier to understand.

Common Mistake: Updating the Timestamp at the Wrong Time

There are two common ways to update the timestamp:

previousMillis = currentMillis;

or:

previousMillis += interval;

Using previousMillis = currentMillis schedules the next event relative to when the task actually ran. This is simple and good for most projects.

Using previousMillis += interval tries to maintain a more regular schedule, but it can behave badly if the task falls far behind unless handled carefully.

For most beginner and intermediate Arduino projects, previousMillis = currentMillis is easier and safer.

Common Mistake: Button Debounce Restarts the Timer

Button bounce can create several quick press/release events. If each event starts a timer, the behavior can look random.

Use debounce logic when buttons start timers, menus or state changes.

A simple debounce time of 20 ms to 50 ms is often enough for many push buttons.

Recommended Troubleshooting Steps

  1. Search the sketch for all millis() timing code.
  2. Make sure all timing variables are unsigned long.
  3. Use subtraction: millis() - previousMillis >= interval.
  4. Do not reset the start time every loop unless that is intended.
  5. Use flags for actions that should happen only once.
  6. Use separate timestamp variables for separate tasks.
  7. Detect button state changes instead of only reading current state.
  8. Add timeouts to blocking waits.
  9. Check for hidden delay() or blocking while loops.
  10. Use a small state machine for multi-step behavior.

Quick Diagnostic Table

Symptom Likely Cause First Thing to Try
Timer never finishes Start time reset every loop Store start time only when the event begins
Action repeats forever after timeout No flag or state to mark action complete Use a boolean flag or state variable
Fails after many days Rollover-unsafe comparison Use subtraction-based timing with unsigned long
Several timers interfere One timestamp reused for multiple tasks Use one timestamp per independent task
Button starts timer repeatedly Held button detected every loop Detect press edge and debounce button
Timers still feel late Other code blocks the loop Remove hidden delay(), long while loops and blocking functions

What Not to Do

  • Do not store millis() values in int.
  • Do not compare millis() to a future timestamp directly.
  • Do not restart a timer every loop unless you really mean it.
  • Do not use one timing variable for many unrelated tasks.
  • Do not expect millis() to fix code that is blocked somewhere else.
  • Do not ignore button debounce when button events start timers.

CANABLOX Practical Note

CANABLOX projects often combine displays, keypads, RTCs, ADCs, I/O expanders and sensors. That makes proper timing structure more important than in a simple one-module sketch.

A CANABLOX project should usually avoid long blocking delays. Use millis()-based timing for display updates, sensor reads, keypad scanning and communication tasks. Give each independent task its own timestamp, and use small state machines for multi-step behavior such as menus, timed outputs or playlist-style actions.

Conclusion

millis() is powerful, but it must be used correctly. Most problems come from wrong variable types, resetting the timer too often, direct future-time comparisons, missing flags, or trying to manage several tasks with one timestamp.

The safest basic rule is simple: use unsigned long variables and compare elapsed time with subtraction.

Once that pattern is understood, Arduino, ESP32 and CANABLOX projects can handle buttons, displays, sensors and communication much more reliably than sketches built around long delay() calls.

Shopping Cart
Scroll to Top