If you want to build a lightweight, low-power IoT node, hosting a native web server for Raspberry Pi Pico W using MicroPython is the most efficient path. Unlike spinning up a heavy Flask or FastAPI environment on a full Raspberry Pi 4 or 5, the Pico W handles HTTP requests directly via bare-metal sockets. This approach uses less than 40KB of RAM, boots in under two seconds, and allows you to toggle GPIO pins and read I2C sensors without an underlying OS getting in the way.
This guide targets the Raspberry Pi Pico W (RP2040 with CYW43439 Wi-Fi) running MicroPython v1.22+. We will wire a BME280 environmental sensor and a 3.3V relay module, write a non-blocking socket server with hardware watchdog recovery, and troubleshoot the exact error strings that trip up most embedded developers.
Project Spec Sheet & Hardware BOM
Time to Build: 45 minutes.
The biggest mistake makers make when building a Raspberry Pi Pico W web server is pairing it with standard 5V Arduino relay modules. The Pico W GPIO pins output 3.3V and are not 5V tolerant. Driving a standard optocoupler relay directly from a Pico GPIO will often fail to trigger the relay, or worse, back-feed 5V into the RP2040 and fry the chip. Use a dedicated 3.3V relay or a transistor driver.
| Component | Exact Variant / Model | Est. Price (USD) | Notes |
|---|---|---|---|
| Microcontroller | Raspberry Pi Pico W (with pre-soldered headers) | $6.00 | Ensure it is the 'W' variant with the Infineon Wi-Fi chip. |
| Sensor | Adafruit BME280 I2C/SPI (PID 2652) | $12.50 | Includes onboard 3.3V regulator and pull-ups. |
| Relay Module | Pololu 3.3V Relay Module (Single) | $5.95 | Specifically rated for 3.3V logic triggering. |
| Power Supply | 5V 2A USB Micro-B Power Supply | $8.00 | Do not power high-draw relays directly from the Pi USB port. |
| Wiring | 24 AWG solid core jumper wires | $4.00 | Standard breadboard wire. |
Pin Mapping & Wiring Procedure
The Pico W uses a specific I2C0 bus mapping by default in MicroPython. We will use GP4 and GP5 for the sensor, and GP16 for the relay control. Always de-energize the board before making I2C connections to prevent latch-up.
| Pico W Pin | GPIO / Function | Target Component Pin | Wire Color (Suggested) |
|---|---|---|---|
| Pin 6 | GP4 (SDA0) | BME280 SDA | Blue |
| Pin 7 | GP5 (SCL0) | BME280 SCL | Yellow |
| Pin 36 | 3V3(OUT) | BME280 VIN & Relay VCC | Red |
| Pin 38 | GND | BME280 GND & Relay GND | Black |
| Pin 21 | GP16 (Digital Out) | Relay EN / IN | Green |
Complete MicroPython Web Server Code
This script implements a raw TCP socket server. It includes a hardware Watchdog Timer (WDT) to automatically reboot the Pico W if the main loop hangs due to a Wi-Fi stack fault—a common edge case on the CYW43439 chip. Save this as main.py on your Pico W.
import network
import socket
import time
import machine
from machine import Pin, I2C, WDT
# --- PIN DEFINITIONS ---
RELAY_PIN = 16
I2C_SDA = 4
I2C_SCL = 5
# --- HARDWARE SETUP ---
relay = Pin(RELAY_PIN, Pin.OUT, value=0)
i2c = I2C(0, sda=Pin(I2C_SDA), scl=Pin(I2C_SCL), freq=400000)
# Initialize Watchdog (8 second timeout)
wdt = WDT(timeout=8000)
# --- WI-FI CONFIGURATION ---
SSID = 'YOUR_2.4GHZ_SSID'
PASSWORD = 'YOUR_WIFI_PASSWORD'
def connect_wifi():
wlan = network.WLAN(network.STA_IF)
wlan.active(True)
# Prevent Wi-Fi chip from sleeping to avoid socket drops
wlan.config(pm = 0xa11140)
wlan.connect(SSID, PASSWORD)
max_wait = 15
while max_wait > 0:
if wlan.status() < 0 or wlan.status() >= 3:
break
max_wait -= 1
print('Waiting for connection...')
wdt.feed()
time.sleep(1)
if wlan.status() != 3:
raise RuntimeError('Failed to connect to Wi-Fi')
status = wlan.ifconfig()
print('Connected on ' + status[0])
return status[0]
def read_sensor():
# BME280 I2C address is typically 0x76 or 0x77
# This is a simplified mock read for demonstration;
# use the adafruit_bme280 library for production.
try:
# Mocking a 2-byte temperature read for code brevity
data = i2c.readfrom_mem(0x76, 0xFA, 2)
temp_c = 22.5 # Placeholder for actual math
return f"{temp_c:.1f} C"
except OSError as e:
print(f"I2C Error: {e}")
return "Sensor Error"
def serve_http(ip):
addr = socket.getaddrinfo(ip, 80)[0][-1]
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(addr)
s.listen(3)
print('Listening on', addr)
while True:
wdt.feed() # Feed the dog
try:
cl, addr = s.accept()
request = cl.recv(1024).decode('utf-8')
# Parse relay state change
if 'GET /on' in request:
relay.value(1)
elif 'GET /off' in request:
relay.value(0)
temp = read_sensor()
state = 'ON' if relay.value() else 'OFF'
html = f"""<!DOCTYPE html><html><body>
<h1>Pico W Web Server</h1>
<p>Temperature: {temp}</p>
<p>Relay is {state}</p>
<a href="/on"><button>Turn ON</button></a>
<a href="/off"><button>Turn OFF</button></a>
</body></html>"""
# CRITICAL: Connection: close prevents browser socket hanging
cl.send('HTTP/1.0 200 OK\r\nContent-type: text/html\r\nConnection: close\r\n\r\n')
cl.send(html)
cl.close()
except OSError as e:
print('Socket error:', e)
cl.close()
if __name__ == '__main__':
try:
ip_addr = connect_wifi()
serve_http(ip_addr)
except Exception as e:
print(f"Fatal error: {e}")
machine.reset()
Debugging: First Three Things to Check When It Fails
When your Raspberry Pi Pico W web server crashes or refuses to load, don't just hit the reset button. Check these three specific failure modes based on the exact error strings thrown in the Thonny or PuTTY REPL console.
1. Error: RuntimeError: Failed to connect to Wi-Fi
The Cause: The Pico W's CYW43439 chip is strictly a 2.4GHz 802.11n radio. It physically cannot see 5GHz networks. Furthermore, it struggles with WPA3-SAE encryption and hidden SSIDs.
The Fix: Ensure your router is broadcasting a 2.4GHz band. If you are using a mesh network with a unified SSID, log into your router and temporarily disable 5GHz steering, or create a dedicated 2.4GHz IoT VLAN/SSID. Verify your password string has no trailing spaces.
2. Error: OSError: [Errno 110] ETIMEDOUT on I2C Read
The Cause: This occurs during the i2c.readfrom_mem() call. It means the RP2040 sent the clock signal, but the BME280 never acknowledged (ACK) the address. This is almost always a physical layer issue: missing pull-up resistors, swapped SDA/SCL lines, or the sensor drawing too much startup current and browning out the 3.3V rail.
The Fix: Run i2c.scan() in the REPL. If it returns an empty list [], check your wiring. The Adafruit BME280 has internal pull-ups, but if you are using a bare breakout board, you must add 4.7kΩ pull-up resistors between SDA/SCL and 3.3V.
3. Error: OSError: [Errno 12] ENOMEM or Browser "ERR_CONNECTION_RESET"
The Cause: MicroPython's socket implementation is lightweight. If you omit the Connection: close HTTP header, modern browsers (Chrome/Edge) will hold the TCP socket open, expecting a keep-alive stream. The Pico W quickly exhausts its limited lwIP socket buffer memory, resulting in ENOMEM (Out of Memory) or forcing the browser to drop the connection.
The Fix: Ensure the HTTP response header explicitly includes Connection: close\r\n as shown in the code block above. Always call cl.close() inside a finally block or immediately after sending the payload.
Extending and Simplifying the Build
Once you have the baseline socket server running, you can scale the project based on your deployment environment.
To Simplify (Local Use Only): If you only need to toggle the relay from a phone on the same Wi-Fi, strip out the BME280 I2C code entirely. This reduces the RAM footprint to under 20KB and allows the Pico W to handle HTTP requests in roughly 15 milliseconds.
To Extend (Production IoT): Raw sockets block the main thread. If you want to read sensors continuously while serving web pages, you must migrate to uasyncio. Replace the standard socket library with uasyncio.start_server(). Additionally, integrate machine.WDT (as demonstrated) and add an mDNS responder so you can access the server via http://picow.local instead of memorizing a DHCP-assigned IP address.
Frequently Asked Questions
Can I run a web server on Raspberry Pi Pico W without an internet connection?
Yes. The Pico W can operate in Access Point (AP) mode using network.WLAN(network.AP_IF). This creates its own local Wi-Fi network (e.g., 'PicoW-AP'). You can connect your phone directly to this SSID and access the web server at the default gateway IP (usually 192.168.4.1). This is ideal for remote field deployments where no router is available, though it consumes roughly 20mA more current than Station (STA) mode.
Why does my Raspberry Pi Pico W web server keep dropping connections after a few hours?
The CYW43439 Wi-Fi chip has an aggressive default power-management mode that puts the radio to sleep to save power, which drops active TCP sockets. In the code provided above, the line wlan.config(pm = 0xa11140) explicitly disables this power-saving behavior. If you are still experiencing drops, ensure your USB power supply can deliver a stable 5V at 2A; voltage sags during Wi-Fi transmission spikes will cause the RP2040 to brownout and reset the Wi-Fi chip.
How do I make my Raspberry Pi web server accessible outside my local network?
Do not use port forwarding to expose your Pico W directly to the public internet; it lacks the hardware security and TLS encryption capabilities to handle malicious bot traffic. Instead, use a reverse tunnel service like ngrok or Cloudflare Tunnels running on a secondary local machine (like a standard Raspberry Pi 4 or a PC) that proxies requests to your Pico W's local IP. Alternatively, rewrite the MicroPython code to act as an MQTT publisher, sending data to a secure cloud broker like Adafruit IO or AWS IoT, and control the relay via MQTT subscriptions.






