Developing for Microcontrollers
Writing TypeScript for an ESP32 is different from writing it for Node.js or a browser. This page covers the constraints you'll encounter and how to work within them.
Memory is limited
A typical ESP32 has between 320 and 512KB of RAM. After the Mikro.js runtime, WiFi stack, and TLS libraries are loaded, you'll have roughly 80–150KB of free heap depending on what features you use. That's enough for most applications, but you need to be aware of it.
Check your memory usage
import {memoryUsage} from 'mikrojs/sys'
const mem = memoryUsage()
console.log('Free:', (mem.heapTotal - mem.heapUsed) / 1000, 'KB')Call this periodically (e.g., every loop iteration) to spot leaks early.
Avoid large allocations
- Strings are immutable. Concatenating in a loop creates a new string each time. For large outputs, build an array and join once.
- JSON parsing allocates a full object tree. Parse large responses in chunks if possible, or only fetch what you need from APIs.
Uint8Arrayis more memory-efficient than regular arrays for binary data.
Memory management
QuickJS uses reference counting, so most objects are freed immediately when they go out of scope. A separate cycle-removal pass runs automatically when allocated memory grows too large, collecting circular references that refcounting can't handle. In typical embedded code, circular references are rare, so you usually don't need to think about this.
Dynamic imports
You can use import() to conditionally load modules at runtime. This can help keep initial memory usage low by only loading code when it's actually needed.
if (sensorConnected) {
const {readSensor} = await import('./sensor.js')
readSensor()
}Be aware that a dynamic import can cause an out-of-memory crash at runtime if free heap is low at the time of the import. Static imports fail at startup (which is easier to debug); dynamic imports can fail later when conditions are harder to reproduce.
WiFi and TLS use significant memory
Connecting to WiFi allocates buffers for the network stack. HTTPS (TLS) requests add another ~40–50KB on top of that. This is the single largest memory consumer in most applications.
Recommendations:
- Connect to WiFi once and stay connected; don't reconnect repeatedly
- If memory is tight, use HTTP instead of HTTPS for local/trusted endpoints
- Check
memoryUsage()after network operations to understand your headroom
Deep sleep for battery projects
If your device runs on batteries, deep sleep can make a big difference. A typical board draws around 10-20µA in deep sleep, compared to ~80-160mA when active with WiFi.
import {deepSleep, enableTimerWakeup} from 'mikrojs/sleep'
import rtc from 'mikrojs/rtc'
const readings = rtc.createValue('readings', 0)
readings.inc()
// Do your work (read sensor, send data, etc.)
// Sleep for 5 minutes
deepSleep(5 * 60 * 1_000_000)Important: deep sleep resets the CPU. Your program starts from the beginning on wake. Use RTC memory to persist state across sleep cycles.
Timers and the event loop
Mikro.js runs a single-threaded event loop, similar to Node.js. setTimeout, setInterval, and Promise all work as expected. Long-running synchronous code blocks the event loop, which blocks WiFi, timers, and everything else.
Recommendations:
- Break long computations into chunks with
await sleep(0)to yield - Don't use tight
while(true)loops without anawaitinside - WiFi events (connect, disconnect) are delivered through the event loop; a blocked loop means delayed event handling
Numbers
Numbers work like standard JavaScript. QuickJS internally optimizes small integers as int32 values, but this is transparent to your code. For most embedded use cases (ADC values, temperatures, counters), precision is not a concern.
- Bitwise operations work on 32-bit integers (standard JS behavior)
BigIntis supported for integers larger than 2^53
Filesystem
On device, the filesystem uses LittleFS on a flash partition. Files persist across reboots and deep sleep cycles. The simulator provides a filesystem backed by a directory on your computer.
Keep writes to a minimum: flash memory has a limited number of write cycles (~100,000 per sector). Don't write on every loop iteration. Buffer data in RAM and write periodically.
Error handling matters more
On a server, an unhandled exception crashes the process and gets restarted by a supervisor. On a microcontroller, an unhandled exception can leave your device in a broken state, potentially miles from anyone who can reset it.
This is why Mikro.js uses typed Results instead of exceptions. Every failure is visible in the type signature, so you can't accidentally ignore it.
// The type system forces you to handle this
const result = await wifi.connect(ssid, passphrase)
if (!result.ok) {
console.error('WiFi failed:', result.error.name)
// Retry, fall back, or sleep and try later
}For truly unrecoverable errors (missing config, hardware failure at boot), use .orPanic(). On device, you can configure automatic restart on panic so the device recovers.
Host simulator limitations
mikro sim runs your code on your computer using the Mikro.js NAPI addon. It's useful for testing logic without a device, but some things aren't available:
- GPIO, PWM, NeoPixel, I2C, SPI: not available (no hardware)
- WiFi and fetch: work normally (uses your computer's network)
- Timers, sleep, console, sys: work normally
- Deep sleep: exits the process instead of sleeping
Use the simulator for logic, data processing, and network code. Test hardware interactions on the actual device.