Learn how to build a web server with the ESP32 to display sensor readings in gauges. As an example, we’ll display temperature and humidity from a BME280 sensor in two different gauges: linear and radial. You can easily modify the project to plot any other data. To build the gauges, we’ll use the canvas-gauges JavaScript library.

We have a similar tutorial for the ESP8266 board: Web Server – Display Sensor Readings in Gauges
This project will build a web server with the ESP32 that displays temperature and humidity readings from a BME280 sensor. We’ll create a linear gauge that looks like a thermometer to display the temperature, and a radial gauge to display the humidity.

The readings are updated automatically on the web page using Server-Sent Events (SSE).

To learn more about SSE, you can read:
To keep our project better organized and easier to understand, we’ll save the HTML, CSS, and JavaScript files to build the web page on the board’s filesystem (LittleFS).
Make sure you check all the prerequisites in this section before continuing with the project.
We’ll program the ESP32 using Arduino IDE. So, you must have the ESP32 add-on installed. Follow the next tutorial if you haven’t already:
If you want to use VS Code with the PlatformIO extension, follow the next tutorial instead to learn how to program the ESP32:
To upload the HTML, CSS, and JavaScript files to the ESP32 flash memory (LittleFS), we’ll use a plugin for Arduino IDE: LittleFS Filesystem uploader. Follow the next tutorial to install the filesystem uploader plugin:
If you’re using VS Code with the PlatformIO extension, read the following tutorial to learn how to upload files to the filesystem:
To build this project, you need to install the following libraries:
You can install the first three libraries using the Arduino Library Manager. Go to Sketch > Include Library > Manage Libraries and search for the libraries’ names.
To follow this tutorial you need the following parts:
You can use any other sensor that is useful for your project. If you don’t have the sensor, you can also experiment with random values to learn how the project works.
You can use the preceding links or go directly to MakerAdvisor.com/tools to find all the parts for your projects at the best price!
We’ll send temperature and humidity readings from a BME280 sensor. We’re going to use I2C communication with the BME280 sensor module. For that, wire the sensor to the default ESP32 SCL (GPIO 22) and SDA (GPIO 21) pins, as shown in the following schematic diagram.

Recommended reading: ESP32 Pinout Reference: Which GPIO pins should you use?
To keep the project organized and make it easier to understand, we’ll create four files to build the web server:

You should save the HTML, CSS, and JavaScript files inside a folder called data inside the Arduino sketch folder, as shown in the previous diagram. We’ll upload these files to the ESP32 filesystem (LittleFS).
You can download all project files:
Copy the following to the index.html file.
<!DOCTYPE html> <html> <head> <title>ESP IOT DASHBOARD</title> <meta name="viewport" content="width=device-width, initial-scale=1"> <link rel="icon" type="image/png" href="favicon.png"> <link rel="stylesheet" type="text/css" href="style.css"> <script src="http://cdn.rawgit.com/Mikhus/canvas-gauges/gh-pages/download/2.1.7/all/gauge.min.js"></script> </head> <body> <div class="topnav"> <h1>ESP WEB SERVER GAUGES</h1> </div> <div class="content"> <div class="card-grid"> <div class="card"> <p class="card-title">Temperature</p> <canvas id="gauge-temperature"></canvas> </div> <div class="card"> <p class="card-title">Humidity</p> <canvas id="gauge-humidity"></canvas> </div> </div> </div> <script src="script.js"></script> </body> </html>
The HTML file for this project is very simple. It includes the JavaScript canvas-gauges library in the head of the HTML file:
<script src="https://cdn.rawgit.com/Mikhus/canvas-gauges/gh-pages/download/2.1.7/all/gauge.min.js"></script>
There is a <canvas> tag with the id gauge-temperature where we’ll render the temperature gauge later on.
<canvas id="gauge-temperature"></canvas>
There is also another <canvas> tag with the id gauge-humidity, where we’ll render the humidity gauge later on.
<canvas id="gauge-humidity"></canvas>
Copy the following styles to your style.css file. It styles the web page with simple colors and styles.
html { font-family: Arial, Helvetica, sans-serif; display: inline-block; text-align: center; } h1 { font-size: 1.8rem; color: white; } p { font-size: 1.4rem; } .topnav { overflow: hidden; background-color: #0A1128; } body { margin: 0; } .content { padding: 5%; } .card-grid { max-width: 1200px; margin: 0 auto; display: grid; grid-gap: 2rem; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); } .card { background-color: white; box-shadow: 2px 2px 12px 1px rgba(140,140,140,.5); } .card-title { font-size: 1.2rem; font-weight: bold; color: #034078 }
Copy the following to the script.js file.
// Get current sensor readings when the page loads window.addEventListener('load', getReadings); // Create Temperature Gauge var gaugeTemp = new LinearGauge({ renderTo: 'gauge-temperature', width: 120, height: 400, units: "Temperature C", minValue: 0, startAngle: 90, ticksAngle: 180, maxValue: 40, colorValueBoxRect: "#049faa", colorValueBoxRectEnd: "#049faa", colorValueBoxBackground: "#f1fbfc", valueDec: 2, valueInt: 2, majorTicks: [ "0", "5", "10", "15", "20", "25", "30", "35", "40" ], minorTicks: 4, strokeTicks: true, highlights: [ { "from": 30, "to": 40, "color": "rgba(200, 50, 50, .75)" } ], colorPlate: "#fff", colorBarProgress: "#CC2936", colorBarProgressEnd: "#049faa", borderShadowWidth: 0, borders: false, needleType: "arrow", needleWidth: 2, needleCircleSize: 7, needleCircleOuter: true, needleCircleInner: false, animationDuration: 1500, animationRule: "linear", barWidth: 10, }).draw(); // Create Humidity Gauge var gaugeHum = new RadialGauge({ renderTo: 'gauge-humidity', width: 300, height: 300, units: "Humidity (%)", minValue: 0, maxValue: 100, colorValueBoxRect: "#049faa", colorValueBoxRectEnd: "#049faa", colorValueBoxBackground: "#f1fbfc", valueInt: 2, majorTicks: [ "0", "20", "40", "60", "80", "100" ], minorTicks: 4, strokeTicks: true, highlights: [ { "from": 80, "to": 100, "color": "#03C0C1" } ], colorPlate: "#fff", borderShadowWidth: 0, borders: false, needleType: "line", colorNeedle: "#007F80", colorNeedleEnd: "#007F80", needleWidth: 2, needleCircleSize: 3, colorNeedleCircleOuter: "#007F80", needleCircleOuter: true, needleCircleInner: false, animationDuration: 1500, animationRule: "linear" }).draw(); // Function to get current readings on the webpage when it loads for the first time function getReadings(){ var xhr = new XMLHttpRequest(); xhr.onreadystatechange = function() { if (this.readyState == 4 && this.status == 200) { var myObj = JSON.parse(this.responseText); console.log(myObj); var temp = myObj.temperature; var hum = myObj.humidity; gaugeTemp.value = temp; gaugeHum.value = hum; } }; xhr.open("GET", "/readings", true); xhr.send(); } if (!!window.EventSource) { var source = new EventSource('/events'); source.addEventListener('open', function(e) { console.log("Events Connected"); }, false); source.addEventListener('error', function(e) { if (e.target.readyState != EventSource.OPEN) { console.log("Events Disconnected"); } }, false); source.addEventListener('message', function(e) { console.log("message", e.data); }, false); source.addEventListener('new_readings', function(e) { console.log("new_readings", e.data); var myObj = JSON.parse(e.data); console.log(myObj); gaugeTemp.value = myObj.temperature; gaugeHum.value = myObj.humidity; }, false); }
Here’s a summary of what this code does:
When you access the web page for the first time, we’ll request the server to get the current sensor readings. Otherwise, we would have to wait for new sensor readings to arrive (via Server-Sent Events), which can take some time depending on the interval that you set on the server.
Add an event listener that calls the getReadings function when the web page loads.
// Get current sensor readings when the page loads window.addEventListener('load', getReadings);
The window object represents an open window in a browser. The addEventListener() method sets up a function to be called when a certain event happens. In this case, we’ll call the getReadings function when the page loads (‘load’) to get the current sensor readings.
Now, let’s take a look at the getReadings function. Create a new XMLHttpRequest object. Then, send a GET request to the server on the /readings URL using the open() and send() methods.
function getReadings() { var xhr = new XMLHttpRequest(); xhr.open("GET", "/readings", true); xhr.send(); }
When we send that request, the ESP will send a response with the required information. So, we need to handle what happens when we receive the response. We’ll use the onreadystatechange property that defines a function to be executed when the readyState property changes. The readyState property holds the status of the XMLHttpRequest. The response of the request is ready when the readyState is 4, and the status is 200.
So, the request should look something like this:
function getStates(){ var xhr = new XMLHttpRequest(); xhr.onreadystatechange = function() { if (this.readyState == 4 && this.status == 200) { … DO WHATEVER YOU WANT WITH THE RESPONSE … } }; xhr.open("GET", "/states", true); xhr.send(); }
The response sent by the ESP is the following text in JSON format (those are just arbitrary values).
{ "temperature" : "25.02", "humidity" : "64.01", }
We need to convert the JSON string into a JSON object using the parse() method. The result is saved on the myObj variable.
var myObj = JSON.parse(this.responseText);
The myObj variable is a JSON object that contains the temperature and humidity readings. We want to update the gauges values with the corresponding readings.
Updating the value of a gauge is straightforward. For example, our temperature gauge is called gaugeTemp (as we’ll see later on), to update a value, we can simply call: gaugeTemp.value = NEW_VALUE. In our case, the new value is the temperature reading saved on the myObj JSON object.
gaugeTemp.value = myObj.temperature;
It is similar for the humidity (our humidity gauge is called gaugeHum).
gaugeHum.value = myObj.humidity;
Here’s the complete getReadings() function.
function getReadings(){ var xhr = new XMLHttpRequest(); xhr.onreadystatechange = function() { if (this.readyState == 4 && this.status == 200) { var myObj = JSON.parse(this.responseText); console.log(myObj); var temp = myObj.temperature; var hum = myObj.humidity; gaugeTemp.value = temp; gaugeHum.value = hum; } }; xhr.open("GET", "/readings", true); xhr.send(); }
The canvas-charts library allows you to build linear and radial gauges to display your readings. It provides several examples, and it is very simple to use. We recommend taking a look at the documentation and exploring all the gauges functionalities:
The following lines create the gauge to display the temperature.
// Create Temperature Gauge var gaugeTemp = new LinearGauge({ renderTo: 'gauge-temperature', width: 120, height: 400, units: "Temperature C", minValue: 0, startAngle: 90, ticksAngle: 180, maxValue: 40, colorValueBoxRect: "#049faa", colorValueBoxRectEnd: "#049faa", colorValueBoxBackground: "#f1fbfc", valueDec: 2, valueInt: 2, majorTicks: [ "0", "5", "10", "15", "20", "25", "30", "35", "40" ], minorTicks: 4, strokeTicks: true, highlights: [ { "from": 30, "to": 40, "color": "rgba(200, 50, 50, .75)" } ], colorPlate: "#fff", colorBarProgress: "#CC2936", colorBarProgressEnd: "#049faa", borderShadowWidth: 0, borders: false, needleType: "arrow", needleWidth: 2, needleCircleSize: 7, needleCircleOuter: true, needleCircleInner: false, animationDuration: 1500, animationRule: "linear", barWidth: 10, }).draw();
To create a new linear gauge, use the new LinearGauge() method and pass as an argument the properties of the gauge.
var gaugeTemp = new LinearGauge({
In the next line, define where you want to put the chart (it must be a <canvas> element). In our example, we want to place it in the <canvas> HTML element with the gauge-temperature id—see the HTML file section.
renderTo: 'gauge-temperature',
Then, we define other properties to customize our gauge. The names are self-explanatory, but we recommend taking a look at all possible configurations and changing the gauge to meet your needs.
In the end, you need to apply the draw() method to actually display the gauge on the canvas.
}).draw();
Special attention that if you need to change the gauge range, you need to change the minValue and maxValue properties:
minValue: 0,
maxValue: 40,
You also need to adjust the majorTicks values for the values displayed on the axis.
majorTicks: [ "0", "5", "10", "15", "20", "25", "30", "35", "40" ],
Creating the humidity gauge is similar, but we use the new RadialGauge() function instead and it is rendered to the <canvas> with the gauge-humidity id. Notice that we apply the draw() method on the gauge so that it is drawn on the canvas.
// Create Humidity Gauge var gaugeHum = new RadialGauge({ renderTo: 'gauge-humidity', width: 300, height: 300, units: "Humidity (%)", minValue: 0, maxValue: 100, colorValueBoxRect: "#049faa", colorValueBoxRectEnd: "#049faa", colorValueBoxBackground: "#f1fbfc", valueInt: 2, majorTicks: [ "0", "20", "40", "60", "80", "100" ], minorTicks: 4, strokeTicks: true, highlights: [ { "from": 80, "to": 100, "color": "#03C0C1" } ], colorPlate: "#fff", borderShadowWidth: 0, borders: false, needleType: "line", colorNeedle: "#007F80", colorNeedleEnd: "#007F80", needleWidth: 2, needleCircleSize: 3, colorNeedleCircleOuter: "#007F80", needleCircleOuter: true, needleCircleInner: false, animationDuration: 1500, animationRule: "linear" }).draw();
Update the readings on the gauge when the client receives the readings on the new_readings event
Create a new EventSource object and specify the URL of the page sending the updates. In our case, it’s /events.
if (!!window.EventSource) { var source = new EventSource('/events');
Once you’ve instantiated an event source, you can start listening for messages from the server with addEventListener().
These are the default event listeners, as shown here in the AsyncWebServer documentation.
source.addEventListener('open', function(e) { console.log("Events Connected"); }, false); source.addEventListener('error', function(e) { if (e.target.readyState != EventSource.OPEN) { console.log("Events Disconnected"); } }, false); source.addEventListener('message', function(e) { console.log("message", e.data); }, false);
Then, add the event listener for new_readings.
source.addEventListener('new_readings', function(e) {
When new readings are available, the ESP32 sends an event (new_readings) to the client. The following lines handle what happens when the browser receives that event.
source.addEventListener('new_readings', function(e) { console.log("new_readings", e.data); var myObj = JSON.parse(e.data); console.log(myObj); gaugeTemp.value = myObj.temperature; gaugeHum.value = myObj.humidity; }, false);
Basically, print the new readings on the browser console, convert the data into a JSON object and display the readings on the corresponding gauges.
Copy the following code to your Arduino IDE or to the main.cpp file if you’re using PlatformIO.
You can also download all the files here.
/********* Rui Santos & Sara Santos - Random Nerd Tutorials Complete instructions at https://RandomNerdTutorials.com/esp32-web-server-gauges/ 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 "LittleFS.h" #include <Arduino_JSON.h> #include <Adafruit_BME280.h> #include <Adafruit_Sensor.h> // Replace with your network credentials const char* ssid = "REPLACE_WITH_YOUR_SSID"; const char* password = "REPLACE_WITH_YOUR_PASSWORD"; // Create AsyncWebServer object on port 80 AsyncWebServer server(80); // Create an Event Source on /events AsyncEventSource events("/events"); // Json Variable to Hold Sensor Readings JSONVar readings; // Timer variables unsigned long lastTime = 0; unsigned long timerDelay = 10000; // Create a sensor object Adafruit_BME280 bme; // BME280 connect to ESP32 I2C (GPIO 21 = SDA, GPIO 22 = SCL) // Init BME280 void initBME(){ if (!bme.begin(0x76)) { Serial.println("Could not find a valid BME280 sensor, check wiring!"); while (1); } } // Get Sensor Readings and return JSON object String getSensorReadings(){ readings["temperature"] = String(bme.readTemperature()); readings["humidity"] = String(bme.readHumidity()); String jsonString = JSON.stringify(readings); return jsonString; } // Initialize LittleFS void initLittleFS() { if (!LittleFS.begin()) { Serial.println("An error has occurred while mounting LittleFS"); } Serial.println("LittleFS mounted successfully"); } // Initialize WiFi void initWiFi() { WiFi.mode(WIFI_STA); WiFi.begin(ssid, password); Serial.print("Connecting to WiFi .."); while (WiFi.status() != WL_CONNECTED) { Serial.print('.'); delay(1000); } Serial.println(WiFi.localIP()); } void setup() { // Serial port for debugging purposes Serial.begin(115200); initBME(); initWiFi(); initLittleFS(); // Web Server Root URL server.on("/", HTTP_GET, [](AsyncWebServerRequest *request){ request->send(LittleFS, "/index.html", "text/html"); }); server.serveStatic("/", LittleFS, "/"); // Request for the latest sensor readings server.on("/readings", HTTP_GET, [](AsyncWebServerRequest *request){ String json = getSensorReadings(); request->send(200, "application/json", json); json = String(); }); events.onConnect([](AsyncEventSourceClient *client){ if(client->lastId()){ Serial.printf("Client reconnected! Last message ID that it got is: %u\n", client->lastId()); } // send event with message "hello!", id current millis // and set reconnect delay to 1 second client->send("hello!", NULL, millis(), 10000); }); server.addHandler(&events); // Start server server.begin(); } void loop() { if ((millis() - lastTime) > timerDelay) { // Send Events to the client with the Sensor Readings Every 10 seconds events.send("ping",NULL,millis()); events.send(getSensorReadings().c_str(),"new_readings" ,millis()); lastTime = millis(); } }
Let’s take a look at the code and see how it works to send readings to the client using server-sent events.
The Adafruit_Sensor and Adafruit_BME280 libraries are needed to interface with the BME280 sensor.
#include <Adafruit_BME280.h> #include <Adafruit_Sensor.h>
The WiFi, ESPAsyncWebServer, and AsyncTCP libraries are used to create the web server.
#include <WiFi.h> #include <AsyncTCP.h> #include <ESPAsyncWebServer.h>
We’ll use LittleFS to save the files to build the web server.
#include "LittleFS.h"You also need to include the Arduino_JSON library to make it easier to handle JSON strings.
#include <Arduino_JSON.h>Insert your network credentials in the following variables, so that the ESP32 can connect to your local network using Wi-Fi.
const char* ssid = "REPLACE_WITH_YOUR_SSID"; const char* password = "REPLACE_WITH_YOUR_PASSWORD";
Create an AsyncWebServer object on port 80.
AsyncWebServer server(80);
The following line creates a new event source on /events.
AsyncEventSource events("/events");
The readings variable is a JSON variable to hold the sensor readings in JSON format.
JSONVar readings;The lastTime and the timerDelay variables will be used to update sensor readings every X number of seconds. As an example, we’ll get new sensor readings every 30 seconds (30000 milliseconds). You can change that delay time in the timerDelay variable.
unsigned long lastTime = 0; unsigned long timerDelay = 30000;
Create an Adafruit_BME280 object called bme on the default ESP I2C pins.
Adafruit_BME280 bme;The following function can be called to initialize the BME280 sensor.
// Init BME280 void initBME(){ if (!bme.begin(0x76)) { Serial.println("Could not find a valid BME280 sensor, check wiring!"); while (1); } }
To get temperature and humidity from the BME280 temperature, use the following methods on the bme object:
The getSensorReadings() function gets the sensor readings and saves them on the readings JSON array.
// Get Sensor Readings and return JSON object String getSensorReadings(){ readings["temperature"] = String(bme.readTemperature()); readings["humidity"] = String(bme.readHumidity()); String jsonString = JSON.stringify(readings); return jsonString; }
The readings array is then converted into a JSON string variable using the stringify() method and saved on the jsonString variable.
The function returns the jsonString variable with the current sensor readings. The JSON string has the following format (the values are just arbitrary numbers for explanation purposes).
{ "temperature" : "25", "humidity" : "50" }
In the setup(), initialize the Serial Monitor, Wi-Fi, filesystem, and the BME280 sensor.
void setup() { // Serial port for debugging purposes Serial.begin(115200); initBME(); initWiFi(); initLittleFS ();
When you access the ESP32 IP address on the root / URL, send the text that is stored on the index.html file to build the web page.
server.on("/", HTTP_GET, [](AsyncWebServerRequest *request){ request->send(LittleFS, "/index.html", "text/html"); });
Serve the other static files requested by the client (style.css and script.js).
server.serveStatic("/", LittleFS, "/");
Send the JSON string with the current sensor readings when you receive a request on the /readings URL.
// Request for the latest sensor readings server.on("/readings", HTTP_GET, [](AsyncWebServerRequest *request){ String json = getSensorReadings(); request->send(200, "application/json", json); json = String(); });
The json variable holds the return from the getSensorReadings() function. To send a JSON string as response, the send() method accepts as first argument the response code (200), the second is the content type (“application/json”) and finally the content (json variable).
Set up the event source on the server.
events.onConnect([](AsyncEventSourceClient *client){ if(client->lastId()){ Serial.printf("Client reconnected! Last message ID that it got is: %u\n", client->lastId()); } // send event with message "hello!", id current millis // and set reconnect delay to 1 second client->send("hello!", NULL, millis(), 10000); }); server.addHandler(&events);
Finally, start the server.
server.begin();
In the loop(), send events to the browser with the newest sensor readings to update the web page every 30 seconds.
events.send("ping",NULL,millis()); events.send(getSensorReadings().c_str(),"new_readings" ,millis());
Use the send() method on the events object and pass as an argument the content you want to send and the name of the event. In this case, we want to send the JSON string returned by the getSensorReadings() function. The send() method accepts a variable of type char, so we need to use the c_str() method to convert the variable. The name of the events is new_readings.
Usually, we also send a ping message every X number of seconds. That line is not mandatory. It is used to check on the client side that the server is alive.
events.send("ping",NULL,millis());
After inserting your network credentials, save the code. Go to Sketch > Show Sketch Folder, and create a folder called data.

Inside that folder, you should save the HTML, CSS, and JavaScript files.
Then, upload the code to your ESP32 board. Make sure you have the right board and COM port selected. Also, make sure you’ve added your network credentials.
After uploading the code, you need to upload the files to the filesystem.
Press [Ctrl] + [Shift] + [P] on Windows or [⌘] + [Shift] + [P] on MacOS to open the command palette. Search for the Upload LittleFS to Pico/ESP8266/ESP32 command and click on it.
If you don’t have this option is because you didn’t install the filesystem uploader plugin. Check this tutorial.

Important: make sure the Serial Monitor is closed before uploading to the filesystem. Otherwise, the upload will fail.
When everything is successfully uploaded, open the Serial Monitor at a baud rate of 115200. Press the ESP32 EN/RST button, and it should print the ESP32 IP address.
Open your browser and type the ESP32 IP address. You should get access to the web page that shows the gauges with the latest sensor readings.

You can also check your gauges using your smartphone (the web page is mobile responsive).

Copyright ©2025. All Rights Reserved Emblab THE RAVE INNOVATION