Logging a new row in your database every time a user clicks a button is a great way to tank your database. If you have millions of users, tracking Daily Active Users (DAU) this way will quickly eat up your storage and leave you with massive, sluggish tables. Let’s build a lightning-fast, memory-efficient DAU tracker using Redis Bitmaps and Node.js instead.

Why Traditional Databases Choke on DAU Tracking
The first time I built a user analytics dashboard, I did the obvious thing: I created a user_logins table in PostgreSQL. Every time a user loaded the app, I inserted a row with their ID and a timestamp. It worked perfectly. For about a week.
Then we ran a marketing campaign. Traffic spiked, our database CPU pegged at 98%, and everything ground to a halt. The database was spending all its time writing duplicate login rows and scanning millions of records just to count unique users for the day. It was a massive waste of disk space and compute power.
If you have 1 million active users and log their activity daily in a traditional SQL database, you’re writing 30 million rows a month. That’s gigabytes of storage just to answer a simple question: “How many unique people used our app today?” We need a faster, lighter approach.
How Redis Bitmaps Save Megabytes of Memory
Redis Bitmaps aren’t actually a separate data type. They’re just an extension of the string type that lets you treat a string like an array of bits. Each bit can be a 0 or a 1. You can read more about how this works in the official Redis Bitmaps documentation.
Think of a bitmap as a giant array of checkboxes where each checkbox represents a user ID. If user 42 logs in, we go to the 42nd checkbox (offset 42) and check it (set it to 1). If they log in again five minutes later, we check it again. It stays 1.
Because a single bit represents one user, the memory footprint is ridiculously small. One byte has 8 bits, meaning we can track 8 users in a single byte. To track 1 million unique users, we only need about 125 kilobytes of RAM. That’s a rounding error on your infrastructure bill.
We can also run lightning-fast bitwise operations across multiple days. Want to find out who logged in on both Monday and Tuesday? Run an AND operation on the two bitmaps. It takes microseconds.
Setting Up the Node.js Project
Let’s get a clean Node.js environment running. I’m using Node.js v20.11.0, but any modern version works. First, spin up a new directory and initialize it.
Run these commands in your terminal to get started:
mkdir redis-dau-tracker
cd redis-dau-tracker
npm init -y
npm install redis express dotenv
You’ll also need a running Redis instance. If you have Docker installed, you can spin up a local Redis container with a single command:
docker run -d --name local-redis -p 6379:6379 redis:alpine
Now, create an index.js file. We’ll use the official Node Redis client to connect to our local server and manage our connection pool.
Tracking User Activity in Real-Time
To track a user, we need to map their user ID to a bit offset. For now, let’s assume your users have auto-incrementing integer IDs (like 1, 2, 3). We’ll handle non-integer IDs like UUIDs in a minute.
We’ll write a helper function that generates a Redis key based on the current date, then sets the bit at the user’s offset to 1 using the SETBIT command.
Here’s the code to initialize the Redis client and track a login:
const redis = require('redis');
const client = redis.createClient({ url: 'redis://localhost:6379'
}); client.on('error', (err) => console.error('Redis Client Error', err)); async function connectRedis() { if (!client.isOpen) { await client.connect(); }
} function getDailyKey(dateString) { return `active_users:${dateString}`;
} async function trackUserActivity(userId, dateString) { await connectRedis(); const key = getDailyKey(dateString); // SETBIT returns the original value of the bit (0 or 1) const previousState = await client.setBit(key, userId, 1); return previousState;
}
If you’re already using Redis for session storage, you can drop this helper right into your authentication middleware. If you need to set up sessions first, check out my guide on how to store Express sessions in Redis.
Calculating Daily and Monthly Active Users
Counting active users for a specific day is incredibly fast. Redis has a native BITCOUNT command that counts the number of bits set to 1. It runs in O(N) time, where N is the number of bytes. For 1 million users, this takes less than a millisecond.
But what if you want Weekly Active Users (WAU) or Monthly Active Users (MAU)? That’s where the BITOP command comes in. It lets you perform bitwise operations (AND, OR, XOR, NOT) across multiple keys and save the result in a new key.
Here is how we can implement both daily counts and multi-day range aggregation:
async function getDailyActiveCount(dateString) { await connectRedis(); const key = getDailyKey(dateString); const count = await client.bitCount(key); return count;
} async function getActiveUsersForRange(dateStrings, destinationKey) { await connectRedis(); const keys = dateStrings.map(date => getDailyKey(date)); // Perform an OR operation to combine all active users across the dates await client.bitOp(redis.commandOptions({}), 'OR', destinationKey, ...keys); const totalUniqueCount = await client.bitCount(destinationKey); // Set an expiry on the temporary destination key so we don't leak memory await client.expire(destinationKey, 300); return totalUniqueCount;
}
If your app gets a lot of traffic, you don’t want to run these aggregations on every single page load. Cache these counts to keep your dashboard fast. Take a look at how to cache API responses with Redis in Node.js to keep things snappy.
Handling High Concurrency and Offsets
Here’s where things blew up when I first launched this in production. Bitmaps require integer offsets. If your database uses UUIDs (like f81d4fae-7dec-11d0-a765-00a0c91e6bf6), you can’t use them as offsets directly.
If you try to convert a hash of a UUID into an integer, you might end up with a massive number like 4,294,967,295. If you set a bit at offset 4 billion, Redis will immediately allocate 512 megabytes of memory just to pad the string with leading zeros. Do this a few times and your Redis instance will crash instantly from running out of RAM.
To fix this, we need to map our non-integer user IDs to sequential, auto-incrementing integers. We can use a Redis Hash to store this mapping. When a user logs in, we check if they have an integer ID. If not, we increment a global counter and save the mapping.
Here’s the mapping logic that prevents memory bloat:
async function getOrCreateUserOffset(uuid) { await connectRedis(); const mappingKey = 'user_id_map'; const counterKey = 'global_user_id_counter'; // Check if mapping already exists let offset = await client.hGet(mappingKey, uuid); if (offset === null) { // Increment counter to get a new unique sequential integer const newOffset = await client.incr(counterKey); // Store the mapping in both directions if needed, or just UUID -> Offset await client.hSet(mappingKey, uuid, newOffset.toString()); offset = newOffset; } else { offset = parseInt(offset, 10); } return offset;
}
This keeps your bitmap offsets dense and sequential, keeping your memory usage at an absolute minimum. If you’re processing these logins via incoming webhooks, you might also want to protect your system from duplicate events by learning how to prevent duplicate webhook processing with Redis.
Simulating Production Traffic
Let’s put everything together into a working Express app. We’ll build an endpoint to log user activity, and another to fetch active user stats for the last 3 days.
Save this code as app.js and run it with node app.js:
const express = require('express');
const redis = require('redis'); const app = express();
app.use(express.json()); const client = redis.createClient({ url: 'redis://localhost:6379' });
client.connect().catch(console.error); const getDailyKey = (dateStr) => `active_users:${dateStr}`; app.post('/track', async (req, res) => { const { userId, date } = req.body; if (!userId || !date) { return res.status(400).json({ error: 'Missing userId or date' }); } try { // Set the bit at the offset of userId await client.setBit(getDailyKey(date), userId, 1); res.json({ success: true, message: `Tracked user ${userId} on ${date}` }); } catch (err) { res.status(500).json({ error: err.message }); }
}); app.get('/stats', async (req, res) => { const { dates } = req.query; // Expects comma-separated dates, e.g., ?dates=2024-04-01,2024-04-02 if (!dates) { return res.status(400).json({ error: 'Missing dates parameter' }); } const dateList = dates.split(','); const tempDestKey = `temp_range:${Date.now()}`; try { const dailyCounts = {}; for (const date of dateList) { dailyCounts[date] = await client.bitCount(getDailyKey(date)); } const keys = dateList.map(date => getDailyKey(date)); await client.bitOp(redis.commandOptions({}), 'OR', tempDestKey, ...keys); const uniqueRangeCount = await client.bitCount(tempDestKey); await client.del(tempDestKey); res.json({ dailyCounts, uniqueRangeCount }); } catch (err) { res.status(500).json({ error: err.message }); }
}); app.listen(3000, () => { console.log('Server running on port 3000');
});
To test this setup, fire off a few POST requests using curl or any API client. Here are the commands I ran to verify the system:
curl -X POST http://localhost:3000/track -H "Content-Type: application/json" -d '{"userId": 5, "date": "2024-10-24"}'
curl -X POST http://localhost:3000/track -H "Content-Type: application/json" -d '{"userId": 12, "date": "2024-10-24"}'
curl -X POST http://localhost:3000/track -H "Content-Type: application/json" -d '{"userId": 12, "date": "2024-10-25"}'
Now, query the stats endpoint to see the unique daily counts and the aggregated total across both days:
curl "http://localhost:3000/stats?dates=2024-10-24,2024-10-25"
The response shows that while user 12 logged in on both days, they’re only counted once in the aggregated range count. That’s the beauty of bitwise OR operations.
Frequently Asked Questions
What is the maximum offset size allowed in Redis Bitmaps?
Redis strings (and Bitmaps) are capped at 512 megabytes. That gives you 2^32 bits, meaning you can track up to 4.29 billion unique user IDs. Unless you’re scaling past Facebook’s size, you won’t hit this limit.
How do I handle negative user IDs?
You can’t use negative integers as offsets in Redis Bitmaps—they have to start at 0. If your database uses negative IDs, you’ll need to map them to positive integers first using a mapping hash or some simple offset-shifting math before calling SETBIT.
Is there a risk of high memory usage if my IDs are sparse?
Absolutely. If you only have two users with IDs 1 and 1,000,000, Redis still allocates enough memory to hold all 1,000,000 bits (about 125 KB), even though you’re only using two of them. Keep your IDs sequential to avoid wasting RAM.
Can I use Bitmaps for real-time event streaming?
Not really. Bitmaps are great for tracking binary state (active/inactive), but they can’t hold event metadata like page URLs or purchase amounts. If you’re building a high-volume event pipeline, you should use Redis Streams to build a real-time event processor instead.
Next Steps
Now that you have a highly optimized active user tracking system up and running, you can start expanding your Redis-based analytics toolkit. If you want to protect your tracking endpoints from abuse, check out our guide on building a Redis Sliding Window Rate Limiter in Node.js.

