Configuration
Project configuration lives in mikro.config.ts at the root of your project, next to package.json.
import {defineConfig} from 'mikro'
export default defineConfig({
// options here
})defineConfig is a no-op identity function that provides type checking and autocompletion.
Runtime options
These options are bundled into the deployed app and read by the firmware at boot.
| Option | Type | Default | Description |
|---|---|---|---|
onPanic | object | {mode: 'restart', delay: 1000} | What the device does after an uncaught exception |
watchdog.blocking | number | false | 30000 | Max ms one event-loop turn may hold the loop |
watchdog.feed | number | off | Max ms between watchdog.feed() calls |
watchdog.awake | number | off | Max ms awake per wake cycle |
stackSize | number (bytes) | firmware default | QuickJS C stack size |
memReserved | number (bytes) | 64 KB, 16 KB without radios | Heap reserved for native subsystems |
wifi.country | WifiCountryCode | none | WiFi regulatory country code |
wifi.hostname | string | device name, else mikrojs-<id> | DHCP hostname advertised by the STA interface |
logFile | true | object | off | Enable on-device file logging |
logFile.dir | string | '/appfs/logs' | Directory the log file lives in |
logFile.maxSize | number | string | '64k' | Rotate when file exceeds this size |
logFile.flush | 'line' | 'error' | 'error' | When to flush buffered writes to flash |
onPanic
When an uncaught exception reaches the top level (a "panic"), the device restarts. onPanic configures what happens between the exception and that restart.
delay is the time in milliseconds the device waits before acting (default 1000). While it waits, the protocol REPL stays responsive, so you can run mikro deploy, mikro clean, or mikro ... --recover to break a crash loop. Use a longer delay during development to give yourself time to recover the device; use a shorter delay in production so it restarts sooner.
{mode: 'restart', delay?}(default): restart after the delay.{mode: 'deepSleep', delay?, duration}: enter deep sleep fordurationms after the delay, then restart on wake. This draws far less power than restarting straight away, which matters for battery-powered devices. The device is unreachable while asleep, so set a longerdelayduring development to keep a window for recovery.
// Battery device: sleep immediately, retry every 10 minutes
onPanic: {mode: 'deepSleep', delay: 0, duration: 600000}See Error Handling for details.
watchdog
Limits for the three watchdogs. Each one restarts the device through onPanic when it fires, so delay and the deep-sleep action apply to all of them. See the watchdog API page for what each one catches and how to size them.
blocking: how long one turn of the event loop may hold the device before the running code is interrupted with a stack trace. Default30000. Setfalseto disable.feed: how long the app may go without callingwatchdog.feed(). Off unless set. The app defines what progress means; this sets how long without it is too long.awake: how long the device may stay awake in one wake cycle, counted from boot. Off unless set. Nothing can extend it, so it bounds the whole cycle. Only for apps that wake, work, and deep-sleep; on a device that stays powered it is a reboot timer.
A wake-cycle app pairs awake with the deep-sleep panic action, and drops it on the bench with a per-environment override. Overrides replace the whole watchdog group, so watchdog: {} in development removes awake and leaves blocking at its default:
import {defineConfig} from 'mikro'
export default defineConfig({
onPanic: {mode: 'deepSleep', delay: 0, duration: 600_000},
watchdog: {awake: 120_000},
env: {development: {watchdog: {}}},
})Values below 1000 ms are raised to 1000 at boot with a warning.
stackSize
QuickJS C stack size. Controls how deep call stacks and recursion can go before hitting a stack overflow. The default is set by the firmware and is suitable for most apps. Only increase this if you hit InternalError: stack overflow on code that genuinely needs deep recursion.
memReserved
Amount of system heap to keep out of QuickJS's reach, reserved for native subsystems (WiFi, lwIP, TLS, HTTP client, C drivers). At runtime initialization, the QuickJS soft cap is computed as free_heap_at_init - memReserved.
The default is 64 KB, or 16 KB on firmware built without WiFi and BLE (such as the no-ble+no-wifi image), which has no network stack to reserve for.
- If your app doesn't touch the network stack, lower it (16-32 KB). This leaves more of the heap for JS and module loading.
- Keep the default (64 KB) for anything with WiFi + HTTPS. TLS handshakes need contiguous multi-KB buffers.
- Don't go below ~8 KB unless you know exactly what your native side does. If system heap hits 0 while QuickJS still thinks it has budget left, that's a hard crash rather than a catchable JS OOM.
Use /mem in the REPL to verify: the System line should have a comfortable cushion over the QuickJS line. See Configuring memReserved for more guidance.
wifi.country
Two-letter ISO 3166-1 country code for WiFi regulatory domain. Controls which channels and power levels are available. Set this to your country to comply with local regulations and get the best WiFi performance. When unset, ESP-IDF defaults to world safe mode ("01", channels 1-11). The full list of supported codes is defined by the ESP-IDF regulatory database (168 countries/regions).
wifi.hostname
DHCP hostname advertised by the STA interface. This is what your router shows in its client list. Must conform to RFC 1123: letters, digits and hyphens only, must not start or end with a hyphen, max 63 characters.
When unset, the device's name (set with mikro name) is used, shaped into a valid hostname: lowercased, with . and _ replaced by hyphens (for example Living_Room.Sensor becomes living-room-sensor). An unnamed device falls back to mikrojs-<device-id> (for example mikrojs-abcdef0123), where the device ID is a base32-encoded MAC.
Takes effect on the next DHCP lease. A name change applies after the WiFi stack is next brought up, typically on the next restart.
logFile
Persist runtime output to a rotated file on the device filesystem for post-mortem debugging. Captures JS console.* output, native MIK_LOG calls, and ESP-IDF ESP_LOG lines. Each entry is prefixed with an ISO 8601 wall-clock timestamp (after SNTP sets the RTC) or [+SSS.fffs] relative to boot otherwise.
Set to true for sensible defaults, or pass an options object to override individual settings:
export default defineConfig({
logFile: true,
})
// or:
export default defineConfig({
logFile: {
dir: '/appfs/logs',
maxSize: '128k',
flush: 'line',
},
})Pull the file off the device with mikro logs pull.
logFile.dir
Directory on the device filesystem where the log file is stored. The file itself is always named log.txt, rotated as log.txt.1 once maxSize is reached. The directory is created on first boot if it doesn't exist.
logFile.maxSize
Maximum file size before rotation. Accepts a number of bytes or a string with K/M suffix (for example '64k' or '1m'). At the cap, log.txt is renamed to log.txt.1 (replacing any previous generation) and a fresh log.txt is started. Total flash usage is bounded at 2 × maxSize.
logFile.flush
When buffered log writes are committed to flash. Tradeoff between forensic fidelity and flash wear:
'error'(default) — buffer everything, flush only on warn/error lines. The post-mortem sweet spot: errors land on flash immediately, normal output rides the stdio buffer.'line'— flush after every newline. Strongest crash-recovery guarantee, hardest on flash. Use only if you genuinely need every line to survive a hard reset.
Flash budget
Logging to flash burns write cycles. Rotation caps usage at 2 × maxSize, but flush: 'line' will accelerate wear noticeably under chatty workloads. Default to 'error' unless you have a specific reason not to.
Build options
Build-time options that control how your code is compiled and bundled. These are stripped from the config before deploying to the device. Can also be overridden by CLI flags.
| Option | Type | Default | Description |
|---|---|---|---|
build.bundle | boolean | false | Bundle into a single module via esbuild |
build.minifier | 'esbuild' | 'terser' | 'swc' | 'esbuild' | Which minifier to use |
build.minifyLevel | 'default' | 'max' | 'default' | Minification aggressiveness |
build.logLevel | 'none' | 'error' | 'warn' | 'info' | 'debug' | 'warn' in production, else 'debug' | Build-time log level threshold |
build.bundle
Bundle user code into a single module via esbuild, with tree-shaking. Firmware builtins (mikro/*) and native modules stay external.
Bundling reduces QuickJS per-module overhead and strips unused code paths, at the cost of less informative stack traces.
build.minifier
Which minifier to use. esbuild is always available. terser requires the terser package to be installed; swc requires @swc/core.
build.minifyLevel
Minification aggressiveness. 'default' uses safe transforms only. 'max' enables unsafe transforms (multiple compress passes, toplevel mangling, pure_getters, unsafe_arrows, unsafe_math, unsafe_methods) for the smallest possible output.
build.logLevel
Build-time log level. Console methods below this threshold are eliminated as dead code by the minifier. Levels from most to least verbose: debug > info > warn > error > none.
When unset, the level depends on the environment: warn in production (removing console.debug, console.log and console.info calls), debug in development and test. The CLI --loglevel flag overrides this setting.
Cascading removal with @__PURE__
If you have helper functions that compute values only used in log calls, annotate them with /* @__PURE__ */ so the minifier can cascade the removal:
const m = /* @__PURE__ */ memoryUsage()
console.log(`[mem] heap: ${m.heapUsed}`)
// both statements eliminated at logLevel: 'warn'High log verbosity can cause out-of-memory errors
Setting a higher log level (such as debug) keeps more console calls in the bundle. Each call site adds string formatting, template literal evaluation, and argument allocation at runtime. On memory-constrained devices this extra work can push the QuickJS heap past its limit and cause InternalError: out of memory. If you need verbose logging on a tight device, add it incrementally and monitor heap usage with /mem in the REPL.
Target options
Options that tell the CLI which board and firmware the project is for. They are read on the host and stripped from the config before deploying.
| Option | Type | Default | Description |
|---|---|---|---|
board | string | none | The board this project targets, e.g. esp32c6-generic |
features | ('wifi' | 'ble' | 'i2s')[] | none | Firmware features the app needs beyond its imports |
board
mikro flash picks the board in this order: the --board flag, then board from the config, then the board in the project itself or its dependencies when there is exactly one, so a board package flashes its own board. With several, it takes the one for the connected chip; when several are for that chip, it asks which one, or stops and lists them without a terminal. Without board dependencies, the CLI detects the chip and uses its generic board, <chip>-generic.
A board's name is the name its firmware reports, usually the name of its board package (@acme/devboard), or @acme/boards/t-display for one board of several in a package. A generic board is <chip>-generic. Before it writes anything, mikro flash checks that the connected chip matches the board and stops if it does not.
features
Four modules each need a firmware feature:
| Module | Feature |
|---|---|
mikro/wifi | wifi |
mikro/http/server | wifi |
mikro/ble | ble |
mikro/i2s | i2s |
The build works out which features the app needs from its imports, and mikro deploy, mikro dev and mikro test stop when the connected firmware lacks one of them. The stock firmware has all three, so this only comes up with custom firmware that leaves a feature out.
An import counts when it is a static import. A module that the app only ever loads with import('mikro/ble') does not count, so the deploy goes through and the import() fails on a device without BLE. List the feature in features when the app cannot do without it:
import {defineConfig} from 'mikro'
export default defineConfig({
features: ['ble'],
})Firmware that predates feature reporting is never checked.
TypeScript presets
The types of mikro/wifi, mikro/ble, mikro/i2s and mikro/http/server resolve only when the project's tsconfig.json grants their feature. Extend the preset for your chip:
{
"extends": "mikro/tsconfig/esp32c6-generic"
}There is one preset per chip (esp32-generic, esp32c3-generic, esp32c5-generic, esp32c6-generic, esp32s3-generic). The bare mikro/tsconfig grants every feature.
The presets grant features through customConditions. If your own tsconfig.json sets customConditions, it replaces the preset's list, so add the conditions you need to it: mikro:wifi, mikro:ble, mikro:i2s. Without them, an import of mikro/wifi reports "Cannot find module".
Simulator options
Simulator-specific options. These are stripped from the config before deploying to a real device.
| Option | Type | Default | Description |
|---|---|---|---|
sim.memLimit | number | string | '300k' | QuickJS heap memory limit |
sim.fsLimit | number | string | '1472k' | Virtual filesystem size limit |
sim.fsRoot | string | '.mikro/sim-fs' | Filesystem sandbox root directory |
All size options accept a number of bytes or a string with K/M suffix (for example '300k' or '1m').
The sim.fsLimit default matches the generic firmware's filesystem. If your device has a different partition table, set sim.fsLimit to the size that storageUsage().total reports on the device.
Per-environment overrides
Put shared config at the top level and override it per environment under env. There are three environments, each tied to commands:
| Environment | Commands |
|---|---|
production | mikro deploy, mikro build |
development | mikro dev, mikro sim deploy/dev/profile |
test | mikro test, mikro sim test |
The active environment's overrides are merged over the base. The env map itself is not deployed:
import {defineConfig} from 'mikro'
export default defineConfig({
// Shared by every environment.
wifi: {country: 'NO'},
env: {
production: {
// Drop debug logging and minify for a smaller build.
build: {minifyLevel: 'max', logLevel: 'warn'},
// Deep-sleep after a crash to save power.
onPanic: {mode: 'deepSleep', delay: 0, duration: 600000},
// Write logs to flash for later inspection.
logFile: true,
},
development: {
// Keep all log output.
build: {logLevel: 'debug'},
// Wait 5 seconds before restarting after a crash, leaving time to redeploy.
onPanic: {mode: 'restart', delay: 5000},
},
test: {
// Test fixtures may exceed the production readFile() limit.
fsReadMax: '256k',
},
},
})Fields you don't override come from the base; every environment here keeps wifi.country. An environment with no env entry uses the base config unchanged.
The merge is shallow: an override replaces a whole top-level field. Overriding build replaces the entire build object, not just the keys you set, so spread the base to keep the rest:
import {defineConfig} from 'mikro'
const build = {minifier: 'esbuild', minifyLevel: 'max'} as const
export default defineConfig({
build,
env: {
// Keep minifier + minifyLevel, raise the log level.
development: {build: {...build, logLevel: 'debug'}},
},
})Config environment vs .env.<mode>
The config environment is not the .env.<mode> file a command loads. mikro sim dev uses the development config but reads .env.simulator. The .env file can be finer-grained.
Full example
import {defineConfig} from 'mikro'
export default defineConfig({
onPanic: {mode: 'restart', delay: 500},
memReserved: 32 * 1024,
wifi: {
country: 'NO', // Norway
hostname: 'living-room-sensor',
},
logFile: true,
build: {
bundle: true,
minifier: 'esbuild',
minifyLevel: 'max',
logLevel: 'warn',
},
sim: {
memLimit: '512k',
fsLimit: '2m',
},
})