In this project, we’ll build a web server with the ESP32 that allows you to control an LED by scheduling a timer with an ON/OFF action for any period duration (seconds, minutes, or hours). The web page also has two buttons that you can use to turn the LED on or off immediately. The ESP32 will be programmed using Arduino IDE.

You might like reading: Building an ESP32 Web Server: The Complete Guide for Beginners (Arduino IDE).
Project Overview
The following image shows the web page you’ll build for this project.

Here are the key features of this web server:
- Current LED state text label: “GPIO is ON” / “GPIO is OFF”
- Instant LED control: TURN ON / TURN OFF buttons
- Set Timer HTML form: you can set the action (TURN ON/OFF), time unit, and timer duration
- Timer status and remaining time to perform the selected action
- Cancel timer button
For simplicity, we’ll save the HTML and CSS to build the web page in a variable on the Arduino sketch, but you could use the LittleFS Filesystem.
Prerequisites
Before following this guide, you need to install the ESP32 Core add-on in your Arduino IDE. Follow the next guide to install it, if you haven’t already:
You will also need an ESP32 development board model of your choice.
Install Required Libraries in Arduino IDE
We’ll build the web server using the following libraries:
You can install these libraries in the Arduino Library Manager. Open the Library Manager by clicking the Library icon in the left sidebar.
Search for ESPAsyncWebServer and install the ESPAsyncWebServer by ESP32Async.

Then, install the AsyncTCP library. Search for AsyncTCP and install the AsyncTCP by ESP32Async.

ESP32 Web Server: Timer Schedule – Arduino Sketch
The following code creates a web server that serves a web page that lets you schedule a timer to turn an LED ON/OFF for a selected time duration.
/*********
Rui Santos & Sara Santos - Random Nerd Tutorials
Complete project details at https://RandomNerdTutorials.com/esp32-web-server-timer-schedule-arduino/
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files.
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*********/
#include <Arduino.h>
#include <WiFi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <freertos/timers.h>
// REPLACE WITH YOUR NETWORK CREDENTIALS
const char* ssid = "REPLACE_WITH_YOUR_SSID";
const char* password = "REPLACE_WITH_YOUR_PASSWORD";
// LED connected to GPIO 5
const int ledPin = 5;
// Global variables
AsyncWebServer server(80);
TimerHandle_t gpioTimer = NULL;
// Timer action: 1 = ON, 0 = OFF
int targetAction = -1;
TickType_t timerStartTick = 0;
uint64_t totalDurationTicks = 0;
// HTML Web Page
const char index_html[] PROGMEM = R"rawliteral(
<!DOCTYPE HTML>
<html>
<head>
<title>ESP32 Web Server - Timer Schedule</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body { font-family: Arial, sans-serif; text-align: center; margin: 0; padding: 20px; background: #f4f4f4; }
h1 { color: #333; }
.card { max-width: 440px; margin: 20px auto; padding: 25px; background: white; border-radius: 12px; box-shadow: 0 4px 15px rgba(0,0,0,0.1); }
button { width: 100%; padding: 14px; margin: 8px 0; font-size: 18px; border: none; border-radius: 8px; cursor: pointer; font-weight: bold; }
.onButton { background: #28a745; color: white; }
.offButton { background: #999999; color: white; }
button:hover { opacity: 0.95; }
.state { font-size: 22px; font-weight: bold; margin: 15px 0; }
.ledOn { color: #28a745; }
.ledOff { color: #999999; }
label { display: block; margin: 12px 0 6px; font-weight: bold; text-align: left; }
select, input[type="number"] { width: 100%; padding: 12px; margin: 8px 0; font-size: 16px; border-radius: 6px; border: 1px solid #ccc; box-sizing: border-box;}
.startButton { background:#1a73e8; color:white; }
#cancelButton { display:none; background:#dc3545; color:white; padding:10px 20px; border:none; border-radius:6px; cursor:pointer; margin-top:10px; }
</style>
</head>
<body>
<h1>ESP32 - Timer Schedule</h1>
<div class="card">
<div class="state">GPIO is <span id="ledState">Loading...</span></div>
<button class="onButton" onclick="controlLED(1)">TURN ON</button>
<button class="offButton" onclick="controlLED(0)">TURN OFF</button>
</div>
<div class="card">
<h2>Set Timer</h2>
<form id="timerForm" action="/set-timer" method="POST">
<label for="action">Action</label>
<select name="action" id="action">
<option value="" disabled selected hidden>--- Select Action ---</option>
<option value="1">TURN ON (HIGH)</option>
<option value="0">TURN OFF(LOW)</option>
</select>
<label for="unit">Time Unit</label>
<select name="unit" id="unit">
<option value="s">Seconds</option>
<option value="m">Minutes</option>
<option value="h">Hours</option>
</select>
<label for="duration">Duration</label>
<input type="number" name="duration" id="duration" min="1" value="30" required>
<button type="submit" class="startButton">START TIMER</button>
</form>
<div style="margin-top:20px; padding:15px; background:#f8f9fa; border-radius:8px;">
<strong>Timer Status:</strong> <span id="status">No active timers</span><br><br>
<span id="remaining" style="font-size:18px;"></span><br>
<button id="cancelButton" onclick="cancelTimer()">
CANCEL TIMER
</button>
</div>
</div>
<script>
// Update LED State
function updateLEDState() {
fetch('/led-state')
.then(r => r.json())
.then(data => {
const stateEl = document.getElementById('ledState');
if (data.state === 1) {
stateEl.innerHTML = '<span class="ledOn">ON</span>';
} else {
stateEl.innerHTML = '<span class="ledOff">OFF</span>';
}
})
.catch(() => {
document.getElementById('ledState').innerHTML = 'Error';
});
}
// Control LED
function controlLED(state) {
fetch('/control?state=' + state)
.then(() => updateLEDState());
}
// Timer functions
let countdownInterval = null;
let remainingSeconds = 0;
// Format time to hours, minutes and seconds
function formatTime(seconds) {
if (seconds <= 0) return "0s";
let h = Math.floor(seconds / 3600);
let m = Math.floor((seconds % 3600) / 60);
let s = seconds % 60;
if (h > 0) return h + "h " + m + "m " + s + "s";
if (m > 0) return m + "m " + s + "s";
return s + "s";
}
// Check the remaining time for the timer to end
function updateTimerStatus() {
fetch('/timer-status')
.then(r => r.json())
.then(data => {
document.getElementById('status').innerText = data.status;
const cancelButton = document.getElementById('cancelButton');
const remainingEl = document.getElementById('remaining');
if (data.active) {
remainingSeconds = data.remaining_seconds;
cancelButton.style.display = 'inline-block';
if (!countdownInterval) {
countdownInterval = setInterval(() => {
if (remainingSeconds > 0) {
remainingSeconds--;
remainingEl.innerText = "Remaining: " + formatTime(remainingSeconds);
} else {
clearInterval(countdownInterval);
countdownInterval = null;
}
}, 1000);
}
remainingEl.innerText = "Remaining: " + formatTime(remainingSeconds);
} else {
cancelButton.style.display = 'none';
remainingEl.innerText = "";
if (countdownInterval) {
clearInterval(countdownInterval);
countdownInterval = null;
}
}
});
}
// Cancel timer button
function cancelTimer() {
if (confirm("Cancel the current timer?")) {
fetch('/cancel-timer', { method: 'POST' })
.then(() => updateTimerStatus());
}
}
// Update every 10 seconds
setInterval(() => {
updateLEDState();
updateTimerStatus();
}, 10000);
window.onload = () => {
updateLEDState();
updateTimerStatus();
};
</script>
</body>
</html>
)rawliteral";
// Timer Callback - Turns the LED on or off depending on the action selected
void IRAM_ATTR timerCallback(TimerHandle_t xTimer) {
if (targetAction != -1) {
digitalWrite(ledPin, targetAction ? HIGH : LOW);
Serial.printf("Timer finished: GPIO %d set to %s\n", ledPin, targetAction ? "HIGH (ON)" : "LOW (OFF)");
}
targetAction = -1;
timerStartTick = 0;
totalDurationTicks = 0;
}
void setup() {
Serial.begin(115200);
delay(1000);
// Define ledPin as an OUTPUT and initialize it off (LOW)
pinMode(ledPin, OUTPUT);
digitalWrite(ledPin, LOW);
// Create a timer and assign the timerCallback function
gpioTimer = xTimerCreate("GPIO_Timer", pdMS_TO_TICKS(1000), pdFALSE, 0, timerCallback);
// Start the Wi-Fi connection
WiFi.begin(ssid, password);
Serial.print("Connecting to Wi-Fi");
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println("\nWi-Fi Connected!");
// Print the ESP32 IP Address
Serial.print("Access ESP32 IP Address: http://");
Serial.println(WiFi.localIP());
// Root URL handler
server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) {
request->send_P(200, "text/html", index_html);
});
// Instant LED control
server.on("/control", HTTP_GET, [](AsyncWebServerRequest *request) {
if (request->hasParam("state")) {
int state = request->getParam("state")->value().toInt();
digitalWrite(ledPin, state ? HIGH : LOW);
Serial.printf("LED set to %s\n", state ? "ON" : "OFF");
}
request->send(200, "text/plain", "OK");
});
// Get current LED state
server.on("/led-state", HTTP_GET, [](AsyncWebServerRequest *request) {
int state = digitalRead(ledPin);
request->send(200, "application/json", "{\"state\":" + String(state) + "}");
});
// Set the timer duration to perform the action selected
server.on("/set-timer", HTTP_POST, [](AsyncWebServerRequest *request) {
if (request->hasParam("action", true) && request->hasParam("duration", true) && request->hasParam("unit", true)) {
targetAction = request->getParam("action", true)->value().toInt();
int dur = request->getParam("duration", true)->value().toInt();
String unit = request->getParam("unit", true)->value();
uint64_t durationSeconds = 0;
if (unit == "s") durationSeconds = (uint64_t)dur;
else if (unit == "m") durationSeconds = (uint64_t)dur * 60ULL;
else if (unit == "h") durationSeconds = (uint64_t)dur * 3600ULL;
if (durationSeconds > 0) {
if (xTimerIsTimerActive(gpioTimer)) xTimerStop(gpioTimer, 0);
uint64_t tempTicks = durationSeconds * (uint64_t)configTICK_RATE_HZ;
TickType_t timerTicks = (tempTicks > 0xFFFFFFFFULL) ? 0xFFFFFFFFULL : (TickType_t)tempTicks;
timerStartTick = xTaskGetTickCount();
totalDurationTicks = tempTicks;
xTimerChangePeriod(gpioTimer, timerTicks, 0);
xTimerStart(gpioTimer, 0);
Serial.printf("Timer scheduled: GPIO %d will be set to %s in %llu seconds\n", ledPin,
targetAction ? "ON" : "OFF", durationSeconds);
}
}
request->redirect("/");
});
// Cancel Timer
server.on("/cancel-timer", HTTP_POST, [](AsyncWebServerRequest *request) {
// Stop the active timer
if (xTimerIsTimerActive(gpioTimer)) {
xTimerStop(gpioTimer, 0);
Serial.println("Timer cancelled");
}
targetAction = -1;
timerStartTick = 0;
totalDurationTicks = 0;
request->send(200, "text/plain", "OK");
});
// Timer Status - Returns the remaining time in seconds
server.on("/timer-status", HTTP_GET, [](AsyncWebServerRequest *request) {
if (targetAction != -1 && xTimerIsTimerActive(gpioTimer) && totalDurationTicks > 0) {
// Calculate how many seconds are left for the timer to end
TickType_t now = xTaskGetTickCount();
uint64_t elapsedTicks = 0;
if (now >= timerStartTick) {
elapsedTicks = now - timerStartTick;
} else {
elapsedTicks = (0xFFFFFFFFULL - timerStartTick) + now + 1;
}
uint64_t remainingTicks = (totalDurationTicks > elapsedTicks) ? totalDurationTicks - elapsedTicks : 0;
uint64_t remainingSeconds = remainingTicks / (uint64_t)configTICK_RATE_HZ;
// Return the amount of seconds left and action that will execute when the timer ends
String json = "{\"status\":\"Setting GPIO "+ String(ledPin) +" to " +
String(targetAction ? "ON" : "OFF") + "\",";
json += "\"active\":true,";
json += "\"remaining_seconds\":" + String((unsigned long)remainingSeconds) + "}";
request->send(200, "application/json", json);
} else {
request->send(200, "application/json", "{\"status\":\"No active timers\",\"active\":false,\"remaining_seconds\":0}");
}
});
server.begin();
Serial.println("ESP32 Web Server Timer Schedule is Ready!");
}
void loop() {
vTaskDelay(pdMS_TO_TICKS(100));
}
You just need to insert your network credentials in the code and it will work straight away.
const char* ssid = "REPLACE_WITH_YOUR_SSID";
const char* password = "REPLACE_WITH_YOUR_SSID";
How the Code Works
Continue reading to learn how the code works or skip to the Demonstration section.
Including Libraries
First, include the required libraries to connect to Wi-Fi, create the web server, and use FreeRTOS timers.
#include <Arduino.h>
#include <WiFi.h>
#include <AsyncTCP.h>
#include <ESPAsyncWebServer.h>
#include <freertos/timers.h>
Network Credentials
Insert your network credentials in the following variables so that the ESP32 connects to your network.
// REPLACE WITH YOUR NETWORK CREDENTIALS
const char* ssid = "REPLACE_WITH_YOUR_SSID";
const char* password = "REPLACE_WITH_YOUR_SSID";
LED Pin
Define the GPIO that will be used to control the LED. You can modify the ledPin variable to control any other GPIO.
const int ledPin = 5;
Creating a Server Object
Create an AsyncWebServer object called server on port 80.
AsyncWebServer server(80);
Timers
Create auxiliary variables to run the timers.
TimerHandle_t gpioTimer = NULL;
// Timer action: 1 = ON, 0 = OFF
int targetAction = -1;
TickType_t timerStartTick = 0;
uint64_t totalDurationTicks = 0;
Learn more about FreeRTOS timers: ESP32 with FreeRTOS: Software Timers/Timer Interrupts (Arduino IDE).
Web Page
The index_html variable contains the text with the HTML, CSS, and JavaScript to build the web page.
const char index_html[] PROGMEM = R"rawliteral(
<!DOCTYPE HTML>
<html>
<head>
<title>ESP32 Web Server - Timer Schedule</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body { font-family: Arial, sans-serif; text-align: center; margin: 0; padding: 20px; background: #f4f4f4; }
h1 { color: #333; }
.card { max-width: 440px; margin: 20px auto; padding: 25px; background: white; border-radius: 12px; box-shadow: 0 4px 15px rgba(0,0,0,0.1); }
button { width: 100%; padding: 14px; margin: 8px 0; font-size: 18px; border: none; border-radius: 8px; cursor: pointer; font-weight: bold; }
.onButton { background: #28a745; color: white; }
.offButton { background: #999999; color: white; }
button:hover { opacity: 0.95; }
.state { font-size: 22px; font-weight: bold; margin: 15px 0; }
.ledOn { color: #28a745; }
.ledOff { color: #999999; }
label { display: block; margin: 12px 0 6px; font-weight: bold; text-align: left; }
select, input[type="number"] { width: 100%; padding: 12px; margin: 8px 0; font-size: 16px; border-radius: 6px; border: 1px solid #ccc; box-sizing: border-box;}
.startButton { background:#1a73e8; color:white; }
#cancelButton { display:none; background:#dc3545; color:white; padding:10px 20px; border:none; border-radius:6px; cursor:pointer; margin-top:10px; }
</style>
</head>
<body>
<h1>ESP32 - Timer Schedule</h1>
<div class="card">
<div class="state">GPIO is <span id="ledState">Loading...</span></div>
<button class="onButton" onclick="controlLED(1)">TURN ON</button>
<button class="offButton" onclick="controlLED(0)">TURN OFF</button>
</div>
<div class="card">
<h2>Set Timer</h2>
<form id="timerForm" action="/set-timer" method="POST">
<label for="action">Action</label>
<select name="action" id="action">
<option value="" disabled selected hidden>--- Select Action ---</option>
<option value="1">TURN ON (HIGH)</option>
<option value="0">TURN OFF(LOW)</option>
</select>
<label for="unit">Time Unit</label>
<select name="unit" id="unit">
<option value="s">Seconds</option>
<option value="m">Minutes</option>
<option value="h">Hours</option>
</select>
<label for="duration">Duration</label>
<input type="number" name="duration" id="duration" min="1" value="30" required>
<button type="submit" class="startButton">START TIMER</button>
</form>
<div style="margin-top:20px; padding:15px; background:#f8f9fa; border-radius:8px;">
<strong>Timer Status:</strong> <span id="status">No active timers</span><br><br>
<span id="remaining" style="font-size:18px;"></span><br>
<button id="cancelButton" onclick="cancelTimer()">
CANCEL TIMER
</button>
</div>
</div>
<script>
// Update LED State
function updateLEDState() {
fetch('/led-state')
.then(r => r.json())
.then(data => {
const stateEl = document.getElementById('ledState');
if (data.state === 1) {
stateEl.innerHTML = '<span class="ledOn">ON</span>';
} else {
stateEl.innerHTML = '<span class="ledOff">OFF</span>';
}
})
.catch(() => {
document.getElementById('ledState').innerHTML = 'Error';
});
}
// Control LED
function controlLED(state) {
fetch('/control?state=' + state)
.then(() => updateLEDState());
}
// Timer functions
let countdownInterval = null;
let remainingSeconds = 0;
// Format time to hours, minutes and seconds
function formatTime(seconds) {
if (seconds <= 0) return "0s";
let h = Math.floor(seconds / 3600);
let m = Math.floor((seconds % 3600) / 60);
let s = seconds % 60;
if (h > 0) return h + "h " + m + "m " + s + "s";
if (m > 0) return m + "m " + s + "s";
return s + "s";
}
// Check the remaining time for the timer to end
function updateTimerStatus() {
fetch('/timer-status')
.then(r => r.json())
.then(data => {
document.getElementById('status').innerText = data.status;
const cancelButton = document.getElementById('cancelButton');
const remainingEl = document.getElementById('remaining');
if (data.active) {
remainingSeconds = data.remaining_seconds;
cancelButton.style.display = 'inline-block';
if (!countdownInterval) {
countdownInterval = setInterval(() => {
if (remainingSeconds > 0) {
remainingSeconds--;
remainingEl.innerText = "Remaining: " + formatTime(remainingSeconds);
} else {
clearInterval(countdownInterval);
countdownInterval = null;
}
}, 1000);
}
remainingEl.innerText = "Remaining: " + formatTime(remainingSeconds);
} else {
cancelButton.style.display = 'none';
remainingEl.innerText = "";
if (countdownInterval) {
clearInterval(countdownInterval);
countdownInterval = null;
}
}
});
}
// Cancel timer button
function cancelTimer() {
if (confirm("Cancel the current timer?")) {
fetch('/cancel-timer', { method: 'POST' })
.then(() => updateTimerStatus());
}
}
// Update every 10 seconds
setInterval(() => {
updateLEDState();
updateTimerStatus();
}, 10000);
window.onload = () => {
updateLEDState();
updateTimerStatus();
};
</script>
</body>
</html>
)rawliteral";
In the web page, we create two buttons that make requests on the following URL paths to control the LED on/off:
- TURN ON: /control?state=1
- TURN OFF: /control?state=0
<button class="onButton" onclick="controlLED(1)">TURN ON</button>
<button class="offButton" onclick="controlLED(0)">TURN OFF</button>
The other main section of the web page is the HTML web form where you can select the timer action, unit and duration. It also shows the current state of the timer, time remaining and a button to cancel the timer.
<div class="card">
<h2>Set Timer</h2>
<form id="timerForm" action="/set-timer" method="POST">
<label for="action">Action</label>
<select name="action" id="action">
<option value="" disabled selected hidden>--- Select Action ---</option>
<option value="1">TURN ON (HIGH)</option>
<option value="0">TURN OFF(LOW)</option>
</select>
<label for="unit">Time Unit</label>
<select name="unit" id="unit">
<option value="s">Seconds</option>
<option value="m">Minutes</option>
<option value="h">Hours</option>
</select>
<label for="duration">Duration</label>
<input type="number" name="duration" id="duration" min="1" value="30" required>
<button type="submit" class="startButton">START TIMER</button>
</form>
<div style="margin-top:20px; padding:15px; background:#f8f9fa; border-radius:8px;">
<strong>Timer Status:</strong> <span id="status">No active timers</span><br><br>
<span id="remaining" style="font-size:18px;"></span><br>
<button id="cancelButton" onclick="cancelTimer()">
CANCEL TIMER
</button>
</div>
</div>
Timer Callback Function
The timerCallback() function is the FreeRTOS one-shot timer handler that, when it expired, sets the GPIO ON/OFF based on the previously selected action in the web server.
void IRAM_ATTR timerCallback(TimerHandle_t xTimer) {
if (targetAction != -1) {
digitalWrite(ledPin, targetAction ? HIGH : LOW);
Serial.printf("Timer finished: GPIO %d set to %s\n", ledPin, targetAction ? "HIGH (ON)" : "LOW (OFF)");
}
targetAction = -1;
timerStartTick = 0;
totalDurationTicks = 0;
}
setup()
In the setup(), we start by initializing the Serial Monitor and preparing the GPIO.
Serial.begin(115200);
delay(1000);
pinMode(ledPin, OUTPUT);
digitalWrite(ledPin, LOW);
Then, create the gpioTimer and assign the timerCallback function described earlier that runs when the timer expires.
gpioTimer = xTimerCreate("GPIO_Timer", pdMS_TO_TICKS(1000), pdFALSE, 0, timerCallback);
Start the Wi-Fi connection and print the ESP32 IP address in the Serial Monitor.
WiFi.begin(ssid, password);
Serial.print("Connecting to Wi-Fi");
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println("\nWi-Fi Connected!");
// Print the ESP32 IP Address
Serial.print("Access ESP32 IP Address: http://");
Serial.println(WiFi.localIP());
Root URL /
Then, handle the web server. When you receive a request on the root (/) URL (this happens when you access the ESP IP address), send the HTML text stored in PROGMEM to display the web page:
server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) {
request->send_P(200, "text/html", index_html);
});
LED Control Route /control
This GET route controls the LED ON/OFF based on the state parameter (1 = HIGH and 0 = LOW).
server.on("/control", HTTP_GET, [](AsyncWebServerRequest *request) {
if (request->hasParam("state")) {
int state = request->getParam("state")->value().toInt();
digitalWrite(ledPin, state ? HIGH : LOW);
Serial.printf("LED set to %s\n", state ? "ON" : "OFF");
}
request->send(200, "text/plain", "OK");
});
GET LED State Route /led-state
This GET route reads the current state of the LED and returns it as a simple JSON object containing the state value.
server.on("/led-state", HTTP_GET, [](AsyncWebServerRequest *request) {
int state = digitalRead(ledPin);
request->send(200, "application/json", "{\"state\":" + String(state) + "}");
});
Set Timer Route /set-timer
This HTTP POST route receives the data from the web page with the selected action, duration, and time unit. It converts the duration into seconds and then FreeRTOS ticks to start the one-shot timer. When the timer expires, the action runs.
server.on("/set-timer", HTTP_POST, [](AsyncWebServerRequest *request) {
if (request->hasParam("action", true) && request->hasParam("duration", true) && request->hasParam("unit", true)) {
targetAction = request->getParam("action", true)->value().toInt();
int dur = request->getParam("duration", true)->value().toInt();
String unit = request->getParam("unit", true)->value();
uint64_t durationSeconds = 0;
if (unit == "s") durationSeconds = (uint64_t)dur;
else if (unit == "m") durationSeconds = (uint64_t)dur * 60ULL;
else if (unit == "h") durationSeconds = (uint64_t)dur * 3600ULL;
if (durationSeconds > 0) {
if (xTimerIsTimerActive(gpioTimer)) xTimerStop(gpioTimer, 0);
uint64_t tempTicks = durationSeconds * (uint64_t)configTICK_RATE_HZ;
TickType_t timerTicks = (tempTicks > 0xFFFFFFFFULL) ? 0xFFFFFFFFULL : (TickType_t)tempTicks;
timerStartTick = xTaskGetTickCount();
totalDurationTicks = tempTicks;
xTimerChangePeriod(gpioTimer, timerTicks, 0);
xTimerStart(gpioTimer, 0);
Serial.printf("Timer scheduled: GPIO %d will be set to %s in %llu seconds\n", ledPin,
targetAction ? "ON" : "OFF", durationSeconds);
}
}
request->redirect("/");
});
Cancel Timer Route /cancel-timer
This route stops any active FreeRTOS timer and resets the timer global variables (timerStartTick, totalDurationTicks) to their idle state.
server.on("/cancel-timer", HTTP_POST, [](AsyncWebServerRequest *request) {
// Stop the active timer
if (xTimerIsTimerActive(gpioTimer)) {
xTimerStop(gpioTimer, 0);
Serial.println("Timer cancelled");
}
targetAction = -1;
timerStartTick = 0;
totalDurationTicks = 0;
request->send(200, "text/plain", "OK");
});
Timer Status Route /timer-status
This route checks if a timer is running, calculates the exact remaining time in seconds, and returns a JSON object with the current status message.
server.on("/timer-status", HTTP_GET, [](AsyncWebServerRequest *request) {
if (targetAction != -1 && xTimerIsTimerActive(gpioTimer) && totalDurationTicks > 0) {
// Calculate how many seconds are left for the timer to end
TickType_t now = xTaskGetTickCount();
uint64_t elapsedTicks = 0;
if (now >= timerStartTick) {
elapsedTicks = now - timerStartTick;
} else {
elapsedTicks = (0xFFFFFFFFULL - timerStartTick) + now + 1;
}
uint64_t remainingTicks = (totalDurationTicks > elapsedTicks) ? totalDurationTicks - elapsedTicks : 0;
uint64_t remainingSeconds = remainingTicks / (uint64_t)configTICK_RATE_HZ;
// Return the amount of seconds left and action that will execute when the timer ends
String json = "{\"status\":\"Setting GPIO "+ String(ledPin) +" to " +
String(targetAction ? "ON" : "OFF") + "\",";
json += "\"active\":true,";
json += "\"remaining_seconds\":" + String((unsigned long)remainingSeconds) + "}";
request->send(200, "application/json", json);
} else {
request->send(200, "application/json", "{\"status\":\"No active timers\",\"active\":false,\"remaining_seconds\":0}");
}
});
Initialize the Server
Finally, call the begin() method on the server object to initialize the server.
server.begin();
Demonstration
After inserting your network credentials, upload the code to the ESP32 board. After uploading, open the Serial Monitor at a baud rate of 115200 and press the ESP32 RST button.

The ESP32 IP address will be printed in the Serial Monitor.

With an ESP32 that has an LED connected to GPIO 5, open any browser on your local network and type the ESP32 IP address. The following web page will load.

Click on the TURN ON or TURN OFF buttons to control the LED. You can also check the current LED state on the text label.

Next, you have an HTML form where you can select the action, time unit and timer duration. After selecting your desired timer settings, press the “START TIMER” button.

In the bottom of the web page, the time status will show the current pending action and remaining time. There’s also a button to cancel the timer.

In the Arduino IDE Serial Monitor, you can check all the actions that were performed in the web server.

Wrapping Up
In this tutorial, you learned how to create a simple web server to create a timer to control your ESP32 GPIOs. You can now extend this project to control more outputs. You can take a look at other tutorials:
- ESP32 Web Server: Display Sensor Readings in Gauges
- ESP32 Web Server: Control Stepper Motor (WebSocket)
- ESP32 Web Server (WebSocket) with Multiple Sliders: Control LEDs Brightness (PWM)
- ESP32 Web Server using Server-Sent Events (Update Sensor Readings Automatically)
If you would like to learn more about building web servers with the ESP32 from scratch, we recommend taking a look at our eBook dedicated to this subject:
Thanks for reading.



