ESP32 vs Pi Zero vs Jetson Nano: First TinyML Pick

Disclosure: As an Amazon Associate, I earn from qualifying purchases. Some links in this post are affiliate links — they cost you nothing extra.
⚡ Key Takeaways
  • ESP32-S3 is 16x more power-efficient than Jetson Nano for always-on inference (0.3W vs 5W idle), making it the only viable choice for battery-powered projects.
  • Jetson Nano delivers 40-50x faster inference than ESP32 for video tasks, but thermal throttling kicks in at 78°C without active cooling.
  • Raspberry Pi Zero 2 W offers the easiest development workflow with standard Python and TFLite, but preprocessing bottlenecks (340ms NumPy vs 12ms CUDA) limit real-time applications.
  • ESP32 model size is capped at 400KB due to SRAM limits, forcing aggressive quantization and architecture compromises (MobileNet v2 width_multiplier=0.35).
  • INT8 quantization drops accuracy 1-5% depending on task complexity; always validate quantized models on held-out test sets before deploying to edge devices.

The $5 Board Beat the $150 One

I ran the same MobileNet v2 quantized model across three edge boards — ESP32-S3, Raspberry Pi Zero 2 W, and Jetson Nano — expecting the Jetson to dominate. It didn’t. For a wake-word detection task running 24/7, the ESP32 pulled 0.3W idle versus the Nano’s 5W, making it 16x more power-efficient for always-on inference. The Pi Zero sat in the middle at 1.2W but choked on anything beyond INT8 models.

If you’re building your first TinyML project, the board choice matters more than the model architecture. Pick wrong and you’ll spend weeks fighting thermal throttling, power budgets, or toolchain hell. Here’s what actually happened when I deployed the same 10-class audio classifier on each platform.

Arduino and LoRa components set up on a breadboard for a DIY project.
Photo by Bmonster Lab on Pexels

Power Budget Reality Check

The Jetson Nano’s 5W idle consumption makes it a non-starter for battery-powered projects. Running inference bumps it to 8-10W, which drains a 10,000mAh USB power bank in under 5 hours. The ESP32-S3 pulls 80mA at 3.3V during active inference (0.26W) and drops to 10mA in light sleep between predictions. That’s the difference between recharging daily versus monthly.

But power efficiency comes with compute tradeoffs. The ESP32’s dual-core Xtensa LX7 at 240MHz delivers roughly 600 DMIPS, while the Nano’s quad-core Cortex-A57 at 1.43GHz pushes 9,000+ DMIPS. For tasks requiring real-time video processing (YOLOv5, pose estimation), the Nano’s 128 CUDA cores make it 40-50x faster than the ESP32’s CPU-only inference.

The Pi Zero 2 W splits the difference with a quad-core Cortex-A53 at 1GHz (around 4,000 DMIPS) and 1.2W average power draw. It’s the Goldilocks option for projects that need more than microcontroller-class compute but can’t justify the Nano’s power budget.

Enjoying this article? Get more like it delivered to your inbox. Subscribe to the newsletter

Toolchain Pain Points

ESP-IDF (the ESP32’s native framework) supports TensorFlow Lite for Microcontrollers out of the box, but you’ll hit cryptic linker errors if your model exceeds 400KB. The ESP32-S3 has 512KB SRAM, but the TFLite interpreter itself consumes 180-200KB, leaving around 300KB for model weights and activations. I had to requantize a MobileNet v2 from INT8 to INT4 using TFLite’s experimental quantization API to squeeze under the limit.

The Pi Zero runs standard TFLite (not the micro variant), giving you the full Python API and model support. Installation is a single pip install tflite-runtime, and you can load models directly from .tflite files without recompiling firmware. The catch? NumPy operations are painfully slow on the 1GHz ARM Cortex-A53. Preprocessing a 96×96 spectrogram with librosa.feature.melspectrogram took 340ms — longer than the 120ms inference time.

Jetson Nano supports the full TensorRT stack, which means you can convert PyTorch/ONNX models to optimized .engine files with FP16 or INT8 precision. The conversion process is finicky (TensorRT 8.x fails on certain ONNX opsets), but once it works, inference speed jumps 3-5x compared to TFLite. For a MobileNet v2 classifier, I measured 8ms on TensorRT vs 28ms on TFLite.

Model Deployment Workflow

Here’s the ESP32 deployment flow for a 10-class wake-word model:

import tensorflow as tf

# Train a small model (Keras API)
model = tf.keras.Sequential([
    tf.keras.layers.Input(shape=(49, 40, 1)),  # Mel spectrogram
    tf.keras.layers.Conv2D(16, 3, activation='relu'),
    tf.keras.layers.MaxPooling2D(2),
    tf.keras.layers.Conv2D(32, 3, activation='relu'),
    tf.keras.layers.GlobalAveragePooling2D(),
    tf.keras.layers.Dense(10, activation='softmax')
])
model.compile(optimizer='adam', loss='sparse_categorical_crossentropy')
model.fit(train_data, epochs=20)

# Convert to TFLite with INT8 quantization
converter = tf.lite.TFLiteConverter.from_keras_model(model)
converter.optimizations = [tf.lite.Optimize.DEFAULT]
converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8]
converter.inference_input_type = tf.int8
converter.inference_output_type = tf.int8

def representative_dataset():
    for sample in train_data.take(100):
        yield [sample[0]]  # Must be float32 input

converter.representative_dataset = representative_dataset
tflite_model = converter.convert()

# Check model size (must be <400KB for ESP32-S3)
print(f"Model size: {len(tflite_model) / 1024:.1f} KB")
with open('model.tflite', 'wb') as f:
    f.write(tflite_model)

The ESP-IDF build system expects a C byte array, not a .tflite file:

xxd -i model.tflite > model_data.cc

This generates a unsigned char model_tflite[] array you include in your ESP32 firmware. The TFLite Micro interpreter allocates a static tensor arena (typically 100-200KB), so you need to profile memory usage with tflite::MicroErrorReporter to avoid stack overflows.

On the Pi Zero, deployment is trivial Python:

import tflite_runtime.interpreter as tflite
import numpy as np

interpreter = tflite.Interpreter(model_path='model.tflite')
interpreter.allocate_tensors()

input_details = interpreter.get_input_details()
output_details = interpreter.get_output_details()

# Assume preprocessed spectrogram input
interpreter.set_tensor(input_details[0]['index'], spectrogram)
interpreter.invoke()
output = interpreter.get_tensor(output_details[0]['index'])
predicted_class = np.argmax(output)

No firmware compilation, no memory profiling hell. You can SSH in, edit the Python script, and rerun it immediately.

Jetson Nano adds a TensorRT conversion step:

import tensorrt as trt
import pycuda.driver as cuda

# Convert TFLite to ONNX (use tf2onnx or onnx-tf)
# Then optimize with TensorRT
TRT_LOGGER = trt.Logger(trt.Logger.WARNING)
builder = trt.Builder(TRT_LOGGER)
network = builder.create_network(1 << int(trt.NetworkDefinitionCreationFlag.EXPLICIT_BATCH))
parser = trt.OnnxParser(network, TRT_LOGGER)

with open('model.onnx', 'rb') as f:
    parser.parse(f.read())

config = builder.create_builder_config()
config.set_memory_pool_limit(trt.MemoryPoolType.WORKSPACE, 1 << 30)  # 1GB
config.set_flag(trt.BuilderFlag.INT8)  # Requires calibration dataset

engine = builder.build_serialized_network(network, config)
with open('model.engine', 'wb') as f:
    f.write(engine)

TensorRT’s INT8 calibration requires a representative dataset (similar to TFLite), but the speedup is worth it. My MobileNet v2 latency dropped from 28ms (TFLite FP32) to 8ms (TensorRT INT8).

Inference Latency Breakdown

I measured end-to-end latency for a 10-class audio classifier (input: 96×96 Mel spectrogram, model: MobileNet v2 width_multiplier=0.35, INT8 quantized):

Board Preprocessing Inference Total Power
ESP32-S3 45ms (C++) 180ms 225ms 0.3W
Pi Zero 2 W 340ms (NumPy) 120ms 460ms 1.2W
Jetson Nano 12ms (CUDA) 8ms 20ms 8W

The Jetson’s CUDA-accelerated preprocessing (via OpenCV compiled with CUDA support) destroys the Pi’s NumPy-based pipeline. But for wake-word detection running every 500ms, even the ESP32’s 225ms latency is acceptable. The Pi Zero’s 460ms total time is borderline — if you need sub-200ms response, it’s not viable without offloading preprocessing to C/C++.

Thermal Throttling Surprises

The Jetson Nano hit 78°C under continuous inference load (ambient 24°C, no heatsink) and throttled the CPU from 1.43GHz to 1.0GHz after 15 minutes. Adding a $12 Noctua 40mm fan dropped temps to 52°C and eliminated throttling. The Pi Zero 2 W peaked at 68°C but didn’t throttle — the Cortex-A53 has better thermal headroom at 1GHz than the Nano’s Cortex-A57 at 1.43GHz.

The ESP32-S3 stayed at 41°C during inference, rising to 48°C when also driving an I2S microphone and OLED display. Microcontrollers don’t throttle — they just crash if you exceed thermal limits (usually 85-105°C depending on the chip).

Storage and I/O Constraints

The ESP32-S3 has 8MB flash (via external QSPI), enough for firmware, model weights, and a small circular buffer for audio samples. If you need to log inference results or retrain on-device, you’ll need an SD card breakout (adds $3 and complexity). The Pi Zero and Jetson both use microSD cards (16GB minimum recommended), making data logging trivial.

For I/O, the ESP32 shines with built-in peripherals: I2S for MEMS microphones, SPI for displays, I2C for sensors, and ADC for analog inputs. The Pi Zero requires USB dongles for most sensors (no built-in ADC), and the Jetson’s 40-pin GPIO is slower than the ESP32’s direct peripheral access. If your project involves reading from multiple sensors at >1kHz, the ESP32’s hardware timer interrupts and DMA channels make life easier.

When Each Board Wins

Pick the ESP32-S3 when:
– Battery-powered or solar-powered projects (power budget <0.5W)
– Always-on inference (wake-word, anomaly detection, vibration monitoring)
– Model size <400KB and inference latency <500ms is acceptable
– You need tight hardware integration (I2S microphones, analog sensors, actuators)
– Cost matters (the ESP32-S3-DevKitC-1 is $10 vs $15 for Pi Zero vs $150 for Jetson)

Pick the Pi Zero 2 W when:
– You want standard Linux userspace (apt, pip, SSH) without cross-compiling firmware
– Model size 1-10MB, inference latency 100-300ms
– Power budget 1-2W (USB power bank lasts a full day)
– You’re prototyping and need fast iteration (edit Python, rerun, no flashing)
– You need Bluetooth/WiFi without external modules

Pick the Jetson Nano when:
– Real-time video inference (object detection, pose estimation, segmentation)
– Inference latency <20ms required
– You can afford 5-10W power budget (wall power or large battery)
– TensorRT optimization is worth the toolchain complexity
– You need CUDA-accelerated preprocessing (OpenCV GPU, cuDNN)

For my first TinyML project — a wake-word detector running 24/7 on a 5,000mAh battery — the ESP32 was the only viable choice. The Pi would drain in 2 days, the Jetson in 5 hours.

Detailed view of a Raspberry Pi circuit board with microchips and components.
Photo by Alessandro Oliverio on Pexels

Model Architecture Constraints

The ESP32 forces you into truly tiny models. MobileNet v2 with width_multiplier=0.35 and input size 96×96 produces a 380KB INT8 model — right at the edge of SRAM limits. Going larger requires external PSRAM (the ESP32-S3 supports up to 8MB PSRAM), but PSRAM is 10x slower than internal SRAM, tanking inference speed.

The Pi Zero handles full MobileNet v2 (3.5MB) and even small EfficientNet-Lite models (4-6MB) without issue. The bottleneck is CPU speed, not memory. If you’re doing audio classification, a 2D CNN with 3-4 conv layers works fine. For image tasks, stick with MobileNet v2 or v3-small — anything larger will exceed 500ms latency.

The Jetson Nano runs full ResNet-50, EfficientNet-B3, and even stripped-down YOLO models. The 4GB RAM and 128 CUDA cores handle models up to 50-100MB (after TensorRT optimization). But at that point, you’re no longer doing “TinyML” — you’ve crossed into edge inference for robotics and industrial vision.

The Quantization Tax

INT8 quantization introduces accuracy loss, and the impact varies by task. For my 10-class wake-word classifier, FP32 → INT8 dropped validation accuracy from 94.2% to 92.8% (1.4% loss). For a 100-class ImageNet subset, the drop was 4.7% (76.3% → 71.6%). Quantization-aware training (QAT) recovered 2-3% of the lost accuracy, but it requires retraining the full model with fake quantization ops:

import tensorflow_model_optimization as tfmot

quantize_model = tfmot.quantization.keras.quantize_model
q_aware_model = quantize_model(model)
q_aware_model.compile(optimizer='adam', loss='sparse_categorical_crossentropy')
q_aware_model.fit(train_data, epochs=10)  # Fine-tune with quantization simulation

The ESP32 requires INT8 for any reasonable model size. The Pi Zero can run INT8 or FP32 (FP32 is 2-3x slower). The Jetson supports FP32, FP16, and INT8 — FP16 is the sweet spot, offering 1.5-2x speedup over FP32 with negligible accuracy loss.

Post-Quantization Accuracy Check

Always validate your quantized model on a held-out test set before deploying. TFLite’s quantization can silently break models if your representative dataset doesn’t cover the input distribution:

# Load both models
fp32_interp = tflite.Interpreter(model_path='model_fp32.tflite')
int8_interp = tflite.Interpreter(model_path='model_int8.tflite')
fp32_interp.allocate_tensors()
int8_interp.allocate_tensors()

# Compare outputs on test set
for sample, label in test_data:
    fp32_interp.set_tensor(fp32_input_idx, sample)
    int8_interp.set_tensor(int8_input_idx, sample)
    fp32_interp.invoke()
    int8_interp.invoke()

    fp32_pred = np.argmax(fp32_interp.get_tensor(fp32_output_idx))
    int8_pred = np.argmax(int8_interp.get_tensor(int8_output_idx))

    if fp32_pred != int8_pred:
        print(f"Mismatch: FP32={fp32_pred}, INT8={int8_pred}, True={label}")

I’ve seen quantization flip predictions on edge cases (low-contrast images, noisy audio) where the FP32 model was already uncertain. If your task has safety implications (medical, automotive), test exhaustively.

Power Profiling Methodology

I measured power with a USB multimeter (RuiDeng UM25C) inline between the power supply and each board. For the ESP32, I also used the ESP-IDF’s built-in power monitoring (samples internal voltage/current at 1kHz). Key findings:

  • ESP32 deep sleep: 10mA (0.033W) — usable for duty-cycled inference (wake every 1 second, infer, sleep)
  • Pi Zero idle (no inference): 120mA (0.6W)
  • Jetson Nano idle (MAXN power mode): 1.2A (6W)
  • Jetson Nano 5W power mode: 0.9A (4.5W), but CPU clocks drop to 921MHz

The Jetson’s power modes trade performance for efficiency, but even the 5W mode consumes 15x more than the ESP32 under active inference.

Memory Debugging Hell

The ESP32’s biggest pain point is RAM. The TFLite Micro arena allocator is a bump allocator — once you allocate memory for tensors, you can’t free it mid-inference. If your model’s intermediate activations exceed the arena size (configured at compile time), you get a stack overflow crash with no error message.

Debugging this requires instrumenting TFLite Micro’s MicroAllocator:

#include "tensorflow/lite/micro/micro_error_reporter.h"
#include "tensorflow/lite/micro/micro_interpreter.h"

constexpr int kTensorArenaSize = 200 * 1024;  // 200KB
uint8_t tensor_arena[kTensorArenaSize];

tflite::MicroErrorReporter micro_error_reporter;
tflite::ErrorReporter* error_reporter = &micro_error_reporter;

const tflite::Model* model = tflite::GetModel(model_tflite);
tflite::MicroInterpreter interpreter(model, resolver, tensor_arena, kTensorArenaSize, error_reporter);

TfLiteStatus allocate_status = interpreter.AllocateTensors();
if (allocate_status != kTfLiteOk) {
    TF_LITE_REPORT_ERROR(error_reporter, "AllocateTensors() failed");
    return;
}

size_t used_bytes = interpreter.arena_used_bytes();
TF_LITE_REPORT_ERROR(error_reporter, "Arena used: %d / %d bytes", used_bytes, kTensorArenaSize);

I had to bump kTensorArenaSize from 150KB to 200KB to fit my model. Trial and error is the only way — there’s no static analysis tool that predicts arena size.

Network Connectivity Trade-offs

The ESP32-S3 has WiFi 802.11 b/g/n and Bluetooth 5 LE. If you need to send inference results to a server, it’s trivial:

#include <WiFi.h>
#include <HTTPClient.h>

WiFi.begin("SSID", "password");
while (WiFi.status() != WL_CONNECTED) { delay(500); }

HTTPClient http;
http.begin("https://api.example.com/inference");
http.addHeader("Content-Type", "application/json");
String payload = "{\"class\":" + String(predicted_class) + "}";
int httpCode = http.POST(payload);
http.end();

But WiFi costs power — sending a POST request consumes 200mA for ~2 seconds, equivalent to 7 minutes of inference. For battery projects, use BLE to send data to a nearby hub (phone, gateway) instead.

The Pi Zero W and Jetson Nano both have Ethernet (via USB dongle for Pi, built-in for Jetson), making them better for always-connected deployments. If you’re logging 1MB/hour of inference telemetry, WiFi upload on the ESP32 will drain the battery faster than inference itself.

Cloud Integration Patterns

For production TinyML, you’ll eventually need a cloud backend for:
– Model updates (OTA firmware for ESP32, SSH scp for Pi/Jetson)
– Inference logging (for retraining or anomaly detection)
– Edge-cloud hybrid inference (lightweight local model + heavyweight cloud fallback)

The ESP32 supports OTA updates via the ESP-IDF framework:

#include <esp_ota_ops.h>
#include <esp_http_client.h>

esp_http_client_config_t config = { .url = "https://example.com/firmware.bin" };
esp_err_t ret = esp_https_ota(&config);
if (ret == ESP_OK) {
    esp_restart();  // Boot into new firmware
}

The Pi and Jetson use standard Linux update mechanisms (systemd timers + rsync, or custom Python scripts). If you’re deploying 100+ edge devices, consider using AWS IoT Greengrass or Balena for fleet management.

What I’d Pick Today

For my next TinyML project — a vibration-based motor fault detector for predictive maintenance — I’d pick the ESP32-S3. The device needs to run 24/7 on a 10,000mAh battery, sampling accelerometer data at 1kHz and running FFT + inference every 5 seconds. The Pi Zero would drain the battery in 3 days, and the Jetson is overkill (and can’t survive the -10°C to 50°C operating range).

But if I were building a robotic arm with real-time vision feedback, I’d pick the Jetson without hesitation. The ESP32 can’t handle 30 FPS object detection, and the Pi Zero’s 460ms latency would make the arm sluggish.

The board choice locks in your power, latency, and cost constraints. Choose wrong and you’ll rewrite your entire pipeline.

FAQ

Q: Can I run PyTorch models on ESP32?

No. The ESP32 only supports TensorFlow Lite for Microcontrollers. You must convert PyTorch → ONNX → TensorFlow → TFLite. Use onnx-tf and tf.lite.TFLiteConverter for the conversion pipeline. Expect shape inference errors — not all PyTorch ops have TFLite equivalents.

Q: How do I profile inference latency on ESP32?

Use esp_timer_get_time() for microsecond-precision timing:

int64_t start = esp_timer_get_time();
interpreter.Invoke();
int64_t end = esp_timer_get_time();
printf("Inference: %lld ms\n", (end - start) / 1000);

Don’t rely on millis() — it has 1ms resolution and misses short operations.

Q: Why is my Pi Zero TFLite inference slower than expected?

Check if you’re using the Python tensorflow package instead of tflite-runtime. The full TensorFlow package is 10x larger and slower. Install only tflite-runtime for embedded deployment. Also verify you’re using a 64-bit OS — the 32-bit Raspberry Pi OS is measurably slower for FP32 operations.

Where I’m Still Uncertain

I haven’t stress-tested any of these boards for >1 month continuous uptime. The ESP32 community reports WiFi connection drops after 7-10 days (fixed by periodic reconnects), and I’ve seen the Pi Zero’s SD card corrupt after power loss during a write. For production, you’d need watchdog timers, filesystem journaling, and remote monitoring — none of which I’ve validated at scale.

I’m also curious whether the ESP32-S3’s AI acceleration extensions (vector instructions, hardware matrix multiply) actually speed up TFLite inference. The documentation claims 2-3x improvements, but I couldn’t find a TFLite build that enables them. If you’ve measured this, I’d love to see benchmarks.

Did you find this helpful?

Your support keeps this blog running and ad-free content coming.

☕ Buy me a coffee
TODAY 557 | TOTAL 114,406