Documented optimizations applied to minimize time-to-usable-state on a headless Pi Zero 2 W. Target services: USB gadget, WiFi, SSH, cursed-controls web UI.
| Milestone | Before | After |
|---|---|---|
| Total boot | 23.1s | ~10.2s |
| Userspace (multi-user.target) | 18.2s | ~6.5s |
| USB gadget ready | — | ~5.5s from power-on |
| SSH available | ~18s | ~6.5s from power-on |
Hardware: Pi Zero 2 W, SanDisk Ultra SD card, powered via USB2 (500mA).
0.0s Power on
3.5s Kernel hands off to systemd
4.6s SD card partitions detected (MMC hardware init)
5.1s Filesystems mounted, tmpfiles set up
5.1s basic.target reached
5.5s cursed-controls up — USB gadget active
6.3s WiFi chipset firmware loaded, wpa_supplicant fires
6.5s SSH ready
7-8s DHCP lease obtained, web UI reachable
What it does: Blocks network-online.target until the network has a full IP and routing.
Most services only need network.target (stack initialized), not full connectivity.
This service is the single most common boot blocker on Pi setups.
sudo systemctl mask systemd-networkd-wait-online.serviceRisk: Services that explicitly After=network-online.target (e.g. apt timers) will
start before the network is fully up. For headless appliances this is almost always fine.
What it does: cloud-init runs 4 staged services at every boot to apply cloud provisioning
config. On Raspberry Pi OS it seeds from /boot/firmware/user-data, which ships as an
all-comments template doing nothing. It was adding ~6s to the critical chain and injecting
After=cloud-init-network.service into SSH, blocking it.
# Disable via the official mechanism
sudo touch /etc/cloud/cloud-init.disabled
# Mask all stages so they can't be started even if wanted
sudo systemctl mask \
cloud-init-main.service \
cloud-init-local.service \
cloud-init-network.service \
cloud-config.service \
cloud-final.service \
cloud-init-hotplugd.socket \
cloud-init.target \
cloud-config.targetRisk: If you actually use cloud-init for first-boot setup (SSH keys, user creation,
hostname, etc.) this will break that. On a Pi used as a fixed appliance it's safe.
Revert with sudo rm /etc/cloud/cloud-init.disabled and unmask the services.
What it does: NetworkManager is a heavy-weight network manager taking ~5s to declare
itself up. systemd-networkd + wpa_supplicant@<iface> is a lightweight alternative
that reaches network.target in under 1s on a single-interface WiFi setup.
Step 1 — Write wpa_supplicant config (replace with your network details):
sudo tee /etc/wpa_supplicant/wpa_supplicant-wlan0.conf > /dev/null << 'EOF'
ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev
update_config=0
country=NL
network={
ssid="YourNetworkName"
psk="YourPassword"
key_mgmt=WPA-PSK
priority=10
}
network={
ssid="FallbackNetwork"
psk="FallbackPassword"
key_mgmt=WPA-PSK
priority=5
}
EOF
sudo chmod 600 /etc/wpa_supplicant/wpa_supplicant-wlan0.confImportant: Always quote
ssidandpskvalues. Without quotes, special characters like#are treated as comments and silently truncate your password.
update_config=0prevents wpa_supplicant from writing back to the config file at runtime (e.g. caching BSSID info). Set to1only if you usewpa_clito add networks on the fly. With0, the SD card is never written during normal WiFi operation.
Step 2 — Write systemd-networkd config:
sudo mkdir -p /etc/systemd/network
sudo tee /etc/systemd/network/10-wlan0.network > /dev/null << 'EOF'
[Match]
Name=wlan0
[Network]
DHCP=yes
IPv6AcceptRA=yes
[DHCPv4]
RouteMetric=600
UseDNS=yes
UseDomains=yes
[DHCPv6]
RouteMetric=600
EOFStep 3 — Make resolv.conf static (networkd without systemd-resolved doesn't write it):
sudo tee /etc/resolv.conf > /dev/null << 'EOF'
nameserver <your-router-ip>
nameserver 8.8.8.8
nameserver 8.8.4.4
search lan
EOFStep 4 — Enable new stack, disable old:
# Enable
sudo systemctl enable systemd-networkd wpa_supplicant@wlan0
# Disable NetworkManager and ModemManager
sudo systemctl disable NetworkManager NetworkManager-dispatcher wpa_supplicant
sudo systemctl mask ModemManagerStep 5 — Reboot (do not stop NM before rebooting — your current SSH session will drop).
Risk: If the wpa_supplicant config has any error (wrong password, bad syntax, missing
quotes around special characters) the Pi will have no WiFi and you'll need SD card access
to fix it. Test your config carefully before rebooting. The standalone wpa_supplicant.service
must be disabled to avoid conflicting with wpa_supplicant@wlan0.service.
Services that add boot time and are not needed on a headless appliance Pi:
# AppArmor — ships with no active profiles on Pi OS, loads and does nothing
sudo systemctl disable apparmor
# Console keyboard and font setup — not needed headless
sudo systemctl disable keyboard-setup console-setup
# ext4 online metadata check — runs at every boot, only useful for active scrubbing
sudo systemctl mask e2scrub_reap
# ModemManager — probes serial ports for LTE modems; Pi Zero 2 W has none.
# Also interferes with Bluetooth UART initialization.
sudo systemctl mask ModemManagerRisk: Low. AppArmor, keyboard-setup and console-setup are genuinely unused on headless. e2scrub_reap can be unmasked if you want periodic ext4 scrubbing (the timer still works, just not the every-boot activation).
What it does: dtoverlay=vc4-kms-v3d in config.txt loads the full Mesa/KMS GPU
stack (5+ kernel modules, ~313ms). On a headless Pi with no display this is wasted time
and memory.
In /boot/firmware/config.txt, remove or do not include:
dtoverlay=vc4-kms-v3d
max_framebuffers=2
disable_fw_kms_setup=1
Risk: If you ever attach an HDMI display and want a desktop, you'll need to add these back. For headless use there is no downside.
What it does: Two separate fsck skips:
a) Kernel parameter — skips fsck on the root ext4 partition:
In /boot/firmware/cmdline.txt, replace fsck.repair=yes with fsck.mode=skip. While
editing this file, also add quiet to suppress verbose kernel log output during boot
(minor improvement, cleaner serial output):
... fsck.mode=skip quiet rootwait ...
b) fstab passno — stops systemd from generating a fsck unit for the /boot/firmware
FAT32 partition:
In /etc/fstab, set the 6th field (fsck pass) for the boot partition to 0:
PARTUUID=xxxx-01 /boot/firmware vfat defaults 0 0
^
was 2, now 0
Risk: If the Pi loses power mid-write to either partition, corruption will go undetected until it causes visible errors. For a stable deployment where you control firmware updates this is acceptable. If you frequently pull power without clean shutdown, leave fsck enabled.
What it does: If your service loads kernel modules in ExecStartPre, those loads happen
serially, blocking the app. Moving them to modules-load.d makes systemd load them in
parallel during sysinit.target, well before your service starts.
For cursed-controls specifically, init-raspbian.sh was loading uinput, hid-wiimote,
udc_core, and raw_gadget at service start time. dwc2 and libcomposite were
also being modprobed there despite already being loaded via modules-load=dwc2,libcomposite
in cmdline.txt (redundant).
Step 1 — Install raw_gadget as a proper system module (it's out-of-tree, so not in the kernel tree by default):
sudo mkdir -p /lib/modules/$(uname -r)/extra
sudo cp ~/raw-gadget/raw_gadget/raw_gadget.ko /lib/modules/$(uname -r)/extra/
sudo depmod -a
# Verify it's found:
sudo modinfo raw_gadget | grep -E 'filename|vermagic'The vermagic field must match uname -r exactly — if you update the kernel, rebuild
the module and repeat this step.
Step 2 — Create a modules-load.d config:
sudo tee /etc/modules-load.d/cursed-controls.conf > /dev/null << 'EOF'
uinput
hid-wiimote
udc_core
raw_gadget
EOFThese now load during systemd-modules-load.service at sysinit.target, in parallel
with other early boot work, instead of blocking your service start.
Step 3 — Replace ExecStartPre in the service unit with inlined commands (the module loads are gone; only the two fast sysfs operations remain):
ExecStartPre=-/usr/sbin/rfkill unblock bluetooth
ExecStartPre=/bin/sh -c 'echo 1 > /sys/module/bluetooth/parameters/disable_ertm'The init-raspbian.sh script has an lsmod check and will safely skip the raw_gadget
insmod if the module is already loaded — so if you apply only steps 1–2 without step 3,
the script becomes a fast no-op for module loading and still works correctly.
Risk: Module load ordering. systemd-modules-load.service completes during
sysinit.target, before basic.target, which is before most user services — so there is
no race in practice. The main risk is kernel updates invalidating the installed .ko; pin
your kernel or add the reinstall step to your update process.
What it does: If your service does work that doesn't need a network connection (USB
gadget, hardware I/O, local processing), don't block it on network.target. Change
After=network.target to After=basic.target in the unit file. The service starts as
soon as the system is initialized, not after the WiFi stack is ready.
For cursed-controls-web.service specifically, the original unit had:
[Unit]
After=network.target bluetooth.target
Wants=bluetooth.targetChanged to:
[Unit]
After=basic.targetWants=bluetooth.target was also removed — Bluetooth still starts (it's enabled
separately), it just no longer blocks the service. The app handles a delayed BT connection
gracefully. This moved cursed-controls startup from ~6.7s (after WiFi stack) to ~5.3s
(after basic system init), putting it before the WiFi chipset has even loaded its firmware.
For a web UI that binds to 0.0.0.0, binding works without a DHCP lease — the port is
open immediately, it just isn't reachable until the lease arrives in the background.
Risk: If your app genuinely needs a network address at startup (outbound connections,
hostname resolution), this will cause startup failures. Use Restart=on-failure +
RestartSec=5 as a safety net so it retries when network is available.
What it does: By default the Pi uses the ondemand CPU governor, scaling between
600MHz and 1000MHz. During early boot (before load is detected), the CPU may run at
600MHz, slowing all initialization. force_turbo=1 in config.txt locks the CPU at
maximum frequency from the first firmware instruction.
Note: the standard Linux kernel parameter cpufreq.default_governor=performance in
cmdline.txt does not work on Pi — the raspberrypi-cpufreq driver ignores it.
force_turbo=1 in config.txt is the only reliable method and operates at the firmware
level before the kernel boots.
In /boot/firmware/config.txt:
arm_boost=1
force_turbo=1
Risk: On USB2 power (500mA / 2.5W), the Pi Zero 2 W at full load draws close to the
limit. In practice it's fine (normal operating draw is well under 2.5W), but if you see
under-voltage warnings (vcgencmd get_throttled returning non-zero), revert this.
force_turbo=1 also disables thermal throttling — on a Zero 2 W without a heatsink in a
warm enclosure this could theoretically cause issues, though in practice the chip is
efficient enough that it's rarely a concern.
What it does: The SDHOST MMC controller defaults to 50MHz. On quality SD cards it can be pushed higher, reducing kernel load time and SD card detection latency.
In /boot/firmware/config.txt (in the [all] section):
dtparam=sd_overclock=100
The Pi Zero 2 W's controller runs from a 400MHz base clock with integer dividers, giving achievable steps of 50MHz → 100MHz → 200MHz. 108MHz or other intermediate values will silently snap to 100MHz.
Tested stable at 100MHz on a SanDisk Ultra (manufacturer ID 0x03). Saved ~630ms off SD card detection time.
200MHz is not recommended for mid-range cards (Ultra, Endurance). It may work but risks silent data corruption. The Extreme Pro line handles it more reliably.
Risk: If the card can't handle the clock speed, the Pi will fail to boot or corrupt the filesystem. If it fails to boot, reduce the value via SD card access on another machine. Start at 100MHz and only go higher if you have a card rated for it.
By default the systemd journal is stored in RAM (Storage=volatile) and lost on reboot.
For debugging boot failures, enable persistent storage temporarily:
sudo mkdir -p /var/log/journal
# With Storage=auto (default), the directory existing is enough to trigger persistenceRead logs from the previous boot:
journalctl --boot -1Once the system is stable, revert to volatile to eliminate the only active SD card writer during normal operation:
sudo mkdir -p /etc/systemd/journald.conf.d
sudo tee /etc/systemd/journald.conf.d/volatile.conf > /dev/null << 'EOF'
[Journal]
Storage=volatile
RuntimeMaxUse=20M
EOF
sudo rm -rf /var/log/journalWith Storage=volatile and update_config=0 in wpa_supplicant, the SD card is
effectively read-only during normal operation — safe for frequent power pulls.
# Overall boot time breakdown
systemd-analyze time
# Per-service time (sorted by slowest)
systemd-analyze blame
# Full dependency chain to default target
systemd-analyze critical-chain
# Chain for a specific service
systemd-analyze critical-chain ssh.service
# Check for CPU/power throttling
vcgencmd get_throttled # 0x0 = no issues
# Check actual SD card clock
sudo cat /sys/kernel/debug/mmc0/ios | grep clock
# Check CPU frequency and governor
cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq
cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governorStatic IP — eliminates DHCP round trip (100–400ms). Trade-off is needing to manage a fixed IP assignment. Worth doing if you need the last few hundred milliseconds.
SSH socket activation — systemd holds port 22 open immediately; SSH daemon starts on first connection. Shifts the ~700ms SSH startup from boot time to first-connection latency. Not a net win if you connect immediately after boot.
Custom kernel — stripping unused drivers from the kernel config can shave 1s+ off kernel boot time. Significant effort, breaks with upstream kernel updates.
initramfs optimization — moving WiFi firmware into the initramfs could reduce the brcmfmac initialization time (~2.5s in userspace). Complex to set up and maintain.