A rate-limit counter that gets its expiry after the first increment is one dropped connection away from a permanent lockout.
The usual shape is INCR, then EXPIRE. Two problems live in that gap.
First, failure. If the connection dies between the two commands, you are left with a counter that never expires. The user stays blocked until someone notices and deletes the key by hand.
Second, semantics. If EXPIRE runs on every increment, each new attempt pushes the deadline further out. That is not a fixed window anymore: a persistent caller can keep the window open indefinitely, which is the opposite of what the limit was for.
In our open-source project otpguard, the Redis adapter inverts the order. Before the counter exists, it writes the key with value zero using SET with NX and PX, so the lifetime travels with the creating write, and only then increments.
NX is the part that carries the meaning. The write lands only for whoever creates the key, so a later increment cannot move the deadline. The window stays anchored to the first increment.
Two smaller decisions came with it. A non-positive window is rejected before any command reaches the client, instead of quietly becoming an instant expiry. And a counter with no TTL is an explicit choice at the call site, not something you get by forgetting a line.
We also wrote the rule into the storage contract itself, so the in-memory backend keeps the existing deadline instead of refreshing it. Otherwise tests pass against memory and the limit behaves differently in production.
The general lesson we keep relearning in OTP, payment and compliance flows: when a counter guards access to money or accounts, its expiry behaviour is a correctness property, not a tuning detail. Decide the invariant, then make every backend honour it.
If you have needed a fixed window, you probably landed on SET NX PX plus INCR, or you moved the whole thing into a Lua script or a sorted set. We would like to know what pushed that decision.