TL;DR: Irregular flicker at 5–25% brightness (stable at 50%+) is caused by ZC signal jitter, ISR delays from WiFi on ESP32/ESP8266, or TRIAC holding-current misses. Fix: set a minimum stable level (10–15%), use
rbdimmerESP32on ESP32, filter the ZC line, or switch to DimmerLink for hardware-timed control.
This Article vs. the LED Flicker Article
Important: This article is about resistive loads — incandescent and halogen lamps, heating elements — flickering at low dimmer levels. The symptom is irregular, slow fluctuation (0.5–8 Hz) that disappears at medium and high brightness.
If you are using LED lamps and observe flicker at any brightness level, the problem is TRIAC–LED compatibility, not low-level timing. See: LED Flickering with AC Dimmer
What the Problem Looks Like
- Lamp set to 10–20% brightness flickers visibly and irregularly.
- The same lamp at 50% is rock-steady.
- Replacing the controller with an Arduino stops the flicker, but an ESP32 or ESP8266 with WiFi does not.
- Different lamp wattages give different minimum stable levels (40 W lamp may be stable at 18%, while 100 W is stable at 8%).
Root Causes
Cause 1 — Zero-Cross Jitter
Phase-cut dimming works by firing the TRIAC at a precise delay after each AC zero-crossing. At 10% brightness the delay is ~9 ms (for 50 Hz mains, half-period = 10 ms):
delay = (1 - level/100) × half_period
delay at 10% = (1 - 0.10) × 10 ms = 9.0 msA ±0.5 ms jitter on the ZC signal is only 5% error at 50% brightness, but 55% error in the firing angle at 10%. This produces large, visible brightness jumps.
Sources of ZC jitter:
- mains voltage noise and EMI coupling onto the ZC line;
- a long ZC cable running parallel to AC power wires;
- inadequate decoupling on the zero-cross detector board.
Cause 2 — ISR Delays on ESP32 (WiFi)
The rbdimmerESP32 library pins its interrupt service routine to Core 0
with IRAM_ATTR. If you use the wrong library (RBDdimmer instead of
rbdimmerESP32) or your sketch prevents proper ISR pinning, the WiFi
stack can delay the DIM interrupt by 1–5 ms.
At 10% brightness (9 ms delay window), a 2 ms WiFi delay is a 22% relative timing error — enough to miss the firing window completely on some half-cycles.
On Arduino (AVR) the interrupt is hardware-handled in real time — no delay, hence no flicker from this cause.
See: ESP32 IRAM_ATTR Guru Meditation
Cause 3 — TRIAC Holding Current
At very short conduction angles (< 8–10% level) the current through the load may collapse below the TRIAC holding current before the device fully latches. Some half-cycles the TRIAC fires; others it does not. This produces slow, irregular flicker at 1–8 Hz — distinct from the smooth dimming at higher levels.
The minimum holding-current-limited level depends on load wattage:
| Load | Approx. min. stable level |
|---|---|
| 25–40 W incandescent | 20–25% |
| 60 W incandescent | 15% |
| 100 W incandescent | 8–10% |
| 150 W incandescent | 5–8% |
| 500 W heating element | 3–5% |
Cause 4 — Minimum Power Limit Set Too Low
In ESPHome the ac_dimmer component has a min_power parameter.
Setting it below the stable threshold for your load causes the component
to try to fire at angles where the TRIAC cannot reliably latch.
Diagnosing the Problem
- Identify the load — test with a 100 W incandescent first. If stable at 10%, the original load has a holding-current issue at that wattage.
- Check the platform — swap the ESP32/ESP8266 for an Arduino Uno. If flicker disappears, ISR delays (Cause 2) or ZC jitter amplified by software processing (Cause 1) is the issue.
- Inspect the ZC wire routing — if the ZC cable runs next to AC power wires, reroute it away and retest.
- Measure the ZC pulse with an oscilloscope or logic analyser. Jitter > 200 µs at 50 Hz indicates a noisy ZC signal.
Solutions
🟢 For Beginners — Use DimmerLink
DimmerLink handles zero-cross detection and TRIAC firing on its own Cortex-M0+ microcontroller. Firing jitter is under 50 µs regardless of what the host MCU (ESP32, Raspberry Pi, Arduino) is doing. WiFi activity has zero impact on timing.
// ESP32 + DimmerLink — set 15% brightness via I2C
#include <Wire.h>
Wire.begin();
Wire.beginTransmission(0x50);
Wire.write(0x10); // DIM0_LEVEL
Wire.write(15); // 15% — stable at this level
Wire.endTransmission();There is no minimum-level flicker with DimmerLink because the firmware enforces clean firing pulses with hardware timers.
🔵 For Advanced Users — Platform-Specific Fixes
Option A — Set a Minimum Stable Level
Find the lowest stable level experimentally for your specific load, then clamp the control range from below.
rbdimmerESP32 / RBDdimmer:
const uint8_t MIN_STABLE_LEVEL = 12; // adjust for your load
void setDimmerLevel(uint8_t requestedLevel) {
if (requestedLevel == 0) {
dimmer.setState(OFF);
} else {
uint8_t level = max(requestedLevel, MIN_STABLE_LEVEL);
dimmer.setState(ON);
dimmer.setPower(level);
}
}ESPHome:
output:
- platform: ac_dimmer
id: triac_out
gate_pin: GPIO4
zero_cross_pin: GPIO5
min_power: 0.12 # 12% — tune to your load
zero_means_zero: trueOption B — Verify IRAM_ATTR on ESP32
Ensure you are using rbdimmerESP32, not RBDdimmer:
// Correct for ESP32 dual-core (ESP32, ESP32-S3):
#include <rbdimmerESP32.h>
rbdimmer::Dimmer dimmer(DIM_PIN, ZC_PIN, CHANNEL_1, 50);
// Wrong for ESP32 — no IRAM_ATTR, ISR can be preempted:
// #include <RBDdimmer.h>Also confirm WiFi tasks are not pinning themselves to Core 0:
// Dedicated WiFi task should run on Core 1
xTaskCreatePinnedToCore(wifiTask, "WiFi", 4096,
NULL, 1, NULL, 1 /*core 1*/);Option C — Filter the ZC Line
Add a simple RC filter to reduce high-frequency noise on the ZC signal:
ZC output pin ── 100 Ω ──┬── MCU ZC input pin
│
100 pF
│
GNDAdditionally:
- Route the ZC wire at least 2–3 cm away from AC power cables.
- Cross AC and ZC wires at 90° if they must intersect.
- Keep the ZC cable under 30 cm.
Option D — Switch to RMS Dimming Curve
The RMS curve concentrates control sensitivity in the 20–80% range, which avoids the unstable low-end region:
rbdimmerESP32:
// After begin():
dimmer.setCurve(RBDIMMER_CURVE_RMS);DimmerLink (I2C register 0x11):
Wire.beginTransmission(0x50);
Wire.write(0x11); // DIM0_CURVE
Wire.write(1); // 1 = RMS
Wire.endTransmission();| Curve | Code | Best for |
|---|---|---|
| LINEAR | 0 | Universal (default) |
| RMS | 1 | Incandescent, halogen |
| LOG | 2 | Dimmable LED (eye perception) |
Platform Matrix
| Platform | Likely cause | Fix |
|---|---|---|
| Arduino Uno / Mega | ZC jitter or holding current | min_power + RC filter |
| ESP32 with WiFi | ISR delay + ZC jitter | rbdimmerESP32 + IRAM_ATTR |
| ESP8266 with WiFi | ISR delay | Min level 15%+, or DimmerLink |
| Any platform, load < 40 W | Holding current | Limit min level to load's threshold |
| Any platform, guaranteed | Hardware solution | DimmerLink |
Quick Checklist
Related Articles
- Lamp doesn't turn off at 0% → AC Dimmer Doesn't Turn Off
- LED-specific flicker → LED Flickering with AC Dimmer
- ESP32 IRAM_ATTR fix → Guru Meditation & IRAM_ATTR
- Wrong library on ESP32 → Wrong Library: RBDdimmer vs rbdimmerESP32
- Zero-cross not detected → Zero-Cross Detection Errors
Still have questions?
Ask on forum.rbdimmer.com or open a GitHub Issue.