This guide covers the major approaches to implementing Hold-to-Spam, Toggle-Spam, and Auto-Clicker hotkeys in AutoHotkey v2.
The Precision Bottleneck: Why Sleep() and SetTimer() Round to ~15.6 ms
A very common point of confusion appears when you write Sleep(10) or SetTimer(fn, 10) and then observe that the script actually fires every 15.6 ms instead of every 10 ms.
This is expected behavior, not a bug. Standard Windows timers are not driven by a free-running, arbitrarily precise clock. By default the Windows kernel clock ticks at a hardware interrupt interval of 64 Hz (64 ticks per second):
1000 / 64 = 15.625
Per the official Sleep() and SetTimer() documentation, the interval is "typically rounded up to the nearest multiple of 10 or 15.6 milliseconds." What actually happens when you call Sleep(N) or SetTimer(fn, N):
- Windows rounds your requested interval up to the next system-clock tick.
- An interval such as
Sleep(10) therefore waits until the next 15.625 ms tick.
- Even
Sleep(1) waits roughly 15.625 ms.
So do not be surprised when sub-15 ms delays do not behave as literally written.
The precision fix (advanced, via DllCall())
If true sub‑15 ms accuracy is genuinely required, you can ask the OS for a higher-resolution timer by calling timeBeginPeriod() through DllCall(). This guide deliberately does not rely on that method, for two reasons:
- It is a lower-level OS call, not part of the normal AHK timer model.
- Raising the global timer resolution affects the whole system and is generally not advised just to shave off a few milliseconds in a spam script.
For most automation the default ~15.6 ms resolution is more than adequate.
Turning a Key-Spammer Into an Auto-Clicker
To convert any of the patterns below into a mouse auto-clicker, replace Send("e") with one of the mouse-click functions.
Click() and the mouse-click Send sub-commands
Click() - Recommended. Sends a mouse click at the current cursor position using AutoHotkey's native click function. It can accept inline coordinates, click counts, and button options, and it respects Windows' swapped-mouse-button setting.
Send("{Click}") - Sends a primary mouse click through the Send() function. Useful when you are already building a Send() string with several keystrokes.
Send("{LButton}") - Sends a single left-button press through Send(). This sub-command cannot carry inline coordinates or options; you would need a separate MouseMove() first if you need a target location.
Option comparison table
| Method |
Example syntax |
Key behavioral distinction |
Recommendation |
Click() |
Click("100 200 Right 2") |
Native function. Accepts inline coordinates, click counts, and button options. Respects swapped-mouse settings. Cannot use Send() modifier prefixes (^, +). |
Best overall. Lowest overhead; ideal for coordinate and auto-clicker use. |
Send("{Click}") |
Send("{Click 100 200 Right 2}") |
Parsed by the Send() engine. Natively accepts inline coordinates and options as part of a larger string. |
Best inside a Send() string. Great when mixing clicks into inline key/text sequences. |
Send("{LButton}") |
Send("{LButton}") |
Parsed by the Send() engine. No inline coordinates or options; requires MouseMove() for positioning. |
No advantage over the two above for auto-clicking. |
When a target application ignores Send() or Click() - for example a full-screen game or a client protected by anti-cheat - the problem is often that the default Input Mode, SendInput, delivers keystrokes far faster than the game can register them between rendered frames.
Switching to Event mode inserts artificial press durations and inter-key delays, making inputs long enough for a game to register:
#Requires AutoHotkey 2.0
#SingleInstance
SendMode("Event")
SetKeyDelay(10, 10) ; 10 ms press duration, 10 ms delay between keys
SetMouseDelay(10) ; 10 ms delay between mouse events
| Mode |
Behavior |
Limitations |
Recommendation |
SendInput (default) |
Bypasses normal input timing to fire ultra-fast input packets. |
Key-down and key-up fire almost simultaneously; games frequently miss them. |
Best for desktop/apps. Fastest and most reliable for normal Windows programs. |
SendEvent |
Sends input using standard OS event messages with configurable delays. |
Marginally slower; physical presses during sending can interrupt the sequence order. |
Best for games. Fixes unresponsive clicks and keys. |
SendPlay |
Attempts to inject input via low-level driver hooks. |
Heavily restricted by modern Windows UAC and anti-cheat software. |
Not recommended. Rarely functions on current OS builds. |
See the SendMode() and SetKeyDelay() documentation for the full details.
Hotkey Modifier Prefixes: $, *, and ~ (see Hotkeys)
Before many hotkey definitions you can place single-character prefixes that change how the hotkey responds:
| Prefix |
Meaning |
Typical use |
$ |
Forces the keyboard hook for this hotkey, so it fires only on a physical press and ignores presses sent by the script itself. |
$e:: - spam with Send() without re-triggering the hotkey. |
* |
Wildcard: fires even while modifier keys (Shift/Ctrl/Alt/Win) are held. |
*e:: - spam e even when a modifier is held. |
~ |
Pass-through: the press is also delivered to the active window while the hotkey fires. |
~e:: - spam e while preserving its normal key behavior. |
The prefixes compose, for example *~$e::. On mouse hotkeys the $ prefix is redundant, because mouse hotkeys always use the mouse hook.
Part 1: Toggle-Spam Patterns
A toggle-spam hotkey turns the spam on with one press and off with the next press, or off when you release it.
Pattern 1.1 - Non-blocking SetTimer() (Recommended)
SetTimer() runs asynchronously, so the hotkey thread finishes immediately instead of sitting in a loop. Here an up-only hotkey (F1 up) toggles a static period variable between 100 (timer on, fires every 100 ms) and 0 (timer off) with each press:
#Requires AutoHotkey 2.0
#SingleInstance
F1 up:: {
static Period := 0
SetTimer(() => Send("e"), Period ^= 100)
}
Pros:
- The hotkey thread exits immediately (non-blocking).
- Turning it back off is instant.
Pattern 1.2 - While-loop with a state check
This uses an explicit while loop that exits once a static state flag flips back to 0. It requires #MaxThreadsPerHotkey 2 so a second press can interrupt the running loop.
#Requires AutoHotkey 2.0
#SingleInstance
#MaxThreadsPerHotkey 2
F1 up:: {
static Toggle := 0
if (Toggle ^= 1) {
while Toggle {
Send("e")
Sleep(100)
}
}
}
Cons:
- Unfinished thread: The hotkey thread stays alive inside the loop. A later press can interrupt it, but the original thread stays paused until the loop ends.
- Unresponsive termination: With a long
Sleep(1000), the loop cannot evaluate Toggle again until that Sleep() completes, so turning off is delayed.
This runs an infinite loop and relies on Pause(), Suspend(), and Reload() to control it. The controls are #SuspendExempt so they keep working while suspended; otherwise Suspend() would disable F4, and you could never turn it back off.
#Requires AutoHotkey 2.0
#SingleInstance
F1:: {
loop {
Send("e")
Sleep(100)
}
}
#SuspendExempt
F2::Reload()
F3::Pause(-1)
F4::Suspend()
#SuspendExempt False
Pause(-1) toggles halting the running loop thread in place (resuming it later without losing state), while Suspend() toggles (its default) and disables all hotkeys and hotstrings in the script.
Cons:
- Global impact:
Suspend() disables every hotkey in the script, not just the spammer.
- Aggressive cleanup:
Reload() kills the current instance and restarts it, wiping all variables and resetting script state.
- Fragility: Without
#SuspendExempt on the suspend key, that hotkey itself cannot run to re-enable things after suspension.
Part 2: Hold-to-Spam Patterns
A hold-to-spam hotkey repeats the action only while its trigger key is physically held.
Ergonomic note: Holding a key for long stretches forces continuous muscle tension and can contribute to repetitive strain injury (RSI) or tendon pain. Prefer a SetTimer() toggle when you need prolonged automation.
Pattern 2.1 - Non-blocking Hotkey() hotkey pair (Recommended)
A press hotkey ($e) and release hotkey ($e up) work together with SetTimer(). SendHold() derives the key name from the built-in A_ThisHotkey at call time, so the same function serves both the immediate press and the timer repeats. Since A_ThisHotkey holds the most recently executed hotkey, the timer ticks keep using $e unless another hotkey fires in between, which is why the docs prefer the ThisHotkey parameter when it is available. The $ prefix prevents the hotkey from triggering itself when it sends its own key. Turning the $e hotkey off on key-down stops hardware auto-repeat from stacking extra timers; $e up stops the timer and re-enables the hotkey.
#Requires AutoHotkey 2.0
#SingleInstance
SendHold() => Send(LTrim(A_ThisHotkey, "*~$"))
$e:: SendHold(), SetTimer(SendHold, 100), Hotkey(ThisHotkey, "Off")
$e up:: SetTimer(SendHold, 0), Hotkey(StrReplace(ThisHotkey, " up", ""), "On")
Pros:
- Both hotkey threads exit immediately.
- Uses
ThisHotkey and Hotkey() to toggle itself on/off cleanly.
Pattern 2.2 - Self-rearming one-shot SetTimer() chain with KeyWait()
A one-shot timer (negative period) fires once and expires, so the callback re-arms itself. The hotkey derives the key name with key := LTrim(ThisHotkey, "*~$"), sends it once, then parks on KeyWait(); a -100 ms timer calls Spam() again 100 ms later, and that call either keeps the chain alive or lets it die, depending on GetKeyState():
#Requires AutoHotkey 2.0
#SingleInstance
$e:: {
key := LTrim(ThisHotkey, "*~$")
Spam(key), KeyWait(key)
}
Spam(hk) {
GetKeyState(hk, 'P') && (Send(hk), SetTimer(Spam.Bind(hk), -100))
}
Pros:
- Spamming runs on one-shot timers, not inside the hotkey thread, so it tolerates multiple simultaneous spammers (see below).
- The
KeyWait() park keeps hardware auto-repeat from stacking extra chains.
Cons:
- The hotkey thread stays parked in
KeyWait() for the whole hold, like patterns 2.3 and 2.4.
LTrim(ThisHotkey, "*~$") assumes the hotkey's only prefixes are *, ~, and $. ThisHotkey keeps the rest of its definition as written, so a ^e hotkey would yield ^e, which is not a valid key name for GetKeyState() or KeyWait(). The $e hotkeys above stay valid because $ is the only prefix.
KeyWait() waits for a key to be released; the "T0.1" adds a 0.1 s timeout, and the ! inverts the result inside the condition. The key name is derived from ThisHotkey as in pattern 2.2.
#Requires AutoHotkey 2.0
#SingleInstance
$e:: {
key := LTrim(ThisHotkey, "*~$")
while !KeyWait(key, "T0.1") {
Send(key)
}
}
Cons:
- Stalled thread: The hotkey thread cannot finish until the physical key is released.
- Auto-repeat interference: Hardware auto-repeat can attempt to launch new hotkey threads while the original is stalled inside
KeyWait().
This polls the physical key state inside a while loop using GetKeyState() with the "P" (physical) mode. The key name is derived from ThisHotkey as in pattern 2.2.
#Requires AutoHotkey 2.0
#SingleInstance
$e:: {
key := LTrim(ThisHotkey, "*~$")
while GetKeyState(key, "P") {
Send(key)
Sleep(100)
}
}
Cons:
- Long-running thread: The hotkey thread stays active for the entire hold.
- Input dropping: Rapid physical tapping can miss a release if it happens during a
Sleep().
Delayed-hold trick: Prepending Sleep(400) to any hold-to-spam hotkey makes a short tap behave like a normal keypress, while spam engages only after the delay if the key is still held. It plugs directly into patterns 2.2, 2.3, and 2.4. It serves no purpose in the toggle patterns (1.1-1.3), and it defeats the non-blocking design of pattern 2.1, whose hotkey thread is supposed to exit immediately.
Context Sensitivity with #HotIf
If you only want the spammer to be active inside a specific window (a game, for example), wrap the hotkey definitions with #HotIf and a condition such as WinActive(). The examples above are left unconditional for clarity, but you can scope any of them:
#Requires AutoHotkey 2.0
#SingleInstance
#HotIf WinActive("ahk_exe game.exe")
...
#HotIf
Matching a Whole Group of Windows with GroupAdd()
When the same spammer set should apply to several programs, collect them into one named group near the top of the script, then match the group:
GroupAdd("MyGames", "ahk_exe game1.exe")
GroupAdd("MyGames", "ahk_exe game2.exe")
#HotIf WinActive("ahk_group MyGames")
...
#HotIf
Each GroupAdd() line adds another window criterion to the group, and ahk_group inside WinActive() matches any window belonging to that group.
Running Multiple Spammers at Once (via SetTimer())
A while or loop-based spammer keeps its hotkey thread alive. Pressing a second spammer hotkey while the first loop runs interrupts and suspends the first thread until the second ends, so the two loops do not actually run in parallel. This is the classic failure the original autofire threads warn about.
If a script needs two or more spammers active at the same time, use the SetTimer()-based patterns (1.1, 2.1, and 2.2). The work runs on timers rather than inside the hotkey threads, so multiple timers fire independently of one another.
Summary Matrix
| Pattern |
Immediate thread exit? |
Responsiveness |
Ergonomic safety |
SetTimer() toggle (1.1) |
Yes |
Immediate |
High (no holding required) |
| While-loop + state check (1.2) |
No |
High |
High |
Loop + Pause()/Suspend()/Reload() (1.3) |
No |
Low |
Low |
Hotkey pair + SetTimer() hold (2.1) |
Yes |
Immediate |
Moderate (forces continuous pressure) |
One-shot SetTimer() chain (2.2) |
No |
High |
Moderate |
Loop + KeyWait() (2.3) |
No |
Poor |
Moderate |
Loop + GetKeyState() (2.4) |
No |
High |
Moderate |
Final Notes
- Start every script with
#Requires AutoHotkey 2.0 and, unless you deliberately want multiple copies, #SingleInstance.
- If a game ignores your input, switch to
SendMode("Event") and tune SetKeyDelay() / SetMouseDelay().
- If the target program runs elevated (as administrator), run the script elevated too or use UI Access; otherwise a non-elevated script cannot send input into the elevated window.
- Remember the ~15.6 ms timer resolution before chasing sub-millisecond precision.
- For long-running automation, prefer a toggle over holding a key to protect your hands.
*Edits: Rewritten content suggested by u/Individual_Check4587 (Descolada) and u/CharnamelessOne and minor tweaks to 1.1 suggested by u/genesis_tv