Doing several things at once

Beginner13 min readUpdated September 2026

Blink an LED, watch a button and read a sensor, all at the same time. Why delay() makes that impossible, and the simple clock-watching pattern that fixes it for good.

Every project starts simple. Blink an LED: digitalWrite, delay(500), done. Then you add a button. Then a sensor. And suddenly nothing works properly: the button only responds if you hold it down, the sensor updates once a second, and the LED stutters whenever anything else happens.

Nothing is broken. The code is doing exactly what delay() tells it to, which is nothing at all. This article shows why, and the pattern that lets one microcontroller juggle as many jobs as you like. It builds on What a microcontroller actually is and Buttons, pull-ups and bounce.

What delay() actually does

delay(500) means "stop here for half a second". Your code doesn't carry on in the background while it waits. It sits on that line and does nothing else until the time is up. No button checks, no sensor reads, no LED updates.

For a blink sketch that's fine, because blinking is the only job. The trouble starts when there's more than one:

const int LED_PIN = 5;     // an LED and resistor on GPIO5
const int BUTTON_PIN = 0;  // the Vector S3's BOOT button
 
void setup() {
  Serial.begin(115200);
  pinMode(LED_PIN, OUTPUT);
  pinMode(BUTTON_PIN, INPUT_PULLUP);
}
 
void loop() {
  digitalWrite(LED_PIN, HIGH);
  delay(500);
  digitalWrite(LED_PIN, LOW);
  delay(500);
 
  if (digitalRead(BUTTON_PIN) == LOW) {
    Serial.println("Button!");
  }
 
  Serial.println(analogRead(6));  // a sensor on GPIO6
}

Read it as the chip does. One pass through loop() takes a full second, and almost all of that is spent inside the two delay() calls. The button gets looked at once per pass, for an instant. If you press and release it during either delay, the code never finds out. And the sensor is read once a second, whether you wanted that or not.

Try it against the fixed version:

Two programs, same three jobs: blink an LED every half second, react to a button, and read a sensor ten times a second. Tap the big button a few times, quickly. Then try holding it down for a full second.

With delay()

void loop() {
digitalWrite(LED, HIGH);
delay(500);
digitalWrite(LED, LOW);
delay(500);
checkButton();
readSensor();
}
your presseswhat the code is doingcyan line: the only moment it looks at the buttonnow
Caught
0
Missed
0
Sensor reads/s
1

With millis()

void loop() {
unsigned long now = millis();
if (now - lastBlink >= 500) toggleLed();
if (now - lastSample >= 100) readSensor();
checkButton();
}
your presseswhat the code is doinggreen ticks: sensor readsnow
Caught
0
Missed
0
Sensor reads/s
10

Both programs are watching this button. Tap it.

Presses
0
you made
delay() caught
0 of 0
checks once a second
millis() caught
0 of 0
checks constantly
Time
0.0 s
since reset

Quick taps get lost on the delay() side, because each one comes and goes while the code is stuck waiting. Hold the button for a full second and it gets through, because the press is still there when the code finally looks. That's exactly the "you have to hold it down" feeling people get from their first multi-job sketch.

Look at the clock instead

The fix is to stop waiting and start checking. Instead of "wait 500 ms, then toggle the LED", ask on every pass: "has it been 500 ms since I last toggled it?" If not, move on straight away and do the other jobs.

The clock you check is millis(): the number of milliseconds since the board started. Remember when something last happened, and compare:

unsigned long lastBlink = 0;
 
void loop() {
  unsigned long now = millis();
 
  if (now - lastBlink >= 500) {
    lastBlink = now;
    // toggle the LED
  }
 
  // ...and on to the next job immediately
}

Now loop() never stops. Most passes, it's not time to blink yet, so the check takes a fraction of a microsecond and the code carries on. loop() comes round again thousands of times a second, and each job gets looked at every time.

Think of it like cooking several things at once. You don't stand staring at the pasta for ten minutes. You glance at the clock, stir the sauce, check the oven, glance at the clock again. Each glance is quick, so nothing gets forgotten.

Several jobs, several clocks

Each job gets its own "last time" variable and its own interval, and they run completely independently. Here's the whole race sketch done properly:

const int LED_PIN = 5;
const int BUTTON_PIN = 0;
const int SENSOR_PIN = 6;
 
unsigned long lastBlink = 0;
unsigned long lastSample = 0;
bool ledOn = false;
bool wasPressed = false;
 
void setup() {
  Serial.begin(115200);
  pinMode(LED_PIN, OUTPUT);
  pinMode(BUTTON_PIN, INPUT_PULLUP);
}
 
void loop() {
  unsigned long now = millis();
 
  // Job 1: blink every 500 ms
  if (now - lastBlink >= 500) {
    lastBlink = now;
    ledOn = !ledOn;
    digitalWrite(LED_PIN, ledOn ? HIGH : LOW);
  }
 
  // Job 2: read the sensor every 100 ms
  if (now - lastSample >= 100) {
    lastSample = now;
    Serial.println(analogRead(SENSOR_PIN));
  }
 
  // Job 3: react the moment the button goes down
  bool pressed = digitalRead(BUTTON_PIN) == LOW;
  if (pressed && !wasPressed) {
    Serial.println("Button!");
  }
  wasPressed = pressed;
}

Adding a fourth job means adding a fourth block. Nothing else changes, and none of the jobs slows the others down. The button check is the edge-detecting kind from Buttons, pull-ups and bounce. For a real button, add the debounce from that article too; it uses exactly this millis() pattern.

The pattern only works if every job is quick. One slow job, like a delay() hidden in a library call or a sensor that takes 200 ms to answer, freezes everything else for that long. When a job has to wait for something, split it into steps and check back later, which is exactly what the next section is for.

State machines: jobs with steps

Blinking is easy because it's just two states that swap. Real behaviour has steps: drive forward until something's in the way, back up for a bit, turn, drive on. The delay() way of writing that is tempting:

driveBackward();
delay(600);
spinLeft();
delay(800);

And it freezes the whole program for 1.4 seconds, every single time.

The non-blocking way is a state machine. You give each step a name, remember which one you're in and when you entered it, and on every pass run only the code for the current step. Each step decides for itself when it's time to move on.

A robot that drives until something is in the way, backs up, turns, and carries on. The whole behaviour is three states and a switch. Watch which case is running, and hit Obstacle! at different moments.

The robot

The dashed line is its distance sensor. It only looks while driving.

The states

obstacle600 ms800 msDRIVEBACK_UPTURN

now − stateStart = 0 ms

enum State { DRIVE, BACK_UP, TURN };
State state = DRIVE;
unsigned long stateStart = 0;
void enterState(State next) {
state = next;
stateStart = millis();
}
void loop() {
unsigned long now = millis();
switch (state) {
case DRIVE:
driveForward();
if (obstacleAhead()) enterState(BACK_UP);
break;
case BACK_UP:
driveBackward();
if (now - stateStart >= 600) enterState(TURN);
break;
case TURN:
spinLeft();
if (now - stateStart >= 800) enterState(DRIVE);
break;
}
}

In code, the steps are an enum and the machine is a switch:

enum State { DRIVE, BACK_UP, TURN };
State state = DRIVE;
unsigned long stateStart = 0;
 
void enterState(State next) {
  state = next;
  stateStart = millis();
}
 
void loop() {
  unsigned long now = millis();
 
  switch (state) {
    case DRIVE:
      driveForward();
      if (obstacleAhead()) enterState(BACK_UP);
      break;
 
    case BACK_UP:
      driveBackward();
      if (now - stateStart >= 600) enterState(TURN);
      break;
 
    case TURN:
      spinLeft();
      if (now - stateStart >= 800) enterState(DRIVE);
      break;
  }
 
  // Other jobs run here on every pass, while the robot is busy.
}

driveForward(), obstacleAhead() and the rest stand in for your own motor and sensor code. The shape is what matters. Every case returns in microseconds. The timing lives in now - stateStart, not in a delay(). And because loop() keeps coming round, you can blink a status LED, beep the buzzer on GPIO48 or read the IMU at the same time as the robot backs up.

Notice what happened when you hit Obstacle! during BACK_UP or TURN: nothing. Only the DRIVE case checks for obstacles. That's a feature. Each state only has to worry about its own exits, which is why state machines stay understandable as behaviour grows.

The 49-day bug

millis() is a 32-bit number. It counts up to 4,294,967,295 milliseconds, which is about 49.7 days, then wraps back round to 0. A project that runs for months will hit this, and it's why the check is always written this way round:

if (now - lastBlink >= 500)    // right
if (now >= lastBlink + 500)    // wrong: breaks when millis() wraps

They look like the same maths. They aren't, once numbers wrap:

The LED last blinked at millis() = 4,294,967,000, just 296 ms before the counter runs out and wraps back to 0. Slide time forward and see which way of writing the check still works.

150 ms

The check should fire once this reaches 1,000 ms, and not before

lastBlink

4,294,967,000

now = millis()

4,294,967,150

if (now - lastBlink >= 1000)Waits, correct

4,294,967,150 − 4,294,967,000 = 150

if (now >= lastBlink + 1000)Fires, too early!

lastBlink + 1000 wraps to 704, and 4,294,967,150 ≥ 704

Before the wrap, lastBlink + 1000 is too big to fit, so it wraps to a tiny number, and the huge current time is already past it. The second check fires straight away, a whole second early.

Subtraction with unsigned numbers wraps in a way that always gives the true time difference, even when the clock has just rolled past zero. Addition can wrap the deadline round to a small number while the current time is still huge, and the check fires early. Always store the time something happened, and always subtract.

When one loop isn't enough

The millis() pattern and state machines will carry you a very long way; most hobby robots never need anything more. For bigger projects, the ESP32-S3 has a second option built in. Its Arduino core runs on FreeRTOS, a small operating system that can run several separate loops, called tasks, and share the chip's two cores between them. It's powerful, but it brings its own problems, like two tasks changing the same variable at once. Master the single loop first.

What to remember

  1. delay() stops your code completely. Nothing else runs while it waits.
  2. Check the clock instead of waiting. if (now - last >= interval), then carry on.
  3. One "last time" variable per job. Jobs run independently at their own rates.
  4. Keep every job quick. Anything that has to wait becomes a state machine.
  5. Subtract, never add. now - last survives millis() wrapping after 49.7 days.