A Bathroom Fan That Finishes the Job: Humidity Thresholds, Hysteresis, and Run-On Timers
A bathroom fan that stops the moment humidity crosses back under a threshold is not doing its job. The moisture that matters is in the walls, the grout, and the towel bar, not just in the air at the sensor. The fix is not a smarter sensor. It is a control loop with three parts: a threshold that starts the fan, hysteresis that keeps it from chattering, and a run-on timer that lets the fan finish moving air after the sensor says the room is dry.
This is the same pattern whether you are using Home Assistant’s Generic hygrostat, a plain automation, or a mechanical humidistat. The numbers differ. The structure does not.
What the Generic Hygrostat actually does
Home Assistant’s Generic hygrostat is a virtual device that reads one humidity sensor and toggles one switch. In dehumidifier mode, it turns the switch on when measured humidity rises above the target, and off when it falls back to the target. That sounds like exactly what a bathroom fan needs, and it is a reasonable starting point, but the defaults are worth reading carefully.
The integration exposes two tolerance parameters. dry_tolerance defaults to 3, and wet_tolerance defaults to 3. The documentation describes them as the minimum difference between the sensor reading and the target that must change before the switch is toggled. The docs also advise setting each tolerance equal to or above your sensor’s precision. That advice matters more than it looks. A tolerance of 3 on a sensor that reports in whole percentage points is fine. A tolerance of 3 on a sensor that reports to 0.1% is still fine, but a tolerance of 0.5 on a sensor that reports in whole points will produce a control loop that reacts to rounding.
Two other options are relevant to a bathroom. min_cycle_duration sets a minimum time the switch must stay in its current state before it can be toggled again. sensor_stale_duration marks the sensor unavailable if it stops updating, and when the sensor is unavailable the integration turns the switch off for safety. That last behavior is the opposite of what you want for a bathroom fan. If the sensor drops off the network mid-shower, the fan stops. You can work around it, but you have to know it is there.
The Generic hygrostat does not have a built-in run-on timer. It has hysteresis and a minimum cycle time. Run-on is a separate concern, and in Home Assistant the natural tool is the Timer integration.
Why hysteresis alone is not enough
Hysteresis solves one problem: a sensor reading that sits near the threshold and flips the fan on and off every few seconds. It does not solve the problem of residual moisture. When the air in the bathroom reaches the target humidity, the surfaces in the room are still wet. Grout, caulk, and the underside of a bath mat release moisture for a while after the air has cleared. If the fan stops at the moment the air crosses the threshold, that residual moisture stays in the room.
A run-on timer decouples the fan’s stop condition from the sensor reading. The sensor decides when the fan may stop. The timer decides when it actually stops. In practice, the sequence is: humidity rises above the start threshold, fan turns on, humidity falls below the stop threshold, timer starts, timer finishes, fan turns off. If humidity rises again during the timer, the timer is cancelled or restarted.
Home Assistant’s Timer integration is built for this. It is a countdown entity with states idle, active, and paused, and it fires a timer.finished trigger. The documentation gives a bathroom fan example directly: trigger on timer.finished, action fan.turn_off. The same page notes a limitation worth planning around: if a timer finishes while Home Assistant is not running, automations using the timer.finished trigger do not run after startup. A fan that was supposed to stop during a restart will keep running until something else stops it.
The Fan integration provides the other half. It exposes fan.turned_on and fan.turned_off triggers and fan.is_on and fan.is_off conditions, plus the usual turn on, turn off, and toggle actions. The documentation includes a “bathroom fan reminder” example that triggers when a fan has been on for 20 minutes. That is a useful safety net, but it is a notification, not a control. If you want the fan to stop on its own after a maximum runtime, you need a separate automation or a timer with a known duration.
A concrete control loop
Here is a structure that works with the pieces above. It is not the only structure, and the numbers are starting points, not findings.
Pick a start threshold and a stop threshold. A common starting point is start at 60% relative humidity, stop at 55%. The gap is the hysteresis. If your sensor reports in whole points, a 5-point gap is comfortable. If it reports to 0.1%, you can narrow it, but there is rarely a reason to. The Generic hygrostat’s dry_tolerance and wet_tolerance can express this, or you can write the comparison directly in an automation.
Pick a run-on duration. Fifteen to twenty minutes is a common range for a residential bathroom. The right number depends on the room volume, the fan’s rated airflow, and how much water is in the air. A small powder room with a 50 CFM fan and a five-minute shower needs less than a large bathroom with a 110 CFM fan and a twenty-minute shower. If you do not know your fan’s airflow, the run-on timer is the wrong place to guess. Measure the room, find the fan’s rating, and size the timer from that.
Wire the loop so the timer starts when humidity falls below the stop threshold, and cancels or restarts if humidity rises above the start threshold again. The Timer integration supports timer.start, timer.cancel, and timer.change. A simple version uses timer.start on the stop condition and timer.cancel on the start condition. The fan turns off on timer.finished.
Add a maximum runtime. If the fan has been on for 45 minutes, turn it off regardless of humidity. This catches a stuck sensor, a door left open, or a humid day that keeps the room above the stop threshold. The Fan integration’s fan.turned_on trigger with a for option is one way to express this. A second timer is another.
Sensor choice and placement
The control loop is only as good as the humidity reading. ESPHome supports the Sensirion SHT3x-D and SHT85 through the sht3xd platform, with a default update_interval of 60 seconds and an optional heater_enabled flag. The ESPHome documentation notes that the heater “may help provide more accurate readings in condensing conditions, but can also increase temperature readings and decrease humidity readings as a side effect.” That is a real tradeoff in a bathroom, where condensing conditions are the norm. If you enable the heater, expect the humidity reading to run low during and shortly after a shower.
Placement matters more than sensor model. A sensor mounted near the ceiling above the shower will read high humidity quickly and clear quickly. A sensor mounted near the door will lag. A sensor mounted inside the fan housing will read the air the fan is moving, which is not the same as the air in the room. The most useful placement for a control loop is usually on a wall opposite the shower, at roughly chest height, away from direct spray and away from the supply register. That is a recommendation, not a code requirement.
If you are using Zigbee2MQTT or Z-Wave JS, the sensor and the fan switch are exposed as entities in Home Assistant, and the automation runs locally. The vendor cloud is not in the path. That is the point of a local-first stack. It also means the failure modes are yours to handle: a Zigbee sensor that drops off the mesh, a Z-Wave switch that fails to acknowledge, a Home Assistant restart during a shower.
Failure modes and graceful degradation
The Generic hygrostat’s sensor_stale_duration behavior is the clearest example of a safety default that is wrong for this use case. When the sensor is unavailable, the integration turns the switch off. For a dehumidifier, that is safe. For a bathroom fan mid-shower, it means the fan stops and the room stays wet. If you use the Generic hygrostat, either accept that behavior or put a separate automation in front of it that keeps the fan on when the sensor is unavailable and the fan was recently on.
Power loss is the other obvious failure. If the fan is on a smart switch and the switch loses power, the fan stops. When power returns, the switch may come back in its previous state or in an off state, depending on the device. A mechanical timer or a plain switch does not have this problem. It also does not have a humidity sensor, a network dependency, or a firmware update. If the bathroom is used by people who will not troubleshoot a dashboard, a mechanical timer is the smarter choice. I would rather see a $20 mechanical timer that always works than a $200 automation that fails once a month.
Network outage is a softer failure. If Home Assistant is running but the Zigbee coordinator is down, the sensor reading goes stale and the fan may stop. If Home Assistant itself is down, the automation does not run at all. The fan stays in whatever state it was in. A local-first stack reduces the blast radius of a vendor cloud outage, but it does not eliminate the need for a manual override. Put a physical switch on the fan circuit, or at least make sure the smart switch has a physical button that a guest can find.
Sensor drift is the slow failure. A humidity sensor that reads 5 points low will start the fan late and stop it early. There is no automatic fix for this in the Generic hygrostat. The practical approach is to compare the sensor against a known reference once or twice a year, and to replace the sensor when the offset exceeds the hysteresis gap. If your start and stop thresholds are 5 points apart and your sensor drifts 6 points, the loop stops working.
What the codes say, and what they do not
ASHRAE 62.2 is the standard most often cited for residential ventilation, and it addresses bathroom exhaust in terms of airflow and intermittent operation. The ASHRAE standards page is a directory, not the standard itself, and the standard is not free to read in full. I have not retrieved the specific runtime or humidity-control language from a primary source, so I am not going to state a number here. If your project requires code compliance, read the adopted version for your jurisdiction and confirm the requirements with the authority having jurisdiction. A humidity-triggered fan with a run-on timer is a control strategy, not a substitute for meeting the airflow requirement.
What I can say from the retrieved sources is narrower. Home Assistant’s Generic hygrostat defaults to a 3-point tolerance on each side. The Timer integration is designed for countdowns like a bathroom fan run-on. The Fan integration provides the triggers and actions to tie it together. ESPHome’s SHT3x platform has a heater option with a documented tradeoff. Those are the building blocks. The numbers you choose are yours.
When to skip the automation
If the bathroom has one user, a mechanical timer is often enough. If the bathroom has a window that opens, a fan may not be the primary ventilation path. If the fan is on a circuit that also serves a light, a plain switch may be the only thing that fits. If the household includes people who will not open an app, a smart switch with a physical button is the minimum viable interface.
The case for the automation is specific: you want the fan to run long enough to clear residual moisture without running all day, and you want it to start without anyone remembering to flip a switch. That is a real problem. It is also a problem that a $30 humidity-sensing fan controller from a hardware store solves, with no network, no firmware, and no dashboard. If you already have Home Assistant and a humidity sensor in the bathroom, the automation is nearly free. If you do not, the mechanical option is worth pricing before you buy a Zigbee sensor and a smart switch.
FAQ
Can I use the Generic hygrostat for a bathroom fan?
Yes, in dehumidifier mode, with the caveats above. It has hysteresis and a minimum cycle duration, but no run-on timer, and it turns the switch off when the sensor is stale. For a bathroom fan, that last behavior is usually wrong.
What is a reasonable run-on time?
Fifteen to twenty minutes is a common starting range for a residential bathroom. The right number depends on room volume, fan airflow, and shower duration. Start with a number, watch the room, and adjust. Do not treat any number as a standard.
What happens if Home Assistant restarts while the fan is on?
The Timer integration restores active and paused timers after a restart when configured with the restore option, but the documentation notes that automations using the timer.finished trigger do not run on startup if the timer expired while Home Assistant was not running. In practice, a fan that was supposed to stop during a restart may keep running. A maximum-runtime automation is a useful backstop.
Should I enable the SHT3x heater?
The ESPHome documentation says the heater may help in condensing conditions but can increase temperature readings and decrease humidity readings. In a bathroom, that tradeoff is real. If you enable it, expect the humidity reading to run low during and shortly after a shower, and adjust your thresholds accordingly.
Do I need a smart fan switch, or can I use a smart relay?
Either works if the fan is a simple on/off load. A smart fan switch usually gives you a physical button and a known state after power loss. A relay gives you less. Check the fan’s motor type and the switch’s rating before you commit. Some fans have electronics that do not tolerate a relay cutting power abruptly.
How do I know if my sensor has drifted?
Compare it against a known reference once or twice a year. If the offset exceeds your hysteresis gap, the control loop will misbehave. Replace the sensor or recalibrate if the device supports it. There is no automatic drift correction in the Generic hygrostat.
If you are auditing the small systems that run your week, this fan loop is a good example of the pattern: a sensor, a threshold, a delay, and a fallback. The audit article covers the broader approach. The fan is one loop. The same structure applies to lights, locks, and anything else that needs to finish a job after the trigger has passed.