Skip to content

Counters

Verified on Redis 8.10.2 (redis:8.10.2-alpine), ioredis 6.0.0, Node 24, Apple-silicon Mac. The # cli/... transcripts are one session in document order, replayed by the course harness.

Redis stores everything as a string, but it recognises when a string is a valid integer (or float) and lets you do arithmetic on it with dedicated commands. The key insight is that these operations are atomic — Redis executes each one as a single, uninterruptible step.

INCR key increments the integer value by 1. DECR key decrements by 1. If the key does not exist, Redis treats the missing value as 0 before applying the operation. There is no floor at zero: DECR happily goes negative.

cli/keys-strings-counters-1.redis
127.0.0.1:6379> SET pageviews:home 0
OK
127.0.0.1:6379> INCR pageviews:home
(integer) 1
127.0.0.1:6379> INCR pageviews:home
(integer) 2
127.0.0.1:6379> INCR pageviews:home
(integer) 3
127.0.0.1:6379> DECR pageviews:home
(integer) 2
127.0.0.1:6379> DECR pageviews:home
(integer) 1
127.0.0.1:6379> DECR pageviews:home
(integer) 0
127.0.0.1:6379> DECR pageviews:home
(integer) -1
127.0.0.1:6379> INCR views:new
(integer) 1
SET pageviews:home 0
INCR pageviews:home
INCR pageviews:home
INCR pageviews:home
DECR pageviews:home

When you need to add or subtract values other than 1, use INCRBY and DECRBY:

cli/keys-strings-counters-2.redis
127.0.0.1:6379> SET score:player1 100
OK
127.0.0.1:6379> INCRBY score:player1 50
(integer) 150
127.0.0.1:6379> DECRBY score:player1 30
(integer) 120
127.0.0.1:6379> INCRBY score:player1 -10
(integer) 110
SET score:player1 100
INCRBY score:player1 50
DECRBY score:player1 30
INCRBY score:player1 -10

For floating-point arithmetic (prices, averages, sensor data), use INCRBYFLOAT. There is no DECRBYFLOAT — just pass a negative delta. The reply is a string (a bulk reply), not an integer:

cli/keys-strings-counters-3.redis
127.0.0.1:6379> SET temperature 22.5
OK
127.0.0.1:6379> INCRBYFLOAT temperature 1.3
"23.8"
127.0.0.1:6379> INCRBYFLOAT temperature -5.0
"18.8"
127.0.0.1:6379> GET temperature
"18.8"
127.0.0.1:6379> SET f 0.1
OK
127.0.0.1:6379> INCRBYFLOAT f 0.2
"0.3"
127.0.0.1:6379> INCRBYFLOAT nokey 5
"5"

Look at 0.1 + 0.2 = "0.3". Redis computes with a C long double and prints the result with a fixed precision of 17 digits after the decimal point, trimming trailing zeros (INCRBYFLOAT docs), so the classic binary-float artefact (0.30000000000000004 in JavaScript) does not show in the reply. That is display, not exactness: the value is still a binary float, and many small additions can drift. For money, count integer cents with INCRBY.

SET temperature 22.5
INCRBYFLOAT temperature 1.3
INCRBYFLOAT temperature -5.0
GET temperature

INCR only works on a string that is a canonical signed 64-bit integer. Everything else is an error, and the error is worth recognising in production logs:

cli/keys-strings-counters-4.redis
127.0.0.1:6379> SET big 9223372036854775806
OK
127.0.0.1:6379> INCR big
(integer) 9223372036854775807
127.0.0.1:6379> INCR big
(error) ERR increment or decrement would overflow
127.0.0.1:6379> SET word hello
OK
127.0.0.1:6379> INCR word
(error) ERR value is not an integer or out of range
127.0.0.1:6379> SET dec 3.5
OK
127.0.0.1:6379> INCR dec
(error) ERR value is not an integer or out of range
127.0.0.1:6379> SET pad 007
OK
127.0.0.1:6379> INCR pad
(error) ERR value is not an integer or out of range
127.0.0.1:6379> SET t 5 EX 100
OK
127.0.0.1:6379> INCR t
(integer) 6
127.0.0.1:6379> TTL t
(integer) 100
127.0.0.1:6379> OBJECT ENCODING t
"int"

The maximum is 2^63 − 1 = 9223372036854775807; the next INCR fails without changing the key. A decimal string (3.5), a padded one (007) or text is refused (INCRBYFLOAT accepts 3.5). And INCR keeps the TTL of the key it modifies — unlike SET. The value lives internally as a 64-bit integer (OBJECT ENCODING is int).

Because INCR initialises a missing key to 0 before incrementing, you do not need a SET 0 first:

cli/keys-strings-counters-5.redis
127.0.0.1:6379> INCR views:article:42
(integer) 1
127.0.0.1:6379> INCR views:article:42
(integer) 2
127.0.0.1:6379> INCR views:article:42
(integer) 3
127.0.0.1:6379> GET views:article:42
"3"
INCR views:article:42
INCR views:article:42
INCR views:article:42
GET views:article:42

Is INCR really atomic? 10,000 racing increments

Section titled “Is INCR really atomic? 10,000 racing increments”

Twenty separate connections each fire 500 INCRs at the same key at the same time. If INCR were read-then-write, the total would come out short:

src/keys-strings-counters-hits.ts
// @run
import { Redis } from 'ioredis';
const url = process.env.REDIS_URL!;
const clients = Array.from({ length: 20 }, () => new Redis(url));
const admin = clients[0];
await admin.del('b1:hits');
// 20 connections x 500 INCR each, all racing: exactly 10000, nothing lost
await Promise.all(clients.map(async (c) => { for (let i = 0; i < 500; i++) await c.incr('b1:hits'); }));
console.log('INCR total:', await admin.get('b1:hits')); // "10000"
// INCRBYFLOAT: Redis prints 0.3, JavaScript doubles do not
await admin.set('b1:f', '0.1');
console.log('Redis 0.1+0.2 =', await admin.incrbyfloat('b1:f', 0.2), '| JS 0.1+0.2 =', 0.1 + 0.2);
await admin.del('b1:hits', 'b1:f');
clients.forEach((c) => c.disconnect());

Real output: INCR total: 10000 and Redis 0.1+0.2 = 0.3 | JS 0.1+0.2 = 0.30000000000000004. Exactly 10,000, no loss. Compare the GET-then-SET version from the What is Redis lesson, which lost 49 of 50.

Real-world pattern: rate limiter (and its trap)

Section titled “Real-world pattern: rate limiter (and its trap)”

Track how many requests an IP has made within a window: INCR a per-IP key, and make sure the window expires. The obvious version is two commands:

cli/keys-strings-counters-6.redis
127.0.0.1:6379> INCR ratelimit:ip:203.0.113.5
(integer) 1
127.0.0.1:6379> EXPIRE ratelimit:ip:203.0.113.5 60 NX
(integer) 1
127.0.0.1:6379> INCR ratelimit:ip:203.0.113.5
(integer) 2
127.0.0.1:6379> EXPIRE ratelimit:ip:203.0.113.5 60 NX
(integer) 0
127.0.0.1:6379> TTL ratelimit:ip:203.0.113.5
(integer) 60

In application code you check: if the result of INCR exceeds your limit (e.g., 100), reject the request. The key auto-deletes after 60 seconds, resetting the window. Note the flag on EXPIRE: NX (Redis 7.0+) sets the timeout only if the key has none. The second EXPIRE returned 0 and left the window alone — without NX every request would push the deadline forward and an active client would never be released.

The trap: INCR and EXPIRE are two commands, not one. Each is atomic; the pair is not. If the process dies (or the network drops) between them on the first request, the counter exists with no TTL and never resets — that IP is blocked forever. We reproduced it exactly:

src/keys-strings-counters-window.ts
// @run
import { Redis } from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);
const key = 'b1:rl:203.0.113.5';
await redis.del(key);
// BUGGY: INCR and EXPIRE are two commands. If the process dies between them ...
async function hitBuggy(crashAfterIncr: boolean) {
const n = await redis.incr(key);
if (crashAfterIncr) throw new Error('process died between INCR and EXPIRE');
if (n === 1) await redis.expire(key, 60);
return n;
}
await hitBuggy(true).catch(() => {});
await hitBuggy(false); // the next request sees n === 2, so it never sets the TTL
console.log('buggy: count', await redis.get(key), 'ttl', await redis.ttl(key)); // ttl -1 = blocked forever
// FIXED: EXPIRE ... NX on every hit, in one MULTI. NX only sets a TTL when there is none,
// so it cannot extend the window, and it repairs a key that lost its TTL.
async function hit() {
const res = await redis.multi().incr(key).expire(key, 60, 'NX').exec();
return res![0][1] as number;
}
console.log('fixed: count', await hit(), 'ttl', await redis.ttl(key)); // ttl 60
await redis.del(key);
await redis.quit();

Output: buggy: count 2 ttl -1 and fixed: count 3 ttl 60. The fix does two things: the MULTI/EXEC sends both commands as one unit, and EXPIRE ... NX on every hit (not just when n === 1) makes it self-healing: a key that somehow lost its TTL gets one on the next request. The Lua-script form of the same guard is in the Rate Limiting lesson.

An assistant writes an API rate limiter and adds a reassuring comment:

🐛 Spot the bug

async function allow(ip: string): Promise<boolean> {
const key = 'rl:' + ip;
const n = await redis.incr(key); // atomic
if (n === 1) await redis.expire(key, 60); // window starts on the first hit
return n <= 100;
}
Show the answer

Each command is atomic, but the function is not. If the process crashes, the connection resets, or the request is aborted after INCR returns 1 and before EXPIRE runs, the key has no TTL. Every later call sees n > 1, skips EXPIRE, and once n passes 100 the client is rejected forever. We reproduced it: after a simulated crash between the two commands, TTL was -1 and stayed there. The fix (shown above) is to call EXPIRE key 60 NX on every hit, inside MULTI/EXEC (or one Lua script), so the TTL is always repaired.

A Redis counter is an integer with a lock-free “add” button: pressing it is one indivisible step, however many people press at once. What the button does not do is remember to set the alarm clock (TTL) — that is a second, separate press, and any pair of presses can be interrupted between them. Whenever a counter needs a lifetime, fuse the two: EXPIRE ... NX after every increment, a MULTI, or a script.

ChoiceBenefitCost
Use INCR/INCRBY for a counter in RedisAtomic by default, no application-side locking, handles high throughputThe value lives in a single key — no history or audit trail like a database table would give you
Attach EXPIRE to a counter (rate limiter)Free rolling window; no cleanup job needed to delete old countersIf INCR and EXPIRE are not fused (MULTI, NX on every hit, or Lua), a crash between them makes the counter permanent
Use INCRBYFLOAT instead of client-side read-modify-writeAvoids the race condition, same as INCRNo DECRBYFLOAT; binary floating point can accumulate rounding errors over many additions — use integer cents for money
Counter in Redis vs. only in the databaseMillions of increments per second cheaply, real-time readsRedis is not your ledger: durability depends on persistence config, so flush it back to a database periodically
  • “INCR needs the key to exist.” It does not; a missing key starts from 0.
  • “INCR then EXPIRE is atomic because both are.” Two atomic commands are two turns; another client (or a crash) can land between them.
  • “INCR resets the TTL like SET does.” It leaves the TTL untouched.
  • “INCRBYFLOAT is exact decimal arithmetic.” It prints nicely (0.3) but is still a binary float underneath.
  • Reading the current value with GET, adding to it locally, and writing it back with SET — this opens the door to the classic race condition when multiple clients write concurrently. Always use INCR/INCRBY for atomicity.
  • Setting EXPIRE only when INCR returned 1 — a crash after the increment leaves a counter with no TTL. Use EXPIRE key ttl NX on every hit in the same MULTI.
  • Assuming INCR requires the key to already exist — it doesn’t. INCR automatically initializes a missing key to 0 before incrementing; there is no need to SET key 0 first.

Real-world example

GitHub has documented using Redis-backed counters (INCR + EXPIRE) for API rate limiting, alongside other transient/cached data, before later migrating some of that persistent counter workload to a MySQL-backed store as they scaled (Moving persistent data out of Redis, GitHub Engineering, 2015 — a decade-old account).

What value does INCR use when the key does not exist yet?
Which command increments a floating-point value?
Why is INCR safe for concurrent counters?
How do you subtract 5 using INCRBYFLOAT?
SET big 9223372036854775807 then INCR big. What happens?
The rate limiter does INCR key then if (n === 1) EXPIRE key 60. What is the failure mode?