Skip to content
Tutorial
16 min read
October 17, 2026

MicroPython ESP32 Web Server: Control an LED or Relay from Your Browser

Create a working web server with the socket module, parse requests, serve a responsive control page for an LED or a relay, keep the displayed state truthful, choose a safe relay startup state and make the server survive bad or repeated requests.

MicroPython ESP32 Web Server: Control an LED or Relay from Your Browser

An ESP32 can host a small web page that lets a phone or computer on the same Wi-Fi network switch a low-voltage output on and off. This project introduces station-mode Wi-Fi, HTTP routes, browser requests, and safe GPIO control using MicroPython. It is a useful foundation for a status indicator or a carefully designed low-voltage automation prototype.

This guide focuses on an LED first. Relay control is discussed with safety limits, but a browser button is not a substitute for electrical isolation, proper enclosure, or a safety-rated control system. The example server has no login or TLS and should remain on a trusted local network. Never expose it by router port forwarding.

1. Parts and preparation

You need an ESP32 development board, a USB data cable, a breadboard, an LED and series resistor, jumper wires, and a Wi-Fi access point. Use Thonny or another MicroPython REPL tool. The computer and ESP32 should be on the same local network for initial testing.

Use a GPIO that your exact board documents as suitable for output. GPIO 4 appears in the examples only as a placeholder; replace it if necessary. ESP32 boards differ in exposed pins, onboard circuitry, and boot-strapping functions. GPIO pins use 3.3 V logic and are not 5 V tolerant. Do not drive a motor, relay coil, lamp, or other high-current load directly from a GPIO.

Before networking, test the LED with a simple script. Connect the chosen GPIO through a current-limiting resistor to the LED anode and connect the cathode to GND. Verify LED polarity and resistor placement with power disconnected. If your LED module already includes a resistor, check its documentation.

2. Test the output before adding Wi-Fi

from machine import Pin
from time import sleep
led = Pin(4, Pin.OUT)  # verify GPIO 4 for your board
while True:
    led.value(1)
    sleep(1)
    led.value(0)
    sleep(1)

Run this from the REPL and confirm that the LED changes state. If it does not, check the board pinout, LED polarity, resistor, and common ground. Some onboard LEDs are active-low or connected through circuitry, so an external LED is often a clearer first test.

Stop the loop before continuing. Confirm that led.value(0) leaves the output in the intended safe state. If the board resets when the circuit is attached, disconnect it and test the board alone, then inspect for shorts or an unsuitable pin. A reliable network control project starts with a known-good output circuit.

3. Connect to Wi-Fi with a timeout

Replace the placeholder credentials with your local network details. Do not commit real credentials to a public repository or include them in screenshots.

import network, time
SSID = "YOUR_WIFI_NAME"
PASSWORD = "YOUR_WIFI_PASSWORD"
wlan = network.WLAN(network.STA_IF)
wlan.active(True)
if not wlan.isconnected():
    wlan.connect(SSID, PASSWORD)
    deadline = time.ticks_add(time.ticks_ms(), 15000)
    while not wlan.isconnected() and time.ticks_diff(deadline, time.ticks_ms()) > 0:
        time.sleep_ms(250)
if not wlan.isconnected():
    raise RuntimeError("Wi-Fi connection timed out")
print("ESP32 address:", wlan.ifconfig()[0])

The bounded wait prevents a wrong password or unavailable access point from trapping the program in an endless loop. For a deployed device, add a recovery policy such as delayed retries and a visible offline state. Many ESP32 boards use 2.4 GHz Wi-Fi; confirm the capabilities of your exact chip and router.

4. Plan the HTTP routes

The server will provide three routes: / serves the page, /on requests the output be switched on, and /off requests it be switched off. A browser can make these requests with JavaScript fetch() so the page does not reload. The server must validate the requested path and return a clear status for unknown routes.

This simple server uses a blocking socket and serves one client at a time. That keeps the code approachable but is not a hardened web framework. Use request timeouts and close each client connection, otherwise a client that opens a connection and sends nothing may stall the application. Avoid accepting arbitrary commands or values from the browser; use a small allowlist of routes.

For the first test, only control an LED. Once the page and network are working, the same route structure can be adapted to a driver input, but only after the hardware interface and its safe state have been designed.

5. Add the page and socket server

The following script is a compact learning example. Save it as a development file first. It joins Wi-Fi, initializes the LED to OFF, and provides a small responsive page with ON and OFF buttons.

import network, socket, time
from machine import Pin
SSID = "YOUR_WIFI_NAME"
PASSWORD = "YOUR_WIFI_PASSWORD"
led = Pin(4, Pin.OUT)  # verify GPIO and external LED circuit
led.value(0)
wlan = network.WLAN(network.STA_IF)
wlan.active(True)
wlan.connect(SSID, PASSWORD)
deadline = time.ticks_add(time.ticks_ms(), 15000)
while not wlan.isconnected() and time.ticks_diff(deadline, time.ticks_ms()) > 0:
    time.sleep_ms(250)
if not wlan.isconnected():
    raise RuntimeError("Wi-Fi unavailable")

PAGE = """<!doctype html><html><head><meta name='viewport' content='width=device-width,initial-scale=1'><title>ESP32 Control</title></head><body><h1>ESP32 Output</h1><button onclick=\"setOutput('on')\">ON</button><button onclick=\"setOutput('off')\">OFF</button><p id='status'>Ready</p><script>
async function setOutput(state){
 const status=document.getElementById('status');
 try { const r=await fetch('/'+state,{cache:'no-store'}); if(!r.ok) throw new Error('HTTP '+r.status); status.textContent=await r.text(); }
 catch(e){ status.textContent='Request failed: '+e.message; }
}
</script></body></html>"""

addr = socket.getaddrinfo('0.0.0.0', 80)[0][-1]
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(addr)
s.listen(2)
s.settimeout(1)
print('Open http://%s/' % wlan.ifconfig()[0])

This block sets up the page and listening socket. Append the request loop in the next section so each route changes only the intended output and returns a response.

6. Handle browser requests

Append this loop after the previous code. It reads the first request line, extracts the path, accepts only the three expected routes, and closes the connection in all cases.

while True:
    try:
        client, remote = s.accept()
    except OSError:
        continue
    try:
        client.settimeout(2)
        request = client.recv(1024).decode()
        first = request.split("\r\n", 1)[0]
        parts = first.split(" ")
        path = parts[1] if len(parts) > 1 else "/"
        if path == "/":
            body = PAGE.encode()
            content_type = "text/html; charset=utf-8"
            status = "200 OK"
        elif path == "/on":
            led.value(1)
            body = b"Output ON"
            content_type = "text/plain; charset=utf-8"
            status = "200 OK"
        elif path == "/off":
            led.value(0)
            body = b"Output OFF"
            content_type = "text/plain; charset=utf-8"
            status = "200 OK"
        else:
            body = b"Not found"
            content_type = "text/plain; charset=utf-8"
            status = "404 Not Found"
        header = "HTTP/1.1 %s\\r\\nContent-Type: %s\\r\\nContent-Length: %d\\r\\nConnection: close\\r\\nCache-Control: no-store\\r\\n\\r\\n" % (status, content_type, len(body))
        client.send(header.encode())
        client.send(body)
    except Exception as exc:
        print("Request error:", exc)
    finally:
        client.close()

The Content-Length and connection-close header help the browser identify the end of the response. Keep the parser limited to this learning use case; arbitrary HTTP traffic requires more robust parsing and resource limits.

7. Test from a phone or computer

Run the script and note the IP address printed in the REPL. On a device connected to the same local network, open http://ESP32_IP/, replacing the placeholder with the address printed by your board. Press ON and OFF and confirm the LED follows the requested state. The browser status line should report the response or a visible error.

If the page does not load, confirm that the ESP32 actually connected to Wi-Fi and that you are using the current IP address. DHCP may change it after a restart. Guest Wi-Fi and client-isolation settings may prevent devices from communicating with each other even when both have internet access. Try a trusted home network and check router settings before changing the code.

If the page loads but the buttons fail, visit /on and /off directly in the browser. If direct routes work, inspect the browser's developer console or simplify the JavaScript. If neither route works, check the server loop and the REPL for exceptions.

8. Control a relay safely

A relay module can switch a separate circuit, but the ESP32 GPIO must drive only the module's logic input, never the relay coil or load directly. Choose a module designed for 3.3 V logic or use a properly designed interface. Confirm input polarity: some modules are active-low, so writing HIGH may turn the relay off and LOW may energize it. Verify this with the module documentation and a low-voltage test load before any real application.

Do not use a breadboard relay circuit to experiment with exposed mains voltage. Mains wiring can cause fatal electric shock and fire. Any mains-powered design requires appropriate isolation, fusing, enclosure, creepage and clearance, strain relief, grounding where required, and compliance with local electrical rules. Have qualified personnel design and inspect mains work. This article's code is not a certification or safety interlock.

For a relay-controlled project, define the safe state explicitly. The example initializes the LED output to OFF, but relay hardware may behave differently during reset or boot. Test power-up, Wi-Fi loss, script exceptions, and manual recovery with the load disconnected. Never depend on the web page alone to provide an emergency stop.

Reliability, state, and user feedback

The browser receives a response after the output command is processed, but a successful HTTP response only confirms that the program accepted the request; it does not prove an external device physically changed state. If physical feedback matters, add a suitable isolated input or sensor and display measured state separately from the requested state.

This blocking socket server serves one client at a time. Short timeouts and a small listen backlog help a simple demo, but do not make it suitable for heavy traffic. Keep responses short, close clients reliably, and avoid lengthy sensor reads inside a request handler. If the project grows, consider a framework or asynchronous approach supported by your MicroPython build, and test memory use and reconnect behaviour.

For a useful interface, display connection or request errors instead of leaving a button apparently stuck. Avoid repeated rapid clicks that flood the board. You can disable buttons while a request is pending and restore them in a JavaScript finally block. Never present cached status as confirmed physical feedback unless the application has actually measured it.

Security limits and troubleshooting

The example server has no authentication, encryption, or access control. Anyone who can reach the device on the local network may be able to switch the output. Keep it on a trusted LAN, do not port-forward it, and never use it to control safety-critical equipment. For remote access, use a properly authenticated gateway or service rather than exposing this raw socket server to the internet.

Connection timeout: check SSID, password, signal strength, and network band. Browser cannot connect: verify the printed IP, same-LAN access, and router client isolation. Page appears stale: refresh and check server output. Output never changes: test the LED circuit separately, verify the GPIO, and confirm the script is still running. Relay behaves backwards: check whether its input is active-low and verify logic-level compatibility. Unexpected reset: disconnect the load, check for shorts and supply dips, and test with the LED only.

Change one variable at a time. First prove the output, then Wi-Fi, then a static page, and finally the control routes. This sequence narrows the cause of a failure and makes the project easier to maintain.

Final checklist and production considerations

Before sharing the project, verify the GPIO pinout for the exact ESP32 board, test the LED output independently, and keep signals within the board's 3.3 V electrical limits. Use placeholder credentials in shareable code, add a bounded Wi-Fi timeout, allow only known HTTP paths, and test from a second device on the same trusted LAN. Keep the unauthenticated server off the public internet.

For production use, consult the official MicroPython documentation for your firmware, the ESP32 board schematic, and any relay-module datasheet. Network APIs and pin availability can vary across chip families and releases. If remote operation or sensitive loads are involved, add appropriate authentication, access control, fault handling, and independent hardware safety measures; a basic demonstration server is not sufficient.

SolderHub takeaway: browser control combines GPIO and networking, but reliability begins with a proven circuit and a bounded server. Start with an LED, make failures visible, restrict available routes, and design relay electrical safety independently from the web interface.

Improve and document the project

After the basic demonstration works, make one improvement at a time. Keep Wi-Fi credentials in a local configuration file that is not shared publicly, and use placeholders in tutorial code. A response helper can make headers consistent across routes. If you add a status route, return the software output state and label it clearly; do not imply that a successful request proves an external device physically changed state.

The parser in this example reads only the first request line and is intended for a controlled local demonstration. It does not implement every HTTP feature, validate all malformed requests, or provide authentication. Keep the route allowlist fixed. Do not accept arbitrary GPIO numbers or user-provided values as hardware commands.

Test recovery deliberately: restart the access point, enter an incorrect password, refresh the page while the ESP32 reboots, and stop the script from the REPL. The device should fail visibly rather than imply a command succeeded. Record the board model, firmware version, GPIO used, and tested wiring alongside the code so the project can be reproduced.

Signal In

Built from what makers search for

The lookups piling up in SolderHub's search bar every day shape what we publish next — new component pages, board guides, and firmware notes, sent out as they go live.

By subscribing you agree to our Privacy Policy. Unsubscribe anytime.