A Cross-Instance Distributed Lock: Solving Nonce Collisions in a Telegram Trading Bot

Two servers, both signing transactions for the same wallet at the same time, both reading the same nonce from the chain, both broadcasting — one transaction lands, the other gets rejected as a duplicate. That was the failure mode I had to kill on Pulsonic Trading Bot before it could scale past a single signer instance.
This article walks through the fix: a small, purpose-built distributed lock on top of Redis that serializes signing per wallet across any number of instances, while leaving unrelated wallets free to run in parallel.
The Problem: Nonce Collisions in a Telegram Trading Bot
What is a nonce in EVM blockchains?
On EVM blockchains like Ethereum, a transaction consists of several fields, one of which is the nonce. The nonce is a unique number that represents the count of transactions sent from a particular address. It serves two main purposes:
Ordering: The nonce ensures that transactions from the same address are processed in order. A transaction with nonce 9 won’t be mined until everything with nonces 0–8 has been mined.
Uniqueness: A nonce can only be used once. That’s what protects against replay attacks — each transaction can only be executed once.
Why do nonce collisions happen?
Multiple signer instances can pick up queued transactions for the same wallet at the same moment. Each reads the wallet’s nonce from the chain. Each gets the same number back. Both sign with that nonce and broadcast. The network accepts the first one it sees and rejects the second as a duplicate — the user’s trade silently dies.
Initial Solution attempts: BullMQ
The system already uses BullMQ for most background processing, so I looked there first. BullMQ only offers concurrency control at the queue level, not at the resource level.
That means I could set the signing queue’s concurrency to 1, but then every transaction from every user’s wallet would be processed one at a time. That was an easy pass.
The Solution: A Cross-Instance Distributed Lock with Redis
A signer instance acquires a Redis-backed lock keyed by the wallet address before signing. While that lock is held, any other instance that picks up work for the same wallet has to wait, reschedule the job, or move on to the next one in the queue. Other wallets keep running in parallel — only the wallet currently being signed is serialized.
Why Redis
A file lock or a database lock could work in theory, but I chose Redis for a few concrete reasons:
- The bot runs on two servers in different locations, which rules out file locks.
- We use MongoDB as the primary store. Transactions there are atomic, but writes serialize and that would hurt unrelated workloads. I also didn’t want my main database doubling as a lock manager.
- Redis was already in the stack for caching and streaming.
- Redis operations are atomic and have built-in TTLs.
SET key value NX PX ttlis one atomic command. - Lua scripts let me implement safe release and extend operations without round-trips.
- Redis is battle-tested for distributed locks (Redlock).
Why not Redlock?
I could have used Redlock.
I didn’t — partly because the problem didn’t require it, and partly because building things is how I understand them.
This lock was created to solve a very specific problem: serializing nonce usage across signer instances. The blockchain already enforces the final safety guarantees, so a simpler Redis-based lock with TTL and ownership checks is sufficient in practice.
Beyond that, implementing the lock myself made the failure modes explicit and easy to reason about. I knew exactly what could go wrong, why it was acceptable, and how the system would behave under stress or partial failure.
Sometimes the right choice isn’t the most complex or “official” solution — it’s the one you fully understand and can confidently operate.
Lock Requirements
The distributed lock implementation should have the following features:
- Acquire: A caller can acquire a lock on a resource with a specific TTL (to prevent deadlocks) and an optional max wait time (to prevent waiting forever).
- Release: An instance can release the lock it holds for a wallet once it’s done signing and broadcasting.
- Extend: An instance can extend the TTL while it’s still working. Without this, a slow signer could see its lock expire mid-broadcast, another instance could grab it, and we’re right back to nonce collisions.
- Safe: A lock can only be released by the owner that acquired it, or by reaching its TTL.
Implementation
My default move when building something new like this is to isolate it. Forget the nonce problem for a moment — I want to build the lock in a clean project, prove it works, and only then drop it into the bot.
# create a new directory
mkdir distributed-lock
# enter the directory
cd distributed-lock
# initialize a new npm project
npm init -y
# install typescript
npm install -D typescript @types/node
# initialize typescript configuration
npx tsc --init
# install redis client
npm install redis
# create a new directory
mkdir distributed-lock
# enter the directory
cd distributed-lock
# initialize a new npm project
pnpm init
# install typescript
pnpm install -D typescript @types/node
# initialize typescript configuration
pnpm exec tsc --init
# install redis client
pnpm install redis
# create a new directory
mkdir distributed-lock
# enter the directory
cd distributed-lock
# initialize a new npm project
yarn init -y
# install typescript
yarn add -D typescript @types/node
# initialize typescript configuration
npx tsc --init
# install redis client
yarn add redis
# create a new directory
mkdir distributed-lock
# enter the directory
cd distributed-lock
# initialize a new npm project
bun init -y
# install redis client
bun add redis
Open the folder in your favorite IDE, and let’s adjust the tsconfig.json and package.json.
// ....
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"sourceMap": false,
"declarationMap": false,
"types": ["node"],
},
//...
// ....
"type": "module",
"scripts": {
// ...
"start": "tsc && node ./dist/index.js"
},
//...
Let’s create index.ts and reproduce the race condition before fixing it.
The idea: simulate multiple instances reading and updating a nonce. A file holds the nonce for a wallet. We loop n times, each iteration reads the current value, increments it in memory, and writes it back. Run this script concurrently and the final value will be wrong.
import fs from 'fs';
const walletName = '0';
const nonceFileName = `nonce-${walletName}.txt`;
const incrementCount = 10;
function resetNonce() {
fs.writeFileSync(nonceFileName, '0');
}
resetNonce();
/*
simulate incrementing nonces for each wallet `n` times
each time we read the nonce (simulate RPC request getNonce())
and then increment in memory, and then save to file
*/
for (let i = 0; i < incrementCount; i++) {
let nonce = parseInt(fs.readFileSync(nonceFileName, 'utf-8'), 10);
nonce++;
fs.writeFileSync(nonceFileName, nonce.toString());
}
If we run this script once and then check the nonce-0.txt file, we will see the final nonce value is 10.
npx tsc && node ./dist/index.js && cat nonce-0.txt
The output will be 10.
Let’s install the concurrently package to run multiple instances of the script at the same time.
npm install -D concurrently
pnpm install -D concurrently
yarn add -D concurrently
bun add -D concurrently
And for convenience, we can add a start:many script to run two instances of the script at the same time.
"scripts": {
// ...
"start:many": "tsc && concurrently \"node ./dist/index.js\" \"node ./dist/index.js\""
},
Running the start:many script, we expect the final nonce value to be 20, but we will get a different value and probably even NaN.
npm run start:many && cat nonce-0.txt
Yep, NaN. Partial writes and concurrent truncation left the file in a state parseInt couldn’t parse. That’s the race condition we’re trying to solve.
Let’s create a docker-compose.yml file to start a Redis instance.
services:
redis:
image: redis:latest
ports:
- '6379:6379'
Start the Redis instance using Docker Compose.
docker compose up -d
Let’s create a Redis client to be used later by the lock implementation. Create a new file redisClient.ts.
import { createClient, type RedisClientType } from 'redis';
const client: RedisClientType = createClient({
url: `redis://localhost:6379`, // change with your host and port if needed
socket: {
connectTimeout: 5000
}
});
/*
If using an older version of Node that doesn't await top-level
Use something like
const redisClientPromise = client.connect();
Then whenever you need the client, do
const redisClient = await redisClientPromise;
*/
export const redisClient: RedisClientType = await client.connect();
The DistributedLock class needs at least three methods: acquireLock, releaseLock, and touch.
import type { RedisClientType } from 'redis';
import { redisClient } from './redisClient.js';
class DistributedLock {
// to avoid any key collisions
private static NAMESPACE: string = `cache-DistributedLock`;
private redisClient: RedisClientType;
constructor(redisClient: RedisClientType) {
this.redisClient = redisClient;
}
async acquireLock(resource: string, maxWaitTime: number, ttl: number): Promise<boolean> {
//
return true;
}
async releaseLock(resource: string): Promise<void> {
//
}
async touch(resource: string, ttl: number): Promise<boolean> {
//
return true;
}
}
export const lockManager = new DistributedLock(redisClient);
To acquire a lock, we need to write a key into Redis only if that key doesn’t already exist. Redis has first-class support for this via SET with the NX (Not eXists) option. The acquireLock method looks like this:
//...
async acquireLock(resource: string, maxWaitTime: number, ttl: number): Promise<boolean> {
const startTime = performance.now();
const lockKey = `${DistributedLock.NAMESPACE}-lock-${resource}`;
while (true) {
const result = await this.redisClient.set(lockKey, "TRUE", {
condition: "NX",
expiration: {
type: "PX", // ttl in milliseconds, if you want to use seconds, use EX and ttl in seconds
value: ttl
}
});
if (result === 'OK') {
return true;
}
if (performance.now() - startTime >= maxWaitTime) { // respect max wait
return false;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
//...
Straightforward. Now we have lock acquisition — let’s add release.
Releasing is just deleting the key from Redis:
async releaseLock(resource: string): Promise<void> {
const lockKey = `${DistributedLock.NAMESPACE}-lock-${resource}`;
await this.redisClient.del(lockKey);
}
Now let’s wire this into index.ts and watch the race condition go away.
import fs from 'fs';
import { lockManager } from './DistributedLock.js';
import { redisClient } from './redisClient.js';
const walletName = '0';
const nonceFileName = `nonce-${walletName}.txt`;
const incrementCount = 10;
function resetNonce() {
fs.writeFileSync(nonceFileName, '0');
}
resetNonce();
/*
simulate incrementing nonces for each wallet `n` times
each time we read the nonce (simulate RPC request getNonce())
and then increment in memory, and then save to file
*/
for (let i = 0; i < incrementCount; i++) {
const locked = await lockManager.acquireLock(walletName, 5000, 5000);
if (!locked) {
console.log(`Failed to acquire lock for wallet ${walletName} on attempt ${i}`);
continue;
}
try {
let nonce = parseInt(fs.readFileSync(nonceFileName, 'utf-8'), 10);
nonce++;
fs.writeFileSync(nonceFileName, nonce.toString());
} finally {
await lockManager.releaseLock(walletName);
}
}
redisClient.quit();
Running start:many now, the final nonce value is 20 as expected — no more NaN.
npm run start:many && cat nonce-0.txt
Even when we change the incrementCount to a higher value like 1_000_000, we get exactly 2_000_000.
So we’re done, right? Not quite. This implementation works but falls short on the fourth requirement: only the owner of the lock can release it. Right now, anyone holding the name of resource x can call releaseLock(x) and release a lock that never belonged to them.
To fix that, we generate a random token when acquiring the lock, store it as the value, and only delete the key if the caller hands back the matching token. Owner-only release, enforced by Redis.
Let’s adjust acquireLock to generate the token and save it as the value:
import crypto from 'crypto';
//...
async acquireLock(resource: string, maxWaitTime: number, ttl: number): Promise<string | null> {
const startTime = performance.now();
const lockKey = `${DistributedLock.NAMESPACE}-lock-${resource}`;
const value = crypto.randomBytes(16).toString('hex');
while (true) {
const result = await this.redisClient.set(lockKey, value, {
condition: "NX",
expiration: {
type: "PX", // ttl in milliseconds, if you want to use seconds, use EX and ttl in seconds
value: ttl
}
});
if (result === 'OK') {
return value;
}
if (performance.now() - startTime >= maxWaitTime) { // respect max wait
return null;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
//...
We use the crypto module to generate a random hex string and store it as the lock value. The return type changed too — acquireLock now returns the token so the caller can present it on release.
Now the tricky part. releaseLock can no longer just delete the key; it has to check ownership first.
We can’t do something like this either.
async releaseLock(resource: string, value: string): Promise<void> {
const lockKey = `${DistributedLock.NAMESPACE}-lock-${resource}`;
const currentValue = await this.redisClient.get(lockKey);
if (currentValue === value) {
await this.redisClient.del(lockKey);
}
}
That isn’t atomic. Between the get and the del, the lock could expire, get reacquired by someone else, and we’d happily delete their lock.
The fix is a Lua script. Redis executes scripts atomically — no interleaving with other commands. Here’s the script:
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
It checks if the value at the key matches the value the caller passed in, and only deletes if they match.
releaseLock now looks like this:
//...
async releaseLock(resource: string, value: string): Promise<boolean> {
const lockKey = `${DistributedLock.NAMESPACE}-lock-${resource}`;
const luaScript = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
const result = await this.redisClient.eval(luaScript, {
keys: [lockKey],
arguments: [value]
});
if (result === 1) {
return true;
}
return false;
}
//...
Now only the instance that acquired the lock knows the value that can release it. Let’s adjust index.ts to use the new acquire and release methods.
import fs from 'fs';
import { lockManager } from './DistributedLock.js';
import { redisClient } from './redisClient.js';
const walletName = '0';
const nonceFileName = `nonce-${walletName}.txt`;
const incrementCount = 10;
function resetNonce() {
fs.writeFileSync(nonceFileName, '0');
}
resetNonce();
/*
simulate incrementing nonces for each wallet `n` times
each time we read the nonce (simulate RPC request getNonce())
and then increment in memory, and then save to file
*/
for (let i = 0; i < incrementCount; i++) {
const lockValue = await lockManager.acquireLock(walletName, 5000, 5000);
if (!lockValue) {
console.log(`Failed to acquire lock for wallet ${walletName} on attempt ${i}`);
continue;
}
try {
let nonce = parseInt(fs.readFileSync(nonceFileName, 'utf-8'), 10);
nonce++;
fs.writeFileSync(nonceFileName, nonce.toString());
} finally {
await lockManager.releaseLock(walletName, lockValue);
}
}
redisClient.quit();
Run start:many again to confirm nothing regressed:
npm run start:many && cat nonce-0.txt
Still 20.
Let’s improve the dev experience. Right now the caller has to remember the resource name and the token, then pass both back into releaseLock. That’s easy to get wrong. A cleaner pattern: have acquireLock return an object with release and touch methods that already close over the resource and token.
import type { RedisClientType } from "redis";
import { redisClient } from "./redisClient.js";
import crypto from 'crypto';
type AcquireLockReturnType = {
release: () => Promise<boolean>;
touch: () => Promise<boolean>;
};
//...
async acquireLock(resource: string, maxWaitTime: number, ttl: number): Promise<AcquireLockReturnType | null> {
const startTime = performance.now();
const lockKey = `${DistributedLock.NAMESPACE}-lock-${resource}`;
const value = crypto.randomBytes(16).toString('hex');
while (true) {
const result = await this.redisClient.set(lockKey, value, {
condition: "NX",
expiration: {
type: "PX", // ttl in milliseconds, if you want to use seconds, use EX and ttl in seconds
value: ttl
}
});
if (result === 'OK') {
return {
release: () => this.releaseLock(resource, value),
touch: () => this.touch(resource, ttl) // we'll change this later
};
}
if (performance.now() - startTime >= maxWaitTime) { // respect max wait
return null;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
//...
Update index.ts to use the new return type:
//...
for (let i = 0; i < incrementCount; i++) {
const lock = await lockManager.acquireLock(walletName, 5000, 5000);
if (!lock) {
console.log(`Failed to acquire lock for wallet ${walletName} on attempt ${i}`);
continue;
}
try {
let nonce = parseInt(fs.readFileSync(nonceFileName, 'utf-8'), 10);
nonce++;
fs.writeFileSync(nonceFileName, nonce.toString());
} finally {
await lock.release();
}
}
//...
Much cleaner. You get back a lock object, call lock.release() to release, or lock.touch() to extend.
Now let’s implement touch. This is what lets long-running tasks extend their lock as they go, instead of acquiring with a huge TTL and risking a stuck lock if the task crashes.
touch is similar to release: we need a Lua script that checks ownership before extending the key’s TTL. Here’s the script:
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("PEXPIRE", KEYS[1], ARGV[2])
else
return 0
end
The script reads the value and compares it to the provided one. If they match, the TTL is extended by the provided number of milliseconds. Otherwise, nothing happens.
The touch function will look like this:
//...
async touch(resource: string, lockValue: string, ttl: number): Promise<boolean> {
const lockKey = `${DistributedLock.NAMESPACE}-lock-${resource}`;
const luaScript = `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("PEXPIRE", KEYS[1], ARGV[2])
else
return 0
end
`;
const result = await this.redisClient.eval(luaScript, {
keys: [lockKey],
arguments: [lockValue, ttl.toString()]
});
return result === 1;
}
Wire touch into the lock object returned by acquireLock and adjust the AcquireLockReturnType accordingly:
//...
type AcquireLockReturnType = {
release: () => Promise<boolean>;
touch: (ttl: number) => Promise<boolean>; // add ttl param to touch
};
//...
async acquireLock(resource: string, maxWaitTime: number, ttl: number): Promise<AcquireLockReturnType | null> {
//...
if (result === 'OK') {
return {
release: () => this.releaseLock(resource, value),
touch: (newTtl: number) => this.touch(resource, value, newTtl)
};
}
//...
We have a usable distributed lock. Let’s verify touch works by simulating a long-running task. I’ve intentionally tightened the TTL to 100ms — if touch is broken, the lock will expire mid-task and the file write will race.
//...
for (let i = 0; i < incrementCount; i++) {
const lock = await lockManager.acquireLock(walletName, 10000, 100);
if (!lock) {
console.log(`Failed to acquire lock for wallet ${walletName} on attempt ${i}`);
continue;
}
try {
await new Promise((resolve) => setTimeout(resolve, 90)); // simulate some delay in processing
// we touch the lock
await lock.touch(100); // extend lock by 0.1 seconds
await new Promise((resolve) => setTimeout(resolve, 90)); // simulate some more delay in processing
// if the touch is not working, the lock will expire after 0.1 seconds, and our access to the file here is in violation
let nonce = parseInt(fs.readFileSync(nonceFileName, 'utf-8'), 10);
nonce++;
fs.writeFileSync(nonceFileName, nonce.toString());
} finally {
await lock.release();
}
}
//...
Run start:many one more time:
npm run start:many && cat nonce-0.txt
Still 20. The lock holds, touch keeps it alive across the simulated work, and no writer steps on another.
Final Code
import type { RedisClientType } from 'redis';
import { redisClient } from './redisClient.js';
import crypto from 'crypto';
type AcquireLockReturnType = {
release: () => Promise<boolean>;
touch: (ttl: number) => Promise<boolean>;
};
class DistributedLock {
// to avoid any key collisions
private static NAMESPACE: string = `cache-DistributedLock`;
private redisClient: RedisClientType;
constructor(redisClient: RedisClientType) {
this.redisClient = redisClient;
}
async acquireLock(
resource: string,
maxWaitTime: number,
ttl: number
): Promise<AcquireLockReturnType | null> {
const startTime = performance.now();
const lockKey = `${DistributedLock.NAMESPACE}-lock-${resource}`;
const value = crypto.randomBytes(16).toString('hex');
while (true) {
const result = await this.redisClient.set(lockKey, value, {
condition: 'NX',
expiration: {
type: 'PX', // ttl in milliseconds, if you want to use seconds, use EX and ttl in seconds
value: ttl
}
});
if (result === 'OK') {
return {
release: () => this.releaseLock(resource, value),
touch: (newTtl: number) => this.touch(resource, value, newTtl)
};
}
if (performance.now() - startTime >= maxWaitTime) {
// respect max wait
return null;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
async releaseLock(resource: string, value: string): Promise<boolean> {
const lockKey = `${DistributedLock.NAMESPACE}-lock-${resource}`;
const luaScript = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
const result = await this.redisClient.eval(luaScript, {
keys: [lockKey],
arguments: [value]
});
if (result === 1) {
return true;
}
return false;
}
async touch(resource: string, lockValue: string, ttl: number): Promise<boolean> {
const lockKey = `${DistributedLock.NAMESPACE}-lock-${resource}`;
const luaScript = `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("PEXPIRE", KEYS[1], ARGV[2])
else
return 0
end
`;
const result = await this.redisClient.eval(luaScript, {
keys: [lockKey],
arguments: [lockValue, ttl.toString()]
});
return result === 1;
}
}
export const lockManager = new DistributedLock(redisClient);
import fs from 'fs';
import { lockManager } from './DistributedLock.js';
import { redisClient } from './redisClient.js';
const walletName = '0';
const nonceFileName = `nonce-${walletName}.txt`;
const incrementCount = 10;
function resetNonce() {
fs.writeFileSync(nonceFileName, '0');
}
resetNonce();
/*
simulate incrementing nonces for each wallet `n` times
each time we read the nonce (simulate RPC request getNonce())
and then increment in memory, and then save to file
*/
for (let i = 0; i < incrementCount; i++) {
const lock = await lockManager.acquireLock(walletName, 10000, 100);
if (!lock) {
console.log(`Failed to acquire lock for wallet ${walletName} on attempt ${i}`);
continue;
}
try {
await new Promise((resolve) => setTimeout(resolve, 90)); // simulate some delay in processing
// we touch the lock
await lock.touch(100); // extend lock by 0.1 seconds
await new Promise((resolve) => setTimeout(resolve, 90)); // simulate some more delay in processing
// if the touch is not working, the lock will expire after 0.1 seconds, and our access to the file here is in violation
let nonce = parseInt(fs.readFileSync(nonceFileName, 'utf-8'), 10);
nonce++;
fs.writeFileSync(nonceFileName, nonce.toString());
} finally {
await lock.release();
}
}
redisClient.quit();
import { createClient, type RedisClientType } from 'redis';
const client: RedisClientType = createClient({
url: `redis://localhost:6379`, // change with your host and port if needed
socket: {
connectTimeout: 5000
}
});
/*
If using an older version of Node that doesn't await top-level
Use something like
const redisClientPromise = client.connect();
Then whenever you need the client, do
const redisClient = await redisClientPromise;
*/
export const redisClient: RedisClientType = await client.connect();
Improvements Before Production
Before running this in production, a few things are worth adding:
- Jitter: random jitter on the retry delay in
acquireLockto prevent a thundering herd when many callers wake up at the same time. - Incremental backoff: instead of polling every 100ms, back off incrementally so a hot resource doesn’t get hammered.
- Lock queue: instead of polling at all, push waiters onto a queue and hand the lock off as it gets released.
Conclusion
A working cross-instance lock in Redis is a few dozen lines of code plus two short Lua scripts. The same lock is the one running on Pulsonic Trading Bot, where it lets concurrent signers run safely across multiple instances while wallets that aren’t currently signing keep running in parallel. It’s one of the reasons the bot has processed more than 2 million trades without a single nonce-collision incident.
Repository
The full code for this article can be found on GitHub.
