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.
Integers stored as strings
Section titled “Integers stored as strings”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 and DECR
Section titled “INCR and DECR”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.
127.0.0.1:6379> SET pageviews:home 0OK127.0.0.1:6379> INCR pageviews:home(integer) 1127.0.0.1:6379> INCR pageviews:home(integer) 2127.0.0.1:6379> INCR pageviews:home(integer) 3127.0.0.1:6379> DECR pageviews:home(integer) 2127.0.0.1:6379> DECR pageviews:home(integer) 1127.0.0.1:6379> DECR pageviews:home(integer) 0127.0.0.1:6379> DECR pageviews:home(integer) -1127.0.0.1:6379> INCR views:new(integer) 1SET pageviews:home 0
INCR pageviews:home
INCR pageviews:home
INCR pageviews:home
DECR pageviews:homeINCRBY and DECRBY
Section titled “INCRBY and DECRBY”When you need to add or subtract values other than 1, use INCRBY and DECRBY:
127.0.0.1:6379> SET score:player1 100OK127.0.0.1:6379> INCRBY score:player1 50(integer) 150127.0.0.1:6379> DECRBY score:player1 30(integer) 120127.0.0.1:6379> INCRBY score:player1 -10(integer) 110SET score:player1 100
INCRBY score:player1 50
DECRBY score:player1 30
INCRBY score:player1 -10INCRBYFLOAT
Section titled “INCRBYFLOAT”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:
127.0.0.1:6379> SET temperature 22.5OK127.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.1OK127.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 temperatureWhat INCR refuses
Section titled “What INCR refuses”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:
127.0.0.1:6379> SET big 9223372036854775806OK127.0.0.1:6379> INCR big(integer) 9223372036854775807127.0.0.1:6379> INCR big(error) ERR increment or decrement would overflow127.0.0.1:6379> SET word helloOK127.0.0.1:6379> INCR word(error) ERR value is not an integer or out of range127.0.0.1:6379> SET dec 3.5OK127.0.0.1:6379> INCR dec(error) ERR value is not an integer or out of range127.0.0.1:6379> SET pad 007OK127.0.0.1:6379> INCR pad(error) ERR value is not an integer or out of range127.0.0.1:6379> SET t 5 EX 100OK127.0.0.1:6379> INCR t(integer) 6127.0.0.1:6379> TTL t(integer) 100127.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).
Real-world pattern: page-view counter
Section titled “Real-world pattern: page-view counter”Because INCR initialises a missing key to 0 before incrementing, you do not need a SET 0 first:
127.0.0.1:6379> INCR views:article:42(integer) 1127.0.0.1:6379> INCR views:article:42(integer) 2127.0.0.1:6379> INCR views:article:42(integer) 3127.0.0.1:6379> GET views:article:42"3"INCR views:article:42
INCR views:article:42
INCR views:article:42
GET views:article:42Is 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:
// @runimport { 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 lostawait 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 notawait 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:
127.0.0.1:6379> INCR ratelimit:ip:203.0.113.5(integer) 1127.0.0.1:6379> EXPIRE ratelimit:ip:203.0.113.5 60 NX(integer) 1127.0.0.1:6379> INCR ratelimit:ip:203.0.113.5(integer) 2127.0.0.1:6379> EXPIRE ratelimit:ip:203.0.113.5 60 NX(integer) 0127.0.0.1:6379> TTL ratelimit:ip:203.0.113.5(integer) 60In 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:
// @runimport { 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 TTLconsole.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.
Reading AI-generated code
Section titled “Reading AI-generated code”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); // atomicif (n === 1) await redis.expire(key, 60); // window starts on the first hitreturn 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.
Mental Model
Section titled “Mental Model”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.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
Use INCR/INCRBY for a counter in Redis | Atomic by default, no application-side locking, handles high throughput | The 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 counters | If 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-write | Avoids the race condition, same as INCR | No DECRBYFLOAT; binary floating point can accumulate rounding errors over many additions — use integer cents for money |
| Counter in Redis vs. only in the database | Millions of increments per second cheaply, real-time reads | Redis is not your ledger: durability depends on persistence config, so flush it back to a database periodically |
Common Misconceptions
Section titled “Common Misconceptions”- “
INCRneeds the key to exist.” It does not; a missing key starts from 0. - “
INCRthenEXPIREis atomic because both are.” Two atomic commands are two turns; another client (or a crash) can land between them. - “
INCRresets the TTL likeSETdoes.” It leaves the TTL untouched. - “
INCRBYFLOATis exact decimal arithmetic.” It prints nicely (0.3) but is still a binary float underneath.
Common mistakes
Section titled “Common mistakes”- Reading the current value with
GET, adding to it locally, and writing it back withSET— this opens the door to the classic race condition when multiple clients write concurrently. Always useINCR/INCRBYfor atomicity. - Setting
EXPIREonly whenINCRreturned 1 — a crash after the increment leaves a counter with no TTL. UseEXPIRE key ttl NXon every hit in the sameMULTI. - Assuming
INCRrequires the key to already exist — it doesn’t.INCRautomatically initializes a missing key to0before incrementing; there is no need toSET key 0first.
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).