Jittered Sleep Duration (Thundering Herd Prevention)
Problemβ
When thousands of MyStation devices are deployed, they tend to wake up at similar intervals and call the weather API (Open-Meteo/DWD) and transport API (RMV) simultaneously. This creates request spikes that can overwhelm API servers or trigger rate limiting.
Without jitter (1000 devices, 5-min interval):
Requests βββββββββββββββββββββββββββββ β 1000 requests in <2s
βββββββββββββββββββββββββββββ time
With 60s jitter:
Requests ββ β ββ β β ββ β β ββ β β β β ~17 requests/second
βββββββββββββββββββββββββββββ time (spread over 60s)
Solutionβ
Each device adds a deterministic, device-unique offset to its sleep duration on the first wake cycle after boot. This one-time jitter spreads devices apart, and since each device then follows its own interval from that shifted starting point, they remain spread across subsequent cycles without further jitter.
Key Propertiesβ
| Property | Value |
|---|---|
| Jitter source | MAC address (unique per device) |
| Jitter range | 0 to MAX_JITTER_SECONDS - 1 (default: 0β59 seconds) |
| Deterministic | Yes β same device always gets the same offset |
| Applied | Only once per boot (when both lastWeatherUpdate and lastTransportUpdate are 0) |
| Coordination needed | None β devices operate independently |
| Battery impact | None β only adds sleep on first cycle |
Why Only on First Wake?β
If jitter were applied every cycle, it would accumulate and drift the update interval:
β Jitter every cycle (interval=300s, jitter=59s):
Cycle 1: sleeps 359s, wakes 59s late
Cycle 2: sleeps 359s, wakes 118s late
Cycle 3: sleeps 359s, wakes 177s late
...after 5 cycles: 5 minutes behind schedule!
β
Jitter only on first wake:
Cycle 1: sleeps 359s (one-time offset)
Cycle 2: sleeps 300s (exact interval)
Cycle 3: sleeps 300s (exact interval)
...permanently offset by 59s, interval stays exact
The first-wake-only approach is correct because:
- After power loss, all devices boot simultaneously β thundering herd β jitter needed
- After the first cycle,
lastUpdateis set β each device's schedule is already shifted - Subsequent cycles use exact intervals, no drift
Implementationβ
Jitter Seed (getDeviceJitterSeed())β
The jitter seed is derived from the device's factory-programmed MAC address:
uint32_t TimingManager::getDeviceJitterSeed() {
#ifdef NATIVE_TEST
return 0xDEADBEEF; // Fixed seed for reproducible tests
#else
uint8_t mac[6];
esp_efuse_mac_get_default(mac);
return (uint32_t)(mac[2] << 24) | (mac[3] << 16) | (mac[4] << 8) | mac[5];
#endif
}
Why MAC address?
- Burned into eFuse at factory β guaranteed unique per device
- Available without WiFi connection or NVS access
- Zero runtime cost (no random number generation)
- Deterministic: user sees consistent wake times
Jitter Applicationβ
The jitter is added to the final sleep duration only on the first wake after boot:
// At the end of getNextSleepDurationSeconds():
if (getLastWeatherUpdate() == 0 && getLastTransportUpdate() == 0) {
uint32_t jitter = getDeviceJitterSeed() % MAX_JITTER_SECONDS;
sleepSeconds += jitter;
}
The condition lastWeatherUpdate == 0 && lastTransportUpdate == 0 is true only when:
- Device has just cold-booted (power loss, reset, OTA reboot)
- RTC memory was cleared (these are
RTC_DATA_ATTRvariables)
After the first API fetch, at least one lastUpdate is set β jitter is never applied again
until the next cold boot.
Configurationβ
The maximum jitter window is defined in include/util/timing_manager.h:
static constexpr uint32_t MAX_JITTER_SECONDS = 60;
Scaling Recommendationsβ
| Device Count | Recommended MAX_JITTER_SECONDS | Avg Requests/Second |
|---|---|---|
| 100 | 30 | ~3.3 |
| 1,000 | 60 | ~17 |
| 10,000 | 120β180 | ~56β83 |
| 50,000+ | Consider API proxy/cache | β |
For deployments above 10,000 devices, consider adding a caching proxy (e.g., CloudFront + Lambda) between devices and upstream APIs to:
- Absorb request bursts
- Reduce dependency on external API availability
- Cache identical responses (weather data is the same for nearby devices)
Effect on Display Update Timingβ
From the user's perspective, the jitter is invisible:
- The first wake after boot is delayed by up to 60 seconds
- All subsequent updates follow the configured interval exactly
- No drift or accumulation over time
Testingβ
In native tests, getDeviceJitterSeed() returns 0xDEADBEEF, producing a deterministic
jitter of 0xDEADBEEF % 60 = 59 seconds.
Four dedicated jitter tests verify:
- Jitter applies on first wake (both
lastUpdate == 0) - Jitter does NOT apply when weather has been updated
- Jitter does NOT apply when transport has been updated
- Jitter seed is deterministic
Run tests with:
pio test -e native -v
Related Filesβ
| File | Description |
|---|---|
include/util/timing_manager.h | MAX_JITTER_SECONDS constant, getDeviceJitterSeed() declaration |
src/util/timing_manager.cpp | Jitter implementation in getNextSleepDurationSeconds() |
test/test_timing_manager/test_sleep_duration.cpp | Tests verifying jitter behavior |
docs/developer-guide/boot-process.md | Sleep duration calculation overview |