Parsing and generating JSON on a microcontroller is a notorious memory trap. Unlike desktop environments where you can throw megabytes of RAM at a parser, an ESP32 or Arduino Nano has strict heap limits. A poorly sized JSON buffer will silently fragment your RAM, leading to random reboots hours into deployment. The direct answer for 95% of embedded projects is to use the ArduinoJson library (specifically v7), allocate your JsonDocument with a calculated capacity, and strictly validate deserialization errors before accessing keys.

This guide provides a complete, bench-tested Arduino JSON example targeting the ESP32. We will build an environmental sensor node that generates a telemetry payload, parses an incoming configuration command, and handles the exact memory errors that crash most IoT prototypes.

Project Difficulty: Intermediate (3/5)
Estimated Build Time: 45 minutes
Target Board Variant: ESP32-WROOM-32 DevKit V1 (30-pin) programmed via Arduino IDE (Board: 'ESP32 Dev Module')

The Right Tool for the Job: Arduino JSON Library Comparison

Before writing code, you need to select the right parser. The Arduino ecosystem has several JSON libraries, but they vary wildly in RAM overhead and streaming capabilities. Below is a data-dense comparison of the most common libraries evaluated on an ESP32 compiling with ArduinoJson v7, Arduino_JSON, and aJson.

Library Base RAM Overhead Streaming Support Nested Object Handling Maintenance Status (2026)
ArduinoJson v7 ~16 bytes + payload Yes (Read/Write) Dynamic / Pooled Active (Industry Standard)
Arduino_JSON (Official) ~400 bytes base No Strict limits Low (Infrequent updates)
JSON Streaming Parser ~200 bytes state Read-only Callback-based Active (Niche use cases)
aJson High (malloc heavy) No Prone to heap frag Deprecated / Abandoned

The Verdict: ArduinoJson v7 is the undisputed choice. It uses a memory pool allocator that prevents the heap fragmentation that plagues older libraries like aJson. For deep technical guidance on capacity planning, refer to the official ArduinoJson capacity documentation.

Hardware Build: ESP32 Sensor Node with Local JSON Display

To demonstrate both generating and parsing JSON, we will build a node that reads a BME280 sensor, generates a JSON telemetry string, and then parses a mock incoming JSON command to update the local SSD1306 OLED display.

Parts List

  • MCU: ESP32-WROOM-32 DevKit V1 (30-pin variant) — ~$6.00
  • Sensor: BME280 I2C Temperature/Humidity/Pressure breakout (Adafruit 2652 or generic) — ~$4.50
  • Display: SSD1306 128x64 I2C OLED (0.96 inch) — ~$5.00
  • Passives: 2x 4.7kΩ pull-up resistors for I2C SDA/SCL lines (critical if using generic breakouts without onboard pull-ups)
  • Wiring: Half-size breadboard, 22 AWG solid core jumper wires

Pin Mapping Table

Both the BME280 and SSD1306 operate on 3.3V logic and share the I2C bus. Do not use 5V on the ESP32 I2C pins, or you will degrade the internal ESD protection diodes over time.

ESP32 GPIO Function BME280 Pin SSD1306 Pin
3V3 Power VIN / VCC VCC
GND Ground GND GND
GPIO 21 I2C SDA SDI / SDA SDA
GPIO 22 I2C SCL SCK / SCL SCL
Bench Tip: If your I2C devices fail to initialize and return 0xFF on an I2C scanner, check your pull-up resistors. Many cheap SSD1306 modules omit the 4.7kΩ pull-ups to save $0.02 in manufacturing. Solder them directly between 3V3 and the SDA/SCL lines on the breadboard.

Complete Arduino JSON Example: Parsing and Generating

The following code is fully compilable in the Arduino IDE. Ensure you have installed the ArduinoJson (v7+), Adafruit BME280, and Adafruit SSD1306 libraries via the Library Manager. The board variant targeted is the ESP32 Dev Module.

#include <Wire.h>
#include <Adafruit_Sensor.h>
#include <Adafruit_BME280.h>
#include <Adafruit_SSD1306.h>
#include <ArduinoJson.h>

// --- Pin Definitions ---
#define I2C_SDA 21
#define I2C_SCL 22
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define OLED_RESET -1
#define SCREEN_ADDRESS 0x3C

// --- Object Instantiation ---
Adafruit_BME280 bme;
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);

// Mock incoming JSON command (e.g., from MQTT or HTTP)
const char* incoming_command = "{\"device_id\":\"esp32_01\",\"config\":{\"report_interval_ms\":5000,\"enable_display\":true}}";

void setup() {
  Serial.begin(115200);
  delay(1000); // Allow serial monitor to connect

  // Initialize I2C with explicit pins
  Wire.begin(I2C_SDA, I2C_SCL);

  // Initialize Display
  if(!display.begin(SSD1306_SWITCHCAPVCC, SCREEN_ADDRESS)) {
    Serial.println(F("SSD1306 allocation failed"));
    for(;;); // Halt execution
  }
  display.clearDisplay();
  display.setTextSize(1);
  display.setTextColor(SSD1306_WHITE);

  // Initialize BME280
  if (!bme.begin(0x76, &Wire)) {
    Serial.println(F("Could not find a valid BME280 sensor, check wiring!"));
    for(;;);
  }
  
  Serial.println(F("System Initialized. Running JSON routines..."));
}

void loop() {
  // ==========================================
  // PART 1: GENERATING JSON (Telemetry Payload)
  // ==========================================
  JsonDocument telemetryDoc;
  
  telemetryDoc["sensor"] = "bme280";
  telemetryDoc["temp_c"] = bme.readTemperature();
  telemetryDoc["humidity"] = bme.readHumidity();
  telemetryDoc["pressure_hpa"] = bme.readPressure() / 100.0F;
  
  // Add a nested array for error codes
  JsonArray errors = telemetryDoc["errors"].to();
  errors.add(0); // 0 means no error

  Serial.println(F("--- Generated Telemetry ---"));
  serializeJsonPretty(telemetryDoc, Serial);
  Serial.println();

  // ==========================================
  // PART 2: PARSING JSON (Incoming Command)
  // ==========================================
  JsonDocument commandDoc;
  
  // Deserialize the mock incoming string
  DeserializationError error = deserializeJson(commandDoc, incoming_command);

  if (error) {
    Serial.print(F("deserializeJson() failed: "));
    Serial.println(error.f_str());
    display.clearDisplay();
    display.setCursor(0,0);
    display.print(F("JSON ERR: "));
    display.println(error.f_str());
    display.display();
  } else {
    // Safely extract values with fallback defaults
    const char* device_id = commandDoc["device_id"] | "unknown";
    int interval = commandDoc["config"]["report_interval_ms"] | 1000;
    bool display_on = commandDoc["config"]["enable_display"] | false;

    Serial.print(F("Parsed Config -> Interval: "));
    Serial.print(interval);
    Serial.print(F("ms, Display: "));
    Serial.println(display_on ? "ON" : "OFF");

    if (display_on) {
      display.clearDisplay();
      display.setCursor(0,0);
      display.print(F("ID: "));
      display.println(device_id);
      display.print(F("Int: "));
      display.print(interval);
      display.println(F("ms"));
      display.print(F("T: "));
      display.print(bme.readTemperature(), 1);
      display.println(F("C"));
      display.display();
    }
  }

  delay(5000); // Wait before next loop iteration
}

Debugging JSON Failures on Microcontrollers

When JSON parsing fails on an ESP32, the ArduinoJson library returns a specific DeserializationError. Do not use generic 'try-catch' blocks; instead, check the exact error string to diagnose the root cause. Below are the exact error strings and their ranked causes.

1. "DeserializationError: NoMemory"

  • Cause 1 (Most Likely): The JsonDocument capacity is too small for the incoming payload. In v7, while it grows dynamically, hitting the heap ceiling or a predefined pool limit triggers this.
  • Cause 2: Severe heap fragmentation. The ESP32 has enough total free RAM, but no single contiguous block large enough for the JSON tree allocation.

2. "DeserializationError: IncompleteInput"

  • Cause 1: Reading from a Serial or Network buffer before the full payload has arrived. You passed a partial string to the parser.
  • Cause 2: The incoming character array is not null-terminated (\0), causing the parser to read into garbage memory until it hits a buffer limit.

3. "DeserializationError: InvalidInput"

  • Cause 1: Malformed JSON. Missing quotes around keys, trailing commas at the end of arrays, or single quotes instead of double quotes (JSON strictly requires double quotes).
The First 3 Things to Check When Parsing Fails:
  1. Verify Null-Termination: If reading from a char buffer, ensure buffer[len] = '\0'; is explicitly set before calling deserializeJson().
  2. Check Baud Rate & Buffer Overruns: If reading from Serial at 9600 baud while the sender transmits at 115200, you will drop bytes, resulting in IncompleteInput. Always match baud rates and use Serial.available() checks.
  3. Validate the Raw String: Print the raw incoming string to the Serial Monitor before passing it to the parser. Copy that exact output and paste it into a desktop JSON validator like JSONLint to catch hidden formatting errors.

Extending and Simplifying Your JSON Payload

As your IoT project scales, your JSON payloads will grow. A 2KB JSON string is trivial for a Raspberry Pi, but on an ESP32 handling TLS encryption and WiFi stacks simultaneously, it can trigger ESP-IDF memory allocation failures. Here is how to manage the growth.

How to Simplify the Build

If you are hitting memory limits, flatten your JSON structure. Nested objects require the parser to maintain a deeper tree state in RAM. Instead of this:

{"sensor": {"temp": {"value": 22.5, "unit": "C"}}}

Use a flat, delimited key structure:

{"sensor_temp_c": 22.5}

This reduces the parsing overhead and eliminates the need to traverse nested JsonObject pointers in your C++ code, reducing both RAM usage and CPU cycles.

How to Extend the Build

If you need to send large arrays of historical data (e.g., 500 temperature readings) and JSON is becoming too bloated, switch the transport serialization to MessagePack. ArduinoJson supports MessagePack natively. It is a binary format that maps 1:1 with JSON concepts but strips out all the syntax characters (braces, quotes, colons).

To extend the code above for binary transmission, simply replace serializeJson(doc, Serial) with serializeMsgPack(doc, Serial). On the receiving end (like a Python backend or Node-RED), use a MessagePack decoder. This typically shrinks payload sizes by 30-50%, which is critical when operating over constrained networks like LoRaWAN or MQTT-SN.