Learn how to create an ESP32 web server that displays charts with data loaded from a .csv file saved on a microSD card.
In this project, we’ll build an ESP32 data logger using a BME280 sensor. It takes temperature, humidity, and pressure readings and logs those values with a timestamp to a .cvs file on a microSD card. The ESP32 also hosts a web server with real-time charts that loads the data stored in the .csv file. The ESP32 board 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 project:
- The ESP32 has a BME280 sensor attached that takes readings every 60 seconds and stores them on a microSD card in a .csv file;
- The ESP32 hosts a web page that displays 3 real-time charts: temperature, humidity, and pressure;
- On the web page, you can download or delete the .csv file to your device;
- The charts can be zoomed in and highlight each value;
- The web page is fully web responsive, so it runs on any screen size.
For simplicity, we’ll store the HTML and CSS for the web page in a variable in the Arduino sketch, but you can use the LittleFS Filesystem if you want separate files.
Prerequisites
Before proceeding with this tutorial, you should have the ESP32 add-on installed in your Arduino IDE:
You might also find helpful reading these other guides:
- ESP32: BME280 Sensor using Arduino IDE
- ESP32: MicroSD Card Module using Arduino IDE
- ESP32 Async Web Server (ESPAsyncWebServer library)
- ESP32 Datalogger: Download Data File via Web Server (Arduino IDE)
Parts Required
For this tutorial, you need the following parts:
- ESP32 development board (read: Best ESP32 development boards)
- MicroSD card module
- MicroSD card
- BME280 sensor module
- Jumper wires
- Breadboard
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!
Schematic Diagram – ESP32 with BME280 and MicroSD Card
We’re going to use I2C communication with the BME280 sensor module and SPI for the microSD card.
Wire the sensor and microSD card module using the following schematic (or use the tables below to verify the pin assignments).

Connect the BME280 sensor to the ESP32 default I2C pins:
| BME280 | ESP32 |
| SCK (SCL Pin) | GPIO 22 |
| SDI (SDA pin) | GPIO 21 |
The microSD card module communicates using SPI communication protocol. You can connect it to the ESP32 using the default SPI pins.
| MicroSD card module | ESP32 |
| 3V3 | 3.3V |
| CS | GPIO 5 |
| MOSI | GPIO 23 |
| CLK | GPIO 18 |
| MISO | GPIO 19 |
| GND | GND |
The final circuit should look similar to this:

Before proceeding with the tutorial, make sure you format your microSD card as FAT32 (learn more on our microSD card guide). Then, insert the microSD card into the module.
Install Required Libraries in Arduino IDE
For this project, you need to install the following libraries:
- ESPAsyncWebServer by ESP32Async
- AsyncTCP by ESP32Async
- Adafruit_BME280 library
- Adafruit_Sensor library
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.

Search for AsyncTCP and install the AsyncTCP by ESP32Async.

Search for Adafruit BME280 and install the library:

Finally, search for Adafruit Unified Sensor and install this library.

Arduino Sketch – ESP32 Web Server Charts with Historical Data (load .csv file)
The following code creates a web server that serves a web page that loads the sensor data stored in the microSD card .csv file to real-time charts.
/*
Rui Santos & Sara Santos - Random Nerd Tutorials
Complete project details at https://RandomNerdTutorials.com/esp32-web-server-charts-historical-data/
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 <WiFi.h>
#include <ESPAsyncWebServer.h>
#include <AsyncTCP.h>
#include <Adafruit_Sensor.h>
#include <Adafruit_BME280.h>
#include <SD.h>
#include <SPI.h>
#include <time.h>
// REPLACE WITH YOUR NETWORK CREDENTIALS
const char* ssid = "REPLACE_WITH_YOUR_SSID";
const char* password = "REPLACE_WITH_YOUR_PASSWORD";
// BME280 GPIOs
#define SDA_PIN 21
#define SCL_PIN 22
// MicroSD Card Module GPIOs
#define SD_CS 5
#define SD_MOSI 23
#define SD_MISO 19
#define SD_SCK 18
// NTP server
const char* ntpServer = "pool.ntp.org";
const long gmtOffset_sec = 3600; // Change to your timezone
const int daylightOffset_sec = 0;
// Timer to take new readings
unsigned long previousMillis = 0;
const long interval = 60000; // 60 seconds
// CSV filename
const char* dataFile = "/bme280_log.csv";
// AsyncWebServer Web Server
AsyncWebServer server(80);
// BME280
Adafruit_BME280 bme;
// Get Time
String getTimeStr() {
struct tm timeinfo;
if (!getLocalTime(&timeinfo)) return "00:00:00";
char buf[9]; sprintf(buf, "%02d:%02d:%02d", timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec);
return String(buf);
}
// Get Date
String getDateStr() {
struct tm timeinfo;
if (!getLocalTime(&timeinfo)) return "1970-01-01";
char buf[11]; sprintf(buf, "%04d-%02d-%02d", timeinfo.tm_year+1900, timeinfo.tm_mon+1, timeinfo.tm_mday);
return String(buf);
}
// Initialize MicroSD Card and create the .csv file
void initSD() {
SPI.begin(SD_SCK, SD_MISO, SD_MOSI, SD_CS);
if (!SD.begin(SD_CS)) { Serial.println("SD Card Failed"); return; }
if (!SD.exists(dataFile)) {
File f = SD.open(dataFile, FILE_WRITE);
if (f) { f.println("temp_c,temp_f,humidity,pressure,time,day"); f.close(); }
}
}
// Log BME280 data on the MicroSD Card
void logData() {
float tempC = bme.readTemperature();
float tempF = tempC * 9.0 / 5.0 + 32.0;
float hum = bme.readHumidity();
float press = bme.readPressure() / 100.0F;
File file = SD.open(dataFile, FILE_APPEND);
if (file) {
file.printf("%.2f,%.2f,%.2f,%.2f,%s,%s\n",
tempC, tempF, hum, press, getTimeStr().c_str(), getDateStr().c_str());
file.close();
}
}
void setup() {
Serial.begin(115200);
// Initialize BME280 Sensor
Wire.begin(SDA_PIN, SCL_PIN);
if (!bme.begin(0x76)) {
Serial.println("BME280 not found!");
while(1);
}
// 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());
// Configure NTP time
configTime(gmtOffset_sec, daylightOffset_sec, ntpServer);
delay(2000);
// Initialize MicroSD Card
initSD();
// Root URL handler for the mains HTML web page
server.on("/", HTTP_GET, [](AsyncWebServerRequest *request){
String html = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<title>ESP32 Charts with Data Logger</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/hammer.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chartjs-plugin-zoom.min.js"></script>
<style>
body {font-family: Arial, sans-serif; background:#f4f4f4; margin:0; padding:15px;}
.container {max-width:1300px; margin:auto; background:white; padding:20px; border-radius:10px; box-shadow:0 4px 15px rgba(0,0,0,0.1);}
h1 {text-align:center;}
.controls { text-align:center; margin:20px 0; display:flex; flex-wrap:wrap; justify-content:center; gap:12px; }
.btn { padding:12px 22px; font-size:16px; font-weight:600; border:none; border-radius:8px; cursor:pointer; transition:all 0.2s ease; box-shadow:0 2px 6px rgba(0,0,0,0.1); }
.btn:hover { transform:translateY(-2px); box-shadow:0 4px 12px rgba(0,0,0,0.15); }
.btn-blue { background:#2196F3; color:white; }
.btn-green { background:#4CAF50; color:white; }
.btn-red { background:#f44336; color:white; }
.btn-gray { background:#6c777d; color:white; }
.chart-container {position:relative; height:380px; margin-bottom:40px; border:1px solid #ddd; border-radius:8px; padding:15px; touch-action: none;}
.chart-header {display:flex; justify-content:space-between; align-items:center; margin-bottom:12px; flex-wrap:wrap; gap:10px;}
.toggle-group {display:inline-flex; background:#f1f1f1; border-radius:50px; padding:4px; box-shadow:inset 0 2px 4px rgba(0,0,0,0.1);}
.toggle-btn {padding:8px 24px; font-size:15px; font-weight:600; border:none; border-radius:50px; cursor:pointer; transition:all 0.3s;}
.toggle-btn.active {background:#393b41; color:white; box-shadow:0 2px 6px rgba(33,150,243,0.3);}
.latest-reading { background:#f8f8f8; padding:15px; font-size:16px; border-radius:5px; }
</style>
</head>
<body>
<div class="container">
<h1>ESP32 Charts with Data Logger</h1>
<p style="text-align:center;">New readings are added to your chart automatically, you don't need to refresh the web page.</p>
<div class="controls">
<button class="btn btn-blue" onclick="loadCSVData()">Refresh Charts</button>
<button class="btn btn-green" onclick="downloadCSV()">Download CSV</button>
<button class="btn btn-red" onclick="deleteData()">Delete All Data</button>
<button class="btn btn-gray" onclick="resetZoomAll()">Reset All Zoom</button>
</div>
<div class="chart-header">
<h2>Temperature</h2>
<div class="toggle-group">
<button id="btnC" class="toggle-btn active" onclick="setUnit('C')">°C</button>
<button id="btnF" class="toggle-btn" onclick="setUnit('F')">°F</button>
</div>
</div>
<div class="chart-container"><canvas id="tempChart"></canvas></div>
<h2>Humidity</h2>
<div class="chart-container"><canvas id="humChart"></canvas></div>
<h2>Pressure</h2>
<div class="chart-container"><canvas id="pressChart"></canvas></div>
<h3>Latest Reading</h3>
<pre id="latest" class="latest-reading"></pre>
</div>
<script>
let tempChart, humChart, pressChart;
let allData = [];
let currentUnit = 'C';
function createCharts() {
const zoomOptions = {
zoom: { wheel: { enabled: true }, pinch: { enabled: true }, mode: 'x', drag: { enabled: true } },
pan: { enabled: true, mode: 'x', threshold: 5 }
};
const makeChart = (canvasId, color, yLabel) => {
const chart = new Chart(document.getElementById(canvasId), {
type: 'line',
data: { labels: [], datasets: [{ label: yLabel, borderColor: color, tension: 0.2, data: [] }] },
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
scales: {
x: { title: { display: true, text: 'Timestamp' } },
y: { title: { display: true, text: yLabel } }
},
plugins: { zoom: zoomOptions }
}
});
// Double-click to reset zoom
document.getElementById(canvasId).addEventListener('dblclick', () => {
chart.resetZoom();
});
return chart;
};
tempChart = makeChart('tempChart', '#2ecc71', 'Temperature');
humChart = makeChart('humChart', '#3498db', 'Humidity (%)');
pressChart= makeChart('pressChart', '#8c479d', 'Pressure (hPa)');
}
async function loadCSVData() {
try {
const response = await fetch('/download');
const csvText = await response.text();
allData = parseCSV(csvText);
if (allData.length === 0) {
document.getElementById('latest').textContent = "No data logged yet.";
return;
}
const last = allData[allData.length-1];
document.getElementById('latest').innerHTML =
`Temperature: ${last.temp_c}°C / ${last.temp_f}°F<br>` +
`Humidity: ${last.hum}%<br>` +
`Pressure: ${last.press} hPa<br>` +
`<strong>${last.day} ${last.time}</strong>`;
const displayData = allData.slice(-1200);
const labels = displayData.map(d => d.day + " " + d.time);
const tempValues = displayData.map(d => currentUnit === 'C' ? parseFloat(d.temp_c) : parseFloat(d.temp_f));
tempChart.data.labels = labels;
tempChart.data.datasets[0].label = `Temperature (°${currentUnit})`;
tempChart.data.datasets[0].data = tempValues;
tempChart.update();
humChart.data.labels = labels;
humChart.data.datasets[0].data = displayData.map(d => parseFloat(d.hum));
humChart.update();
pressChart.data.labels = labels;
pressChart.data.datasets[0].data = displayData.map(d => parseFloat(d.press));
pressChart.update();
} catch(e) { console.error("Error:", e); }
}
function parseCSV(csv) {
const lines = csv.trim().split('\n');
const result = [];
for (let i = 1; i < lines.length; i++) {
if (!lines[i].trim()) continue;
const [temp_c, temp_f, hum, press, time, day] = lines[i].split(',');
result.push({temp_c, temp_f, hum, press, time, day});
}
return result;
}
function setUnit(unit) {
currentUnit = unit;
document.getElementById('btnC').classList.toggle('active', unit === 'C');
document.getElementById('btnF').classList.toggle('active', unit === 'F');
if (allData.length > 0) loadCSVData();
}
function downloadCSV() {
window.location.href = '/download';
}
function resetZoomAll() {
tempChart.resetZoom();
humChart.resetZoom();
pressChart.resetZoom();
}
async function deleteData() {
if (!confirm("Delete all logged data?")) return;
await fetch('/delete', {method: 'POST'});
alert("Data deleted");
loadCSVData();
}
window.onload = () => {
createCharts();
setUnit('C');
loadCSVData();
setInterval(loadCSVData, 30000);
};
</script>
</body>
</html>
)rawliteral";
request->send(200, "text/html; charset=UTF-8", html);
});
// Download .csv file stored on MicroSD Card
server.on("/download", HTTP_GET, [](AsyncWebServerRequest *request){
request->send(SD, dataFile, "text/csv", true);
});
// Delete .csv file stored on MicroSD Card
server.on("/delete", HTTP_POST, [](AsyncWebServerRequest *request){
if (SD.exists(dataFile)) SD.remove(dataFile);
File f = SD.open(dataFile, FILE_WRITE);
if (f) { f.println("temp_c,temp_f,humidity,pressure,time,day"); f.close(); }
request->send(200);
});
server.begin();
Serial.println("ESP32 Web Server: Charts with Historical Data Ready!");
}
void loop() {
// Timer
if (millis() - previousMillis >= interval) {
previousMillis = millis();
logData();
}
delay(10);
}
With the libraries mentioned previously installed in your Arduino IDE, you just need to insert your network credentials in the following lines 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, read the BME280 sensor, and communicate with the SD Card.
#include <WiFi.h>
#include <ESPAsyncWebServer.h>
#include <AsyncTCP.h>
#include <Adafruit_Sensor.h>
#include <Adafruit_BME280.h>
#include <SD.h>
#include <SPI.h>
#include <time.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";
GPIOs
Define the GPIOs that will be used for the I2C communication with the BME280 sensor and SPI communication with the microSD card module.
// BME280 GPIOs
#define SDA_PIN 21
#define SCL_PIN 22
// MicroSD Card Module GPIOs
#define SD_CS 5
#define SD_MOSI 23
#define SD_MISO 19
#define SD_SCK 18
NTP Server
We’ll request the time from pool.ntp.org, which is a cluster of timeservers that anyone can use to request the time (read our guide ESP32 NTP Client-Server: Get Date and Time).
const char* ntpServer = "pool.ntp.org";
const long gmtOffset_sec = 3600; // Change to your timezone
const int daylightOffset_sec = 0;
Timer
This timer will take new readings every 60 seconds; you can increase this number for a real-world application. For testing purposes and to gather more values in a short period of time, it’s set to 60 seconds.
unsigned long previousMillis = 0;
const long interval = 60000; // 60 seconds
CSV File
The file stored on the microSD card has the following name: bme280_log.csv.
const char* dataFile = "/bme280_log.csv";
Creating a Server Object
Create an AsyncWebServer object called server on port 80.
AsyncWebServer server(80);
Creating BME280 Object
The BME280 will read values using I2C communication protocol. You just need to create an Adafruit_BME280 object called bme as follows.
Adafruit_BME280 bme;
setup()
In the setup(), we start by initializing the Serial Monitor and initializing the BME280 sensor.
Serial.begin(115200);
// Initialize BME280 Sensor
Wire.begin(SDA_PIN, SCL_PIN);
if (!bme.begin(0x76)) {
Serial.println("BME280 not found!");
while(1);
}
Start the Wi-Fi connection and print the ESP32 IP address in the Serial Monitor. You’ll need it later to access the web server.
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());
Configure the time with the settings you’ve defined earlier:
configTime(gmtOffset_sec, daylightOffset_sec, ntpServer);
Call the function to initialize the microSD card.
initSD();
Here’s the initSD() function:
void initSD() {
SPI.begin(SD_SCK, SD_MISO, SD_MOSI, SD_CS);
if (!SD.begin(SD_CS)) { Serial.println("SD Card Failed"); return; }
if (!SD.exists(dataFile)) {
File f = SD.open(dataFile, FILE_WRITE);
if (f) { f.println("temp_c,temp_f,humidity,pressure,time,day"); f.close(); }
}
}
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 the html String variable to display the web page:
server.on("/", HTTP_GET, [](AsyncWebServerRequest *request){
(...)
request->send(200, "text/html; charset=UTF-8", html);
});
The html variable contains the text with the HTML, CSS, and JavaScript to build the web page.
<!DOCTYPE html>
<html>
<head>
<title>ESP32 Charts with Data Logger</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/hammer.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chartjs-plugin-zoom.min.js"></script>
<style>
body {font-family: Arial, sans-serif; background:#f4f4f4; margin:0; padding:15px;}
.container {max-width:1300px; margin:auto; background:white; padding:20px; border-radius:10px; box-shadow:0 4px 15px rgba(0,0,0,0.1);}
h1 {text-align:center;}
.controls { text-align:center; margin:20px 0; display:flex; flex-wrap:wrap; justify-content:center; gap:12px; }
.btn { padding:12px 22px; font-size:16px; font-weight:600; border:none; border-radius:8px; cursor:pointer; transition:all 0.2s ease; box-shadow:0 2px 6px rgba(0,0,0,0.1); }
.btn:hover { transform:translateY(-2px); box-shadow:0 4px 12px rgba(0,0,0,0.15); }
.btn-blue { background:#2196F3; color:white; }
.btn-green { background:#4CAF50; color:white; }
.btn-red { background:#f44336; color:white; }
.btn-gray { background:#6c777d; color:white; }
.chart-container {position:relative; height:380px; margin-bottom:40px; border:1px solid #ddd; border-radius:8px; padding:15px; touch-action: none;}
.chart-header {display:flex; justify-content:space-between; align-items:center; margin-bottom:12px; flex-wrap:wrap; gap:10px;}
.toggle-group {display:inline-flex; background:#f1f1f1; border-radius:50px; padding:4px; box-shadow:inset 0 2px 4px rgba(0,0,0,0.1);}
.toggle-btn {padding:8px 24px; font-size:15px; font-weight:600; border:none; border-radius:50px; cursor:pointer; transition:all 0.3s;}
.toggle-btn.active {background:#393b41; color:white; box-shadow:0 2px 6px rgba(33,150,243,0.3);}
.latest-reading { background:#f8f8f8; padding:15px; font-size:16px; border-radius:5px; }
</style>
</head>
<body>
<div class="container">
<h1>ESP32 Charts with Data Logger</h1>
<p style="text-align:center;">New readings are added to your chart automatically, you don't need to refresh the web page.</p>
<div class="controls">
<button class="btn btn-blue" onclick="loadCSVData()">Refresh Charts</button>
<button class="btn btn-green" onclick="downloadCSV()">Download CSV</button>
<button class="btn btn-red" onclick="deleteData()">Delete All Data</button>
<button class="btn btn-gray" onclick="resetZoomAll()">Reset All Zoom</button>
</div>
<div class="chart-header">
<h2>Temperature</h2>
<div class="toggle-group">
<button id="btnC" class="toggle-btn active" onclick="setUnit('C')">°C</button>
<button id="btnF" class="toggle-btn" onclick="setUnit('F')">°F</button>
</div>
</div>
<div class="chart-container"><canvas id="tempChart"></canvas></div>
<h2>Humidity</h2>
<div class="chart-container"><canvas id="humChart"></canvas></div>
<h2>Pressure</h2>
<div class="chart-container"><canvas id="pressChart"></canvas></div>
<h3>Latest Reading</h3>
<pre id="latest" class="latest-reading"></pre>
</div>
<script>
let tempChart, humChart, pressChart;
let allData = [];
let currentUnit = 'C';
function createCharts() {
const zoomOptions = {
zoom: { wheel: { enabled: true }, pinch: { enabled: true }, mode: 'x', drag: { enabled: true } },
pan: { enabled: true, mode: 'x', threshold: 5 }
};
const makeChart = (canvasId, color, yLabel) => {
const chart = new Chart(document.getElementById(canvasId), {
type: 'line',
data: { labels: [], datasets: [{ label: yLabel, borderColor: color, tension: 0.2, data: [] }] },
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
scales: {
x: { title: { display: true, text: 'Timestamp' } },
y: { title: { display: true, text: yLabel } }
},
plugins: { zoom: zoomOptions }
}
});
// Double-click to reset zoom
document.getElementById(canvasId).addEventListener('dblclick', () => {
chart.resetZoom();
});
return chart;
};
tempChart = makeChart('tempChart', '#2ecc71', 'Temperature');
humChart = makeChart('humChart', '#3498db', 'Humidity (%)');
pressChart= makeChart('pressChart', '#8c479d', 'Pressure (hPa)');
}
async function loadCSVData() {
try {
const response = await fetch('/download');
const csvText = await response.text();
allData = parseCSV(csvText);
if (allData.length === 0) {
document.getElementById('latest').textContent = "No data logged yet.";
return;
}
const last = allData[allData.length-1];
document.getElementById('latest').innerHTML =
`Temperature: ${last.temp_c}°C / ${last.temp_f}°F<br>` +
`Humidity: ${last.hum}%<br>` +
`Pressure: ${last.press} hPa<br>` +
`<strong>${last.day} ${last.time}</strong>`;
const displayData = allData.slice(-1200);
const labels = displayData.map(d => d.day + " " + d.time);
const tempValues = displayData.map(d => currentUnit === 'C' ? parseFloat(d.temp_c) : parseFloat(d.temp_f));
tempChart.data.labels = labels;
tempChart.data.datasets[0].label = `Temperature (°${currentUnit})`;
tempChart.data.datasets[0].data = tempValues;
tempChart.update();
humChart.data.labels = labels;
humChart.data.datasets[0].data = displayData.map(d => parseFloat(d.hum));
humChart.update();
pressChart.data.labels = labels;
pressChart.data.datasets[0].data = displayData.map(d => parseFloat(d.press));
pressChart.update();
} catch(e) { console.error("Error:", e); }
}
function parseCSV(csv) {
const lines = csv.trim().split('\n');
const result = [];
for (let i = 1; i < lines.length; i++) {
if (!lines[i].trim()) continue;
const [temp_c, temp_f, hum, press, time, day] = lines[i].split(',');
result.push({temp_c, temp_f, hum, press, time, day});
}
return result;
}
function setUnit(unit) {
currentUnit = unit;
document.getElementById('btnC').classList.toggle('active', unit === 'C');
document.getElementById('btnF').classList.toggle('active', unit === 'F');
if (allData.length > 0) loadCSVData();
}
function downloadCSV() {
window.location.href = '/download';
}
function resetZoomAll() {
tempChart.resetZoom();
humChart.resetZoom();
pressChart.resetZoom();
}
async function deleteData() {
if (!confirm("Delete all logged data?")) return;
await fetch('/delete', {method: 'POST'});
alert("Data deleted");
loadCSVData();
}
window.onload = () => {
createCharts();
setUnit('C');
loadCSVData();
setInterval(loadCSVData, 30000);
};
</script>
</body>
</html>
Number of Data Points
By default, in this example, the charts will display a maximum of 1200 data points. You can modify that in the following line.
const displayData = allData.slice(-1200);
Note that if you increase this value a lot, it can take more time to load all the data to the charts.
Download CSV File /download
This route sends the .csv file that is stored in the microSD card to the connected client (to load the data to create the charts).
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");
});
Delete CSV File /delete
This route deletes the .csv file stored in the microSD card.
server.on("/delete", HTTP_POST, [](AsyncWebServerRequest *request){
if (SD.exists(dataFile)) SD.remove(dataFile);
File f = SD.open(dataFile, FILE_WRITE);
if (f) { f.println("temp_c,temp_f,humidity,pressure,time,day"); f.close(); }
request->send(200);
});
Initialize the Server
Finally, call the begin() method on the server object to initialize the server.
server.begin();
loop()
In the loop() function, we have a timer that calls the logData() function to get new readings every interval value (by default it’s every 60 seconds).
if (millis() - previousMillis >= interval) {
previousMillis = millis();
logData();
}
The logData() function takes new BME280 sensor readings, stores them in auxiliary variables with a timestamp from the NTP server. Finally, it appends those readings to the last line of the .csv file.
void logData() {
float tempC = bme.readTemperature();
float tempF = tempC * 9.0 / 5.0 + 32.0;
float hum = bme.readHumidity();
float press = bme.readPressure() / 100.0F;
File file = SD.open(dataFile, FILE_APPEND);
if (file) {
file.printf("%.2f,%.2f,%.2f,%.2f,%s,%s\n",
tempC, tempF, hum, press, getTimeStr().c_str(), getDateStr().c_str());
file.close();
}
}
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.

Open any browser on your local network and type the ESP32 IP address. The following web page will load.

At the top of the web page, you have 4 buttons:
- Refresh Charts: the charts update in real-time, so you don’t need to use this button. However, in case it fails to load the .csv file from the microSD card, you can use that button;
- Download CSV: downloads the .csv file that is stored on your microSD card;
- Delete All Data: deletes all the data that has been stored in the .csv file;
- Reset All Zoom: you can zoom in into the charts to see the values more clearly, you can use this button to zoom out and get back to the first view.

By default, the temperature chart shows the values in Celsius degrees. There’s a button to switch to Fahrenheit degrees.

With your cursor, you can select a specific section of the chart to zoom in (it also works on devices with touchscreen. You can just pinch to zoom).

Then, that chart will be zoomed in. You can also select the values to see all the information highlighted as illustrated below. In order to zoom out, you can double-click the chart, or use the Reset All Zoom button at the top of the web page.

Next, you have the pressure chart, you can ignore the data, because I was just testing the project for a few days by powering it on/off.

Finally, at the bottom of the web page, it displays just the latest sensor readings.

Wrapping Up
In this tutorial, you learned how to create a project that logs temperature, humidity, and pressure data with timestamps to a .csv file on a microSD card. You learned how to get those values to display charts on a web page served by the ESP32.
If you liked this project, you may also like these other tutorials:
- ESP32 Datalogger: Download Data File via Web Server (Arduino IDE)
- 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.




Great Project,
I had a lot of problems with trying to use 32GB SD Card, found it works fine with 2GB SD Card.
Obviously my knowledge of SD Cards id missing, I very seldom use.
How can I change the interval (const long interval = 60000;) to read every 6 hrs. I have tried various but none work for me.
Again another great project from you both.
I am VERY rusty on code, so this will not compile, but you should get the idea.
if (millis() – previousMillis >= interval) {
previousMillis = millis();
ticker = ticker+1 ; increments every 60 seconds
}
if (ticker > =60) { ; after 60 minutes,
logData();
ticker =0 ;
}
this way, you can have a simple loop, if temp or humidity or pressure…. is in a certain range,
you can easily have a second loop that logs data more frequently.
if( temp >= 100
if (ticker > =5) { ; after 5 minutes,
logData();
ticker =0 ;
}
I hope this is useful and I hope someone will fix my mistakes.
Parfait !
I like it a lot. Is the BME280 more precise than a DHT22 ? Or a DS18B20 (for external/remote temp) ?
Could you explain how the graphics generation works ? It looks to some Javascript library that I’d like to know better !
Hi.
For this tutorial we use the charts.js library.
You can check the documentation to learn more: https://www.chartjs.org/docs/latest/getting-started/
Unfortunately, we still don’t have a tutorial explaining the javascript section of creating the charts using this library.
We have an explanation on how to build the charts using a different library: highcharts.js: https://randomnerdtutorials.com/esp32-esp8266-plot-chart-web-server/
Regards,
Sara
Hi Sara,
I had launched Arduino2 and installed all libraries mentioned in this tutorial.
Before I continued I let compile the sketch but a compilation error was generated.
Sorry for the lot of data, I can not simply summarize the compilation message.
In file included from C:\Users\User\Documents\Arduino\sketch_mar16a\sketch_mar16a.ino:8:
c:\Users\User\Documents\Arduino\libraries\ESP_Async_WebServer\src/ESPAsyncWebServer.h: In member function ‘tcp_state AsyncWebServer::state() const’:
c:\Users\User\Documents\Arduino\libraries\ESP_Async_WebServer\src/ESPAsyncWebServer.h:1689:49: error: passing ‘const AsyncServer’ as ‘this’ argument discards qualifiers [-fpermissive]
1689 | return static_cast(_server.status());
| ~~~~~~~~~~~~~~^~
In file included from c:\Users\User\Documents\Arduino\libraries\ESP_Async_WebServer\src/ESPAsyncWebServer.h:43:
c:\Users\User\Documents\Arduino\libraries\AsyncTCP\src/AsyncTCP.h:198:13: note: in call to ‘uint8_t AsyncServer::status()’
198 | uint8_t status();
| ^~~~~~
I do not understand at all what is causing this error.
I hope you can help me further.
Hi.
Can you tell me the version of your libraries?
– ESPASyncTCP by ESP32Async
– ESPAsyncWebServer by ESP32Async
Are you using the libraries by ESP32Async? Make sure you only have this version by ESP32Async installed and not other libraries with the same name.
Also double-check the version of your ESP32 boards installation: Tools > Boards > Boards Manager > Searvh for ESP32 and check the version.
Regards,
Sara
The issue was caused by an installed library on more than one place in /libraries.
After uninstalling one of them, the compilation was succesfull.
Great!
I commented about that before I read this comment.
I’m glad everything is working as expected now.
Regards,
Sara
Sara,
Any chance to port this project over using the esp32 CYD. This would be an excellent usage of the board.
Gary
Hi.
Thanks for your comment.
The most similar we have to what you’re asking is this one: https://randomnerdtutorials.com/esp32-cyd-lvgl-line-chart/
But, it doesn’t save the data on the microSD card.
Regards,
Sara
Hi Rui and Sara.
Just curious.
Is there a special reason why you use rawliteral
and not LittleFS for html, javascript and css files anymore?
They could also be placed on the SD card for that matter
in the data folder.
Not that it matters, I can easily change it to my needs.
by the way.
it’s nice to see that you’ve started using
async / await and fetch
It wasn’t to criticize.
It’s just because I needed to have the files
separated from each other in Pioarduini.
I needed to change the timestamp to day month and year
so I could get the date and time in the same String,
to be able to import the csv file into Highcharts.
and it now works as I want it to.
I didn’t explain properly in the previous post
so just one more time.
It wasn’t to criticize.
I just needed to have the files separated
from each other in Pioarduino,
I have changed the timestamp to day, month and year.
The date and time string are now combined into a single string.
The date time stamp is first on the line now, before
temperature, humidity and pressure.
I have removed Fahrenheit as it is not needed.
I can now import the csv file to Highcharts as well.
It is now working as I want it.
Great code you have made.
I must say google AI is quite efficient in terms of coding
I asked how to get getDayOfWeekStr
and I got this in response.
just to implement in your sketch.
// Get weekday
String getDayOfWeekStr() {
struct tm timeinfo;
if (!getLocalTime(&timeinfo)) return “Unknown”;
// Array index matches tm_wday (0 = Sunday, 1 = Monday, etc.)
const char* days[] = {“Sunday”, “Monday”, “Tuesday”, “Wednesday”, “Thursday”, “Friday”, “Saturday”};
return String(days[timeinfo.tm_wday]);
}
Google AI generated code
Short Name Using
short day of the week Mon, Tue, Wed, etc.
// Get weekdayshort
String getDayOfWeekShort() {
struct tm timeinfo;
if (!getLocalTime(&timeinfo)) return “—“;
char buf[4]; // 3 letters + null terminator
strftime(buf, sizeof(buf), “%a”, &timeinfo);
return String(buf);
}
Code to show only the last two digits of the year.
// Get Date
String getDateStr() {
struct tm timeinfo;
if (!getLocalTime(&timeinfo)) return “1970-01-01”;
char buf[9]; // Reduced from 11 to 9 to match the shorter string length
sprintf(buf, “%02d-%02d-%02d”, timeinfo.tm_mday, timeinfo.tm_mon + 1, (timeinfo.tm_year + 1900) % 100);
return String(buf);
}
I know it’s easy to come up with suggestions
once you’ve done all the hard work.
wouldn’t it be better to use Hardware Timer Interrupt
so we’re free from the dirty delay in the main loop.
Google AI code that I use in your sketch.
#include <esp_timer.h>
// Define your interval in microseconds (5 minutes)
const uint64_t TIMER_INTERVAL = 300000000;
// Define the function that will run every 5 minutes
void timerCallback(void* arg) {
// Add your action code here (e.g., read sensors, toggle relay)
Serial.println(“5 minutes have passed!”);
}
void setup() {
Serial.begin(115200);
// Configure the timer
const esp_timer_create_args_t timer_config = {
.callback = &timerCallback,
.arg = NULL,
.dispatch_method = ESP_TIMER_TASK,
.name = “5_min_hours”,
.skip_unhandled_events = false,
};
esp_timer_handle_t timer_handle;
esp_timer_create(&timer_config, &timer_handle);
// Start the periodic timer
esp_timer_start_periodic(timer_handle, TIMER_INTERVAL);
}
void loop() {
// Your main code can run here without being blocked
}
Sorry it was this code and not the one in the previous post
#include <esp_timer.h>
// Define your interval in microseconds (5 minutes)
const uint64_t TIMER_INTERVAL = 300000000;
// Define the function that will run every 5 minutes
void timerCallback(void* arg) {
// Add your action code here (e.g., read sensors, toggle relay)
Serial.println(“5 minutes have passed!”);
}
void setup() {
Serial.begin(115200);
// Configure the timer
const esp_timer_create_args_t timer_config = {
.callback = &timerCallback,
.arg = NULL,
.dispatch_method = ESP_TIMER_TASK,
.name = “5_min_hours”,
.skip_unhandled_events = false,
};
esp_timer_handle_t timer_handle;
esp_timer_create(&timer_config, &timer_handle);
// Start the periodic timer
esp_timer_start_periodic(timer_handle, TIMER_INTERVAL);
}
void loop() {
// Your main code can run here without being blocked
}