A limit switch closes. For about 10 milliseconds, the contact doesn’t close so much as argue with itself — open, closed, open, closed — before it settles. Your PLC scans fast enough to see every one of those bounces. So the part that should register one detection registers four, the counter drifts, and three weeks later someone files a ticket that says “count is wrong sometimes.”
This is the most common bug I get called for that isn’t really a bug. It’s physics, and you have to design around it.
What bouncing actually looks like
Here’s the signal you think you’re getting versus the one the hardware delivers:
The top trace is the raw input — clean intent, ugly reality. The bottom trace is what you want: a single, stable transition that only commits after the signal has held its new state for a debounce window t_d.
A debounce timer doesn’t make the signal cleaner. It makes your logic refuse to believe the signal until it has stopped lying.
Pick a debounce time you can defend
Don’t copy t_d = 50ms from a forum post. Measure, or look it up, then add margin.
| Contact type | Typical bounce | Sane t_d |
|---|---|---|
| Snap-action limit switch | 1–5 ms | 10–20 ms |
| Pushbutton (panel) | 5–20 ms | 30–50 ms |
| Relay contact | 1–10 ms | 20 ms |
| Reed switch | up to 1 ms | 5–10 ms |
The rule: t_d must be longer than the worst-case bounce but shorter than the fastest real event you need to catch. If a part can pass the sensor every 80 ms, a 50 ms debounce is already eating most of your margin. That tension is the whole design — write the two numbers down before you pick a timer.
The pattern, in structured text
This is an on-delay timer (TON) gated by the input. The output only goes true once the raw input has been continuously true for t_d:
// Debounce a single digital input.
// xRaw : BOOL - raw field input
// xClean : BOOL - debounced result
// tDebounce : TIME - hold time, e.g. T#20ms
debounceTimer(IN := xRaw, PT := tDebounce);
xClean := debounceTimer.Q;
That handles the rising edge. The catch: it debounces the transition to true but trusts the falling edge instantly. For symmetric debounce — both edges filtered — track the last stable state and only update it when the timer expires:
// Symmetric debounce: filter BOTH edges.
debounceTimer(IN := (xRaw <> xStable), PT := tDebounce);
IF debounceTimer.Q THEN
xStable := xRaw; // signal held its new state long enough — commit
END_IF
xClean := xStable;
The timer runs only while the raw input disagrees with the committed state. The moment they agree again — bounce settled — the timer resets on its own.
Why not just do it in hardware?
Sometimes you should. An RC filter or a Schmitt-trigger input card debounces before the signal ever reaches the scan. Reach for hardware when:
- The event is faster than your scan cycle (you’ll miss it in software regardless).
- You’re counting high-speed pulses — use a dedicated high-speed counter input, not ladder.
- The line is electrically noisy and you want the filtering close to the source.
For everything else, software debounce wins because it’s visible, adjustable, and documented in the same place as the logic that depends on it.
Things that will still bite you
- Debouncing an already-clean signal. Encoder channels, drive status words, and networked I/O are already conditioned. Adding a debounce timer just adds latency and hides real faults.
- One global
t_dfor every input. A 50 ms estop debounce is a safety problem. A 5 ms debounce on a chattery panel button is useless. Tune per signal class. - Forgetting the timer is per-instance. Reusing one
TONacross multiple inputs in a loop means they fight over the same accumulated time. Instantiate one timer per signal.
The one-line version
Decide t_d from the worst bounce and the fastest real event, debounce both edges, instantiate one timer per input, and never debounce a signal that was already clean. Do that and the count stops being “wrong sometimes.”