Transaction Pipeline with BullMQ
Build an async fintech pipeline with queues, retries, and Redis caching.
Introduction
30 Second Summary
Every time you send money through a payment app, your transfer doesn't actually happen the instant you tap "send." It gets dropped into a queue, processed in the background, and lands in the recipient's account moments later.
In this project, you will build a transaction processing pipeline where money transfers flow through a background queue powered by BullMQ and Redis. You'll POST a transfer, get an instant response, and watch a separate worker process validate, fraud-check, and settle the transaction.
What You'll Build
You'll fire off a transfer via curl, see an instant "pending" response, then switch to your worker terminal and watch it validate funds, run a fraud check, and settle the transaction in real time.
By the end of this project, you'll have:
- A transfer API that responds in milliseconds while a background worker picks up and processes the transaction through validation, fraud checking, and settlement in a separate terminal.
- Live Redis cache hits that skip MongoDB entirely on balance lookups, with automatic cache invalidation when balances change.
- Triggered failures that retry with exponential backoff logged in real time, then route to a dead-letter queue you can query for full failure details.
- Secret Mission: Add priority queues so VIP transfers jump the line ahead of standard ones.
Are there any prerequisites?
This project assumes familiarity with Express and basic Mongoose operations (creating schemas, querying documents). If you've built a MERN stack app before, you're ready to go.
Before We Start
Before diving in, let's lock in what you're about to build and why it matters. You're creating a transaction processing pipeline where money transfers flow through background workers instead of blocking API responses.
Set Up the Project and Infrastructure
Before writing any pipeline code, you need the foundation: a Node.js project with its dependencies and two local databases. Redis handles job queuing, and MongoDB stores your accounts and transactions persistently.
Docker lets you run both databases locally without installing them directly on your machine. Each one runs in its own isolated container that you can start and stop with a single command.
In this step, get ready to:
- Initialize an npm project with four dependencies.
- Start Redis and MongoDB containers with Docker.
- Create the source file structure for the pipeline.
Create the project and install dependencies
You need a project folder on your Desktop and an initialized npm project with Express, BullMQ, ioredis, and Mongoose installed.
- Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Terminal, and pressing Enter.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir txn-pipeline
cd txn-pipeline
- Initialize the npm project and install all four dependencies by running these commands:
npm init -y
npm install express@5.2.1 bullmq@5.81.3 ioredis@5.11.1 mongoose@9.9.0
You should see output ending with a line like added X packages confirming the install completed.
Seeing permission errors on npm install?
On macOS, try prefixing the install command with sudo. On Windows, run your terminal as Administrator.
If you see network errors, check your internet connection and try again. Help me troubleshoot my npm install failure.
The npm init -y command creates a default package.json, but it needs two additions: ES module support and custom scripts.
- Open VS Code by pressing Cmd+Space (macOS) or the Windows key (Windows), typing VS Code, and pressing Enter.
- Click File in the top menu bar.
- Click Open Folder.
- Select the txn-pipeline folder on your Desktop, then click Open.
- Open package.json from the Explorer sidebar on the left.
- Replace the entire contents of package.json with the following:
{
"name": "txn-pipeline",
"version": "1.0.0",
"type": "module",
"scripts": {
"seed": "node src/seed.js",
"start": "node src/server.js",
"worker": "node src/worker.js"
},
"dependencies": {
"bullmq": "5.81.3",
"express": "5.2.1",
"ioredis": "5.11.1",
"mongoose": "9.9.0"
}
}
What does this configuration do?
- "type": "module" enables ES module syntax (import/export) instead of require().
- The three scripts give you shortcuts: npm run seed populates test data, npm start runs the API, and npm run worker starts the background processor.
- Save package.json by pressing Cmd+S (macOS) or Ctrl+S (Windows).
✔️ Awesome, I've got everything!
Great. Make sure package.json is saved and move on to the next substep.
ⓧ I'd like to double check the full code
Your package.json should look exactly like this:
{
"name": "txn-pipeline",
"version": "1.0.0",
"type": "module",
"scripts": {
"seed": "node src/seed.js",
"start": "node src/server.js",
"worker": "node src/worker.js"
},
"dependencies": {
"bullmq": "5.81.3",
"express": "5.2.1",
"ioredis": "5.11.1",
"mongoose": "9.9.0"
}
}
Start Redis and MongoDB via Docker
Your pipeline needs two databases running locally. Redis acts as the message broker for BullMQ job queues, and MongoDB stores account balances and transaction records.
- Open a terminal inside VS Code by pressing Ctrl+` (backtick key, same shortcut on macOS and Windows).
- Start the Redis container by running this command:
docker run -d --name redis-txn -p 6379:6379 redis:7.2-alpine --maxmemory-policy noeviction --appendonly yes
What do these Redis flags do?
- --maxmemory-policy noeviction prevents Redis from silently deleting job data when memory is full. BullMQ requires this.
- --appendonly yes enables persistence so queued jobs survive container restarts.
You should see a long container ID hash printed to the terminal, confirming the container started.
- Start the MongoDB container by running this command:
docker run -d --name mongo-txn -p 27017:27017 mongo:7
You should see another container ID hash printed.
- Verify both containers are running:
docker ps
You should see two rows: one for redis-txn on port 6379 and one for mongo-txn on port 27017.
Seeing a 'name already in use' error?
A container with that name already exists from a previous attempt. Remove it and try again:
docker rm -f redis-txn
docker rm -f mongo-txn
Then re-run the two docker run commands above. Help me fix my Docker container errors.
Create the project file structure
The pipeline splits responsibilities across multiple files: the API server, the background worker, shared configuration, data models, and queue definitions.
- Create the folder structure and empty source files by running these commands in the VS Code terminal:
macOS
mkdir -p src/config src/models src/queues
touch src/server.js src/worker.js src/config/redis.js src/config/db.js src/models/Account.js src/models/Transaction.js src/queues/transaction.queue.js
Windows
mkdir src\config, src\models, src\queues
ni src\server.js, src\worker.js, src\config\redis.js, src\config\db.js, src\models\Account.js, src\models\Transaction.js, src\queues\transaction.queue.js
What are these files for?
- src/server.js is the Express API that receives transfer requests.
- src/worker.js is a separate process that picks up and processes queued jobs.
- src/config/ holds shared Redis and MongoDB connection setup.
- src/models/ holds Mongoose schemas for accounts and transactions.
- src/queues/ holds the BullMQ queue definition.
- Confirm the files exist by running:
ls src src/config src/models src/queues
You should see server.js and worker.js in src/, redis.js and db.js in src/config/, the two model files, and transaction.queue.js in src/queues/.
Don't see the files listed?
Make sure you ran the commands from the txn-pipeline folder. Run pwd to check. If you are somewhere else, run cd ~/Desktop/txn-pipeline and try again.
Help me figure out why my project files weren't created.
Before you run the final check, what do you expect to see if both Docker containers and your project directory are healthy?
- Run this to confirm Node.js works inside your project:
node -e "console.log('ready')"
You should see ready printed. Combined with the two running containers you verified with docker ps, your infrastructure is ready.
Your project skeleton is in place and both databases are running. Next up, you will build an Express API that processes transfers inline, so you can feel the latency problem that queues will solve.
Build the API with Inline Processing
In the last step you set up the project skeleton and spun up Redis and MongoDB in Docker containers. Now it is time to build the actual transfer API.
Before introducing any queues or background workers, you are going to process transfers the simple way: entirely inline, inside the route handler. The API will check the sender's balance, run a fraud check, move the money, and only then respond. This means the caller waits for every single step to finish. You will feel exactly why that is a problem by the end of this step.
In this step, get ready to:
- Create the MongoDB connection and Mongoose models for accounts and transactions.
- Seed test accounts and build a POST /transfer endpoint that processes everything inline.
- Measure the latency and confirm the API blocks for over 2 seconds per request.
Set up the database connection
Your app needs a way to talk to the MongoDB container running on port 27017. Mongoose handles this with a single connection string.
- Open src/config/db.js in VS Code and add the following code:
import mongoose from 'mongoose';
// Connect to the local MongoDB container
const connectDB = async () => {
await mongoose.connect('mongodb://localhost:27017/txn-pipeline');
console.log('MongoDB connected');
};
export default connectDB;
What does this code do?
The connectDB function connects to the txn-pipeline database on your local MongoDB container.
MongoDB will create this database automatically the first time you insert data. The function is async so you can await it at the top level of your server file.
- Save src/config/db.js.
- Verify the connection works by running this command in your terminal:
node -e "import connectDB from './src/config/db.js'; await connectDB();"
You should see MongoDB connected printed, then the script hangs (because the connection stays open). Press Ctrl+C to stop it.
Seeing a connection error?
- Make sure your mongo-txn Docker container is still running. Run docker ps and confirm you see it listed on port 27017.
- If it stopped, restart it with docker start mongo-txn.
Help me debug my MongoDB connection error.
Create the Mongoose models
You need two models: one for accounts (holding balances) and one for transactions (recording each transfer).
- Open src/models/Account.js and add the Account schema:
import mongoose from 'mongoose';
// Each account has a name and a balance in dollars
const accountSchema = new mongoose.Schema({
name: { type: String, required: true },
balance: { type: Number, required: true, default: 0 },
});
const Account = mongoose.model('Account', accountSchema);
export default Account;
What does this code do?
This defines a simple schema with a name (the account holder) and a balance (their available funds in dollars). The balance defaults to 0 if not provided.
- Save src/models/Account.js.
- Now open src/models/Transaction.js and add the Transaction schema:
import mongoose from 'mongoose';
// Records every transfer between accounts
const transactionSchema = new mongoose.Schema({
fromAccount: { type: mongoose.Schema.Types.ObjectId, ref: 'Account', required: true },
toAccount: { type: mongoose.Schema.Types.ObjectId, ref: 'Account', required: true },
amount: { type: Number, required: true },
status: { type: String, enum: ['pending', 'processing', 'completed', 'failed'], default: 'pending' },
priority: { type: String, enum: ['vip', 'standard'], default: 'standard' },
failureReason: { type: String },
attempts: { type: Number, default: 0 },
createdAt: { type: Date, default: Date.now },
completedAt: { type: Date },
});
const Transaction = mongoose.model('Transaction', transactionSchema);
export default Transaction;
What does this code do?
The Transaction schema tracks who sent money, who received it, and how much. The status field records where the transfer is in its lifecycle.
Fields like priority, failureReason, and attempts will become useful once you add queues and retries in later steps.
- Save src/models/Transaction.js.
- Confirm both model files exist by running:
ls src/models/
You should see Account.js and Transaction.js listed.
Seed test accounts and build the inline transfer API
Before you can transfer money, you need accounts with balances. A seed script creates three test accounts: Alice, Bob, and Charlie.
- Create a new file src/seed.js and add this code:
import connectDB from './config/db.js';
import Account from './models/Account.js';
const seed = async () => {
await connectDB();
// Clear any existing accounts and insert fresh ones
await Account.deleteMany({});
const accounts = await Account.insertMany([
{ name: 'Alice', balance: 1000 },
{ name: 'Bob', balance: 500 },
{ name: 'Charlie', balance: 2000 },
]);
console.log('Seeded accounts:');
accounts.forEach(a => console.log(` ${a.name}: $${a.balance} (ID: ${a._id})`));
process.exit(0);
};
seed();
What does this code do?
The seed script connects to MongoDB, wipes any existing accounts, then inserts three fresh ones with known balances. It prints each account's name, balance, and MongoDB ID so you can use those IDs in your transfer requests later.
- Save src/seed.js and run it:
npm run seed
You should see output like this (your IDs will differ):
MongoDB connected
Seeded accounts:
Alice: $1000 (ID: 6691a2...)
Bob: $500 (ID: 6691a2...)
Charlie: $2000 (ID: 6691a2...)
- Copy the IDs for Alice and Bob from your terminal output. You will need them shortly when testing the transfer endpoint.
Seed script hanging or erroring?
- If it hangs without printing anything, your MongoDB container may have stopped. Run docker start mongo-txn and try again.
- If you see a syntax error, confirm your package.json has "type": "module" set. This enables ES module imports.
Help me fix my seed script error.
Now you will build the Express server with a POST /transfer endpoint that processes everything synchronously. This is the inline approach you are about to feel the cost of.
- Open src/server.js and add the server shell with imports and the listener:
import express from 'express';
import connectDB from './config/db.js';
import Account from './models/Account.js';
import Transaction from './models/Transaction.js';
const app = express();
app.use(express.json());
// Connect to MongoDB before handling requests
await connectDB();
app.listen(3000, () => {
console.log('Transaction API running on http://localhost:3000');
});
What does this code do?
This sets up a minimal Express 5 server. The top-level await connectDB() works because your project uses ES modules ("type": "module" in package.json), which supports top-level await.
Express 5 also handles async route handlers natively. If an async handler throws, Express catches the rejection automatically instead of crashing the process.
- Save src/server.js and confirm it starts by running:
node src/server.js
You should see:
MongoDB connected
Transaction API running on http://localhost:3000
- Press Ctrl+C to stop the server. You will restart it after adding the transfer route.
Now add the POST /transfer route that does all processing inline. This means the API does not respond until every stage completes.
- In src/server.js, find the line that says app.listen(3000, and add this route above it:
app.post('/transfer', async (req, res) => {
const { fromAccount, toAccount, amount } = req.body;
// Stage 1: Check sender's balance
const sender = await Account.findById(fromAccount);
if (!sender || sender.balance < amount) {
return res.status(400).json({ error: 'Insufficient funds' });
}
// Stage 2: Fraud check (simulated with 1.5s delay)
await new Promise(resolve => setTimeout(resolve, 1500));
// Stage 3: Debit sender, credit receiver
await Account.findByIdAndUpdate(fromAccount, { $inc: { balance: -amount } });
await Account.findByIdAndUpdate(toAccount, { $inc: { balance: amount } });
// Stage 4: Record the transaction
const transaction = await Transaction.create({
fromAccount,
toAccount,
amount,
status: 'completed',
completedAt: new Date(),
});
res.json({
status: 'completed',
transactionId: transaction._id,
message: 'Transfer processed',
});
});
What does this code do?
This route handler runs four stages sequentially, one after another. The caller's HTTP request stays open the entire time.
- Stage 1 reads the sender's balance from MongoDB and rejects if funds are too low.
- Stage 2 simulates calling an external fraud-detection service with a 1.5-second delay.
- Stage 3 debits the sender and credits the receiver in MongoDB.
- Stage 4 saves a transaction record marked as completed.
Only after all four stages finish does res.json() send the response. That 1.5-second fraud check alone guarantees the response can never be fast.
- Save src/server.js.
✔️ Awesome, I've got everything!
Great. Double check you have saved src/server.js with both the server shell and the POST /transfer route.
ⓧ I'd like to double check the full code
Your src/server.js should contain two sections you built above. Scroll up and confirm:
- The server shell (imports, app setup, await connectDB(), and app.listen) is present at the top and bottom.
- The app.post('/transfer', ...) route sits between await connectDB() and app.listen.
- The route has all four stages: balance check, fraud check delay, debit/credit, and transaction save.
Test and feel the latency
Everything is wired up. Time to see how this inline approach performs under a real request.
- Start the server by running:
node src/server.js
You should see MongoDB connected followed by Transaction API running on http://localhost:3000.
- Open a second terminal window. You need to keep the server running in the first one.
Before you send the transfer request, think about this: you have a balance check (a quick DB read), a 1.5-second fraud check, two DB writes, and a transaction save. How long do you expect the response to take?
- In your second terminal, send a transfer from Alice to Bob by running this command (replace the IDs with the ones from your seed output):
curl -s -w "\nTime: %{time_total}s\n" -X POST http://localhost:3000/transfer \
-H "Content-Type: application/json" \
-d '{"fromAccount": "[[ALICE_ID=\"your Alice ID from seed output\"]]", "toAccount": "[[BOB_ID=\"your Bob ID from seed output\"]]", "amount": 100}'
Watch your terminal. The response takes over 2 seconds to come back.
{"status":"completed","transactionId":"...","message":"Transfer processed"}
Time: 2.1s
That Time: 2.1s line is the problem. The caller sat waiting for 2+ seconds while the server validated funds, ran the fraud check, moved money, and recorded the transaction. In a real payment system, this blocks the entire request thread.
Why is 2 seconds a problem?
In production, payment APIs serve thousands of concurrent users. If every transfer takes 2 seconds to respond, your server can only handle a handful of requests before connections start timing out and piling up.
The fraud check is the bottleneck here, but it is not optional. The real fix is not to skip validation. It is to do the validation somewhere else, off the request path, so the API can respond instantly.
- Switch back to the terminal running your server and press Ctrl+C to stop it.
Not seeing a response from curl?
- Make sure your server is still running in the first terminal. If you accidentally closed it, run node src/server.js again.
- Check that you replaced the placeholder IDs with the actual MongoDB ObjectIDs from your seed output. They look like 24-character hex strings.
- If you see Insufficient funds, your Alice ID might be wrong. Re-run npm run seed and copy the fresh IDs.
Help me troubleshoot my curl transfer request.
You have proven the pain. A 2-second response time is unacceptable for a payment API. Next up, you will move all that heavy processing into a background queue so the API can respond in milliseconds.
Move Processing to a Background Queue
In the previous step, you felt the pain of inline processing. A 2-second response time for every transfer is unacceptable for any real payment system.
The fix is to separate the response from the work. Your API will accept the transfer, queue it in Redis, and respond instantly with a "pending" status. A separate worker process picks up jobs from the queue and handles the heavy lifting independently.
In this step, get ready to:
- Create a shared Redis connection and BullMQ queue definition.
- Refactor the API to queue transfers instead of processing them inline.
- Build a worker process that handles transaction processing in the background.
Create the Redis connection and queue definition
BullMQ needs an ioredis connection with a specific setting: maxRetriesPerRequest: null. Without this, BullMQ's blocking commands will throw errors after the default retry cap is hit.
- Open src/config/redis.js in VS Code and add the Redis connection by pasting this code:
import Redis from 'ioredis';
// BullMQ requires maxRetriesPerRequest: null for blocking commands
const connection = new Redis({
host: 'localhost',
port: 6379,
maxRetriesPerRequest: null,
});
export default connection;
Why maxRetriesPerRequest: null?
BullMQ uses blocking Redis commands like BRPOPLPUSH to wait for new jobs. The default ioredis retry cap of 20 means that after 20 attempts, the connection throws an error and the worker stalls silently. Setting it to null removes the cap so the worker can block indefinitely while waiting for jobs.
- Save src/config/redis.js.
- Now open src/queues/transaction.queue.js and define the transaction queue by pasting this code:
import { Queue } from 'bullmq';
import connection from '../config/redis.js';
// Main queue for processing transfers
const transactionQueue = new Queue('transactions', {
connection,
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 1000,
},
removeOnComplete: { count: 100 },
removeOnFail: { count: 500 },
},
});
// Dead-letter queue for failed transactions
const dlqQueue = new Queue('transactions-dlq', {
connection,
});
export { transactionQueue, dlqQueue };
What does this code do?
You are creating two queues. The main transactionQueue handles active transfers. Its defaults retry failed jobs 3 times with exponential backoff (1s, 2s, 4s delays between retries).
The dlqQueue (dead-letter queue) captures transactions that exhaust all retries. You will wire this up in the worker later.
- Save src/queues/transaction.queue.js.
- Verify the Redis connection works by running this in your terminal:
node --input-type=module -e "import connection from './src/config/redis.js'; console.log('Redis:', await connection.ping()); process.exit(0);"
You should see Redis: PONG printed in your terminal.
Seeing a connection error?
- Confirm your Redis container is still running with docker ps. You should see redis-txn in the list.
- If the container stopped, restart it with docker start redis-txn.
Help me debug my Redis connection error.
Refactor the API to queue instead of process inline
The current src/server.js does all the work before responding. You will replace it entirely so the API just queues the job and responds immediately.
- Select all content in src/server.js and delete it.
- Paste the new imports and server setup at the top:
import express from 'express';
import connectDB from './config/db.js';
import Account from './models/Account.js';
import Transaction from './models/Transaction.js';
import { transactionQueue, dlqQueue } from './queues/transaction.queue.js';
const app = express();
app.use(express.json());
await connectDB();
What changed in the imports?
Instead of importing models just for inline processing, you now import transactionQueue and dlqQueue from your queue definition. The API no longer needs to know how transactions are processed.
- Below the await connectDB() line, paste the new POST /transfer route:
app.post('/transfer', async (req, res) => {
const { fromAccount, toAccount, amount, priority } = req.body;
// Save the transaction as pending
const transaction = await Transaction.create({
fromAccount,
toAccount,
amount,
priority: priority || 'standard',
status: 'pending',
});
// Map priority to BullMQ numeric priority (lower = higher)
const jobPriority = priority === 'vip' ? 1 : 10;
// Queue the job and respond immediately
await transactionQueue.add(
'process-transfer',
{
transactionId: transaction._id.toString(),
fromAccount,
toAccount,
amount,
},
{ priority: jobPriority }
);
res.status(202).json({
status: 'pending',
transactionId: transaction._id,
message: 'Transfer queued for processing',
});
});
What does this route do?
The route creates a Transaction document with status pending, then adds a job to the BullMQ queue. It responds with HTTP 202 (Accepted) immediately. No balance check, no fraud check, no settlement. All of that happens in the worker.
The response time drops from 2+ seconds to single-digit milliseconds because the API is only writing to MongoDB and Redis, not doing any processing.
- Below the POST route, paste the GET endpoints and server listener:
app.get('/transfer/:id', async (req, res) => {
const transaction = await Transaction.findById(req.params.id);
if (!transaction) {
return res.status(404).json({ error: 'Transaction not found' });
}
res.json(transaction);
});
app.get('/accounts', async (req, res) => {
const accounts = await Account.find({});
res.json(accounts);
});
app.get('/dlq', async (req, res) => {
const failedJobs = await dlqQueue.getJobs(['waiting', 'delayed']);
const failures = failedJobs.map(job => ({
transactionId: job.data.transactionId,
amount: job.data.amount,
failureReason: job.data.failureReason,
failedAt: job.data.failedAt,
originalAttempts: job.data.originalAttempts,
}));
res.json(failures);
});
app.listen(3000, () => {
console.log('Transaction API running on http://localhost:3000');
});
What are these endpoints for?
- GET /transfer/:id lets you check the status of a queued transaction (pending, processing, completed, or failed).
- GET /accounts shows current account balances to verify settlements worked.
- GET /dlq lists transactions that failed all retry attempts.
- Save src/server.js.
- Start the API server by running:
npm start
You should see MongoDB connected followed by Transaction API running on http://localhost:3000 in your terminal.
- Open a second terminal in VS Code by pressing Cmd+Shift+` (macOS) or Ctrl+Shift+` (Windows).
- POST a transfer to test the instant response:
curl -X POST http://localhost:3000/transfer \
-H "Content-Type: application/json" \
-d '{"fromAccount": "PASTE_ALICE_ID", "toAccount": "PASTE_BOB_ID", "amount": 100}'
Where do I get the account IDs?
Run curl http://localhost:3000/accounts to see all accounts with their _id values. Copy the _id for Alice into fromAccount and the _id for Bob into toAccount.
The response comes back instantly with something like:
{
"status": "pending",
"transactionId": "...",
"message": "Transfer queued for processing"
}
Compare that to the 2+ second wait from Step 2. The API is now non-blocking. But the transaction stays pending forever because no worker exists to process it yet.
Getting a connection refused error?
- Make sure the server is running in the first terminal (you should see the Transaction API running message).
- Confirm both Docker containers are running with docker ps. You need both redis-txn and mongo-txn.
Help me debug why my Express server won't start with BullMQ.
✔️ Awesome, I've got everything!
Great. Make sure you have saved src/server.js and the server is running without errors.
ⓧ I'd like to double check the full code
Your src/server.js should contain these sections in order:
- Imports (express, connectDB, Account, Transaction, transactionQueue, dlqQueue).
- App setup (express(), express.json(), await connectDB()).
- POST /transfer route that creates a Transaction, adds to queue, responds 202.
- GET /transfer/:id route that returns a transaction by ID.
- GET /accounts route that returns all accounts.
- GET /dlq route that returns failed jobs from the DLQ.
- app.listen on port 3000.
Scroll up and confirm each chunk is present and in the correct order.
Build the worker as a separate process
The queue has a pending job but nothing to process it. You will now build a separate worker process that listens for jobs and handles the validation, fraud check, and settlement stages.
- Open src/worker.js and paste the imports, database connection, and cache helper functions:
import { Worker } from 'bullmq';
import connection from './config/redis.js';
import connectDB from './config/db.js';
import Account from './models/Account.js';
import Transaction from './models/Transaction.js';
import { dlqQueue } from './queues/transaction.queue.js';
await connectDB();
const FRAUD_FAILURE_RATE = 0.2;
// Check Redis cache first, fall back to MongoDB
const getCachedBalance = async (accountId) => {
const cached = await connection.get(`balance:${accountId}`);
if (cached !== null) {
console.log(` Cache HIT for account ${accountId}: $${cached}`);
return parseFloat(cached);
}
console.log(` Cache MISS for account ${accountId}, reading from MongoDB`);
const account = await Account.findById(accountId);
if (!account) throw new Error(`Account ${accountId} not found`);
await connection.set(`balance:${accountId}`, account.balance.toString(), 'EX', 60);
return account.balance;
};
What does getCachedBalance do?
Before hitting MongoDB for a balance, the worker checks Redis. On a cache hit, it returns instantly. On a miss, it reads from MongoDB and stores the result in Redis with a 60-second TTL. This avoids repeated database queries for the same account.
- Below getCachedBalance, paste the cache invalidation helper and the main processing function:
// Remove stale cache after balance changes
const invalidateBalanceCache = async (accountId) => {
await connection.del(`balance:${accountId}`);
console.log(` Cache INVALIDATED for account ${accountId}`);
};
const processTransaction = async (job) => {
const { transactionId, fromAccount, toAccount, amount } = job.data;
console.log(`\nProcessing transaction ${transactionId} (attempt ${job.attemptsMade + 1})`);
await Transaction.findByIdAndUpdate(transactionId, { status: 'processing' });
// Stage 1: Validate funds
console.log(' Stage 1: Validating funds...');
const balance = await getCachedBalance(fromAccount);
if (balance < amount) {
await Transaction.findByIdAndUpdate(transactionId, { status: 'failed', failureReason: `Insufficient funds: has $${balance}, needs $${amount}` });
throw new Error(`Insufficient funds: has $${balance}, needs $${amount}`);
}
console.log(` Validation passed: $${balance} >= $${amount}`);
What is happening in Stage 1?
The worker marks the transaction as processing, then checks the sender's balance (using the Redis cache). If funds are insufficient, it updates the transaction to failed and throws an error. BullMQ catches the thrown error and handles retries.
- Continue pasting the fraud check and settlement stages directly below:
// Stage 2: Fraud check (simulated, randomly fails)
console.log(' Stage 2: Running fraud check...');
if (Math.random() < FRAUD_FAILURE_RATE) {
console.log(' Fraud check FAILED (transient error)');
throw new Error('Fraud check service unavailable');
}
console.log(' Fraud check passed');
// Stage 3: Settlement
console.log(' Stage 3: Settling transaction...');
await Account.findByIdAndUpdate(fromAccount, { $inc: { balance: -amount } });
await Account.findByIdAndUpdate(toAccount, { $inc: { balance: amount } });
await invalidateBalanceCache(fromAccount);
await invalidateBalanceCache(toAccount);
await Transaction.findByIdAndUpdate(transactionId, { status: 'completed', attempts: job.attemptsMade + 1, completedAt: new Date() });
console.log(` Transaction ${transactionId} COMPLETED`);
return { transactionId, status: 'completed' };
};
What do stages 2 and 3 do?
Stage 2 simulates a flaky external fraud-check service by randomly failing 20% of the time. When it fails, BullMQ will retry the entire job with exponential backoff.
Stage 3 debits the sender, credits the receiver, invalidates cached balances for both accounts, and marks the transaction as completed.
- Finally, paste the worker instantiation, event handlers, and shutdown logic:
const worker = new Worker('transactions', processTransaction, {
connection,
concurrency: 5,
limiter: { max: 10, duration: 1000 },
});
worker.on('completed', (job) => {
console.log(`Job ${job.id} completed for transaction ${job.data.transactionId}`);
});
worker.on('failed', async (job, err) => {
console.log(`Job ${job.id} failed (attempt ${job.attemptsMade}/${job.opts.attempts}): ${err.message}`);
if (job.attemptsMade >= job.opts.attempts) {
console.log(` Moving to DLQ: ${job.data.transactionId}`);
await dlqQueue.add('failed-transaction', {
transactionId: job.data.transactionId,
amount: job.data.amount,
fromAccount: job.data.fromAccount,
toAccount: job.data.toAccount,
failureReason: err.message,
failedAt: new Date().toISOString(),
originalAttempts: job.attemptsMade,
});
await Transaction.findByIdAndUpdate(job.data.transactionId, { status: 'failed', failureReason: err.message, attempts: job.attemptsMade });
}
});
What does the failed event handler do?
BullMQ fires the failed event after each failed attempt. The handler checks if all attempts are exhausted. If so, it moves the job data to the dead-letter queue and updates the transaction status to failed in MongoDB.
- At the very bottom of the file, paste the startup message and graceful shutdown handler:
console.log('Transaction worker started, waiting for jobs...');
process.on('SIGTERM', async () => {
console.log('Shutting down worker gracefully...');
await worker.close();
process.exit(0);
});
Why handle SIGTERM?
In production, workers receive SIGTERM when being shut down. The handler calls worker.close() which finishes any in-progress job before exiting. Without this, a job could be interrupted mid-settlement and leave accounts in an inconsistent state.
- Save src/worker.js.
- In your second terminal, start the worker by running:
npm run worker
You should see MongoDB connected followed by Transaction worker started, waiting for jobs... in that terminal.
The worker immediately picks up the pending job from earlier. Watch the second terminal. You should see the processing stages logged (Stage 1, Stage 2, Stage 3) and a completion message.
Worker not picking up the job?
- Verify the queue name in src/worker.js matches the queue name in src/queues/transaction.queue.js. Both must be transactions.
- Check that the Redis container is running with docker ps.
- If you see Fraud check FAILED in the logs, that is normal. The worker will retry automatically.
Help me debug why my BullMQ worker is not processing jobs from the queue.
Before you test the full flow, think about what you expect to happen. If you POST a new transfer now, will the API response be fast or slow? What will you see in the worker terminal?
- In a third terminal (or the second one after the worker logs settle), POST another transfer:
curl -X POST http://localhost:3000/transfer \
-H "Content-Type: application/json" \
-d '{"fromAccount": "PASTE_ALICE_ID", "toAccount": "PASTE_BOB_ID", "amount": 50}'
The API responds instantly with { "status": "pending", "transactionId": "...", "message": "Transfer queued for processing" }. Moments later, the worker terminal logs the three processing stages.
- Confirm the transaction completed by checking its status:
curl http://localhost:3000/transfer/PASTE_TRANSACTION_ID
You should see "status": "completed" in the response. The transfer went from queued to processed without blocking the API.
✔️ Awesome, I've got everything!
Great. Make sure you have saved src/worker.js and the worker is running in a separate terminal.
ⓧ I'd like to double check the full code
Your src/worker.js should contain these sections in order:
- Imports (Worker, connection, connectDB, Account, Transaction, dlqQueue).
- await connectDB() and FRAUD_FAILURE_RATE = 0.2.
- getCachedBalance function (check Redis, fall back to MongoDB, cache with 60s TTL).
- invalidateBalanceCache function (delete cached balance key).
- processTransaction function with Stage 1 (validate), Stage 2 (fraud check), Stage 3 (settle).
- Worker instantiation with concurrency 5 and rate limiter.
- completed and failed event handlers (failed routes to DLQ when attempts exhausted).
- Startup log and SIGTERM graceful shutdown.
Scroll up and confirm each chunk is present and in the correct order.
Your transaction pipeline is alive. The API queues work instantly, and the worker handles the heavy lifting in the background. Next up, you will stress-test the Redis cache and discover what happens when stale data sneaks through.
Add Redis Caching with Cache Invalidation
Your worker checks account balances from MongoDB on every single transaction. Under load, that means dozens of identical balance reads hammering the database for the same data it returned milliseconds ago.
By caching balances in Redis, you can skip MongoDB entirely on repeated lookups. But caching introduces a subtle, dangerous bug that you need to experience firsthand before you can fix it properly.
In this step, get ready to:
- Add a Redis cache layer for account balance lookups.
- Experience the stale cache bug that lets overdrafts slip through.
- Fix the bug with cache invalidation on settlement.
Add a cache layer for balance lookups
The Redis connection your worker already imports can do more than power BullMQ queues. You can use it as a key-value cache, storing account balances with an expiry so the worker skips MongoDB on repeated reads.
- In src/worker.js, add the getCachedBalance function below the FRAUD_FAILURE_RATE constant by copying this code:
const getCachedBalance = async (accountId) => {
const cached = await connection.get(`balance:${accountId}`);
if (cached !== null) {
console.log(` Cache HIT for account ${accountId}: $${cached}`);
return parseFloat(cached);
}
console.log(` Cache MISS for account ${accountId}, reading from MongoDB`);
const account = await Account.findById(accountId);
if (!account) throw new Error(`Account ${accountId} not found`);
await connection.set(`balance:${accountId}`, account.balance.toString(), 'EX', 60);
return account.balance;
};
What does this code do?
This function implements a read-through cache pattern. It checks Redis first using the key balance:{accountId}. If the value exists (cache hit), it returns immediately without touching MongoDB.
On a cache miss, it reads from MongoDB, stores the balance in Redis with a 60-second TTL ('EX', 60), and then returns the value. Every subsequent read within 60 seconds skips the database entirely.
Now update the validation stage inside processTransaction to use the cached balance instead of reading MongoDB directly.
- In the processTransaction function, find the validation stage (the section after console.log(' Stage 1: Validating funds...')).
- Replace any direct MongoDB balance read with this single line:
const balance = await getCachedBalance(fromAccount);
- Save src/worker.js.
- Stop the worker in your worker terminal with Ctrl+C, then restart it by running:
npm run worker
- First, re-seed your accounts so balances are fresh by running this in a third terminal:
npm run seed
- Note the account IDs printed by the seed command. You will need Alice's and Bob's IDs for the next command.
- POST a transfer from Alice to Bob by running this (replace the account IDs with yours):
curl -X POST http://localhost:3000/transfer \
-H "Content-Type: application/json" \
-d '{"fromAccount": "[[ALICE_ID=\"Alice account ID from seed output\"]]", "toAccount": "[[BOB_ID=\"Bob account ID from seed output\"]]", "amount": 100}'
Check the worker terminal. You should see Cache MISS for Alice's account as it reads from MongoDB and caches the balance.
- POST the same transfer again (same command) to see the cache in action.
This time you should see Cache HIT for Alice's account. The worker skipped MongoDB entirely and read the balance from Redis.
Still seeing Cache MISS on the second transfer?
Make sure you restarted the worker after saving. If the first transfer failed (fraud check), the balance was never cached successfully. Re-seed and try again.
help me debug why my Redis cache is always missing
Experience the stale cache bug
Caching feels like a pure win so far. Fewer database reads, faster validation. But there is a dangerous problem hiding in the current implementation.
When a transfer settles and debits Alice's account in MongoDB, the cached balance in Redis is not updated. It still shows the old, pre-transfer amount. A second transfer will validate against stale data.
- Re-seed your accounts to reset balances by running:
npm run seed
- Also flush the Redis cache so no stale entries remain by running:
docker exec redis-txn redis-cli FLUSHDB
Alice has $1000. You are going to POST two transfers of $600 each, back to back. Together they total $1200, which exceeds her balance. The second transfer should be rejected.
Before you run this, what do you think will happen? Will the second transfer fail validation, or will both succeed?
- POST two $600 transfers rapidly by running both commands together:
curl -X POST http://localhost:3000/transfer \
-H "Content-Type: application/json" \
-d '{"fromAccount": "[[ALICE_ID=\"Alice account ID\"]]", "toAccount": "[[BOB_ID=\"Bob account ID\"]]", "amount": 600}' &
curl -X POST http://localhost:3000/transfer \
-H "Content-Type: application/json" \
-d '{"fromAccount": "[[ALICE_ID=\"Alice account ID\"]]", "toAccount": "[[BOB_ID=\"Bob account ID\"]]", "amount": 600}'
- Wait a few seconds for processing, then check Alice's balance by running:
curl http://localhost:3000/accounts
Alice's balance is negative. Both transfers passed validation because the cache returned the stale $1000 for both. Neither transfer invalidated the cache after settling, so the second one validated against outdated data.
Why did both transfers succeed?
The worker's concurrency is 5, meaning both jobs can process simultaneously. Transfer 1 reads the cache (miss, so fetches $1000 from MongoDB and caches it). Transfer 2 reads the cache (hit, still $1000). Both pass validation before either one settles.
This is a classic stale cache problem. The cache holds data that no longer reflects reality. In a banking system, this would mean approving overdrafts.
Fraud check retries in the logs
You might see some fraud check failures and retries in the worker logs. That is normal with the 20% failure rate. Both transfers will eventually settle after retries because the stale cache keeps approving them.
Add cache invalidation on settlement
The fix is straightforward. After the settlement stage debits and credits accounts in MongoDB, you delete the cached balances for both accounts. The next read will be a cache miss, fetching the real, updated balance from the database.
- In src/worker.js, add the invalidateBalanceCache function directly below getCachedBalance by copying this code:
const invalidateBalanceCache = async (accountId) => {
await connection.del(`balance:${accountId}`);
console.log(` Cache INVALIDATED for account ${accountId}`);
};
- Now find the settlement stage inside processTransaction (the section after console.log(' Stage 3: Settling transaction...')).
- After the two findByIdAndUpdate calls that debit and credit accounts, add these two lines:
await invalidateBalanceCache(fromAccount);
await invalidateBalanceCache(toAccount);
What does this fix?
After settlement changes the real balances in MongoDB, the stale Redis entries are deleted. The next transaction that checks either account will get a cache miss, read the true balance from the database, and cache the fresh value.
This is called delete-on-write invalidation. It is simpler than write-through (where you update the cache with the new value) and avoids race conditions between the database write and the cache write.
- Save src/worker.js.
- Stop and restart the worker by pressing Ctrl+C then running:
npm run worker
- Re-seed accounts and flush the cache to start clean:
npm run seed
docker exec redis-txn redis-cli FLUSHDB
Before you test again, do you think the second $600 transfer will succeed or fail this time?
- POST the same two $600 transfers rapidly again:
curl -X POST http://localhost:3000/transfer \
-H "Content-Type: application/json" \
-d '{"fromAccount": "[[ALICE_ID=\"Alice account ID\"]]", "toAccount": "[[BOB_ID=\"Bob account ID\"]]", "amount": 600}' &
curl -X POST http://localhost:3000/transfer \
-H "Content-Type: application/json" \
-d '{"fromAccount": "[[ALICE_ID=\"Alice account ID\"]]", "toAccount": "[[BOB_ID=\"Bob account ID\"]]", "amount": 600}'
- Wait a few seconds, then check balances:
curl http://localhost:3000/accounts
This time, the first transfer settles (Alice goes from $1000 to $400) and the cache is invalidated. The second transfer reads the real balance ($400) from MongoDB, fails validation ($400 < $600), and is correctly rejected.
Check the worker logs. You should see Cache INVALIDATED for both accounts after settlement, followed by a Cache MISS and validation failure on the second transfer.
Both transfers still succeeding?
If both transfers are processed simultaneously (before either settles), the stale cache race can still occur. This is expected with concurrency 5. Try reducing concurrency to 1 temporarily in the Worker options to confirm invalidation works, then set it back to 5.
Also confirm you added the invalidateBalanceCache calls AFTER the MongoDB debit/credit lines, not before them.
help me debug why cache invalidation is not preventing overdrafts
Why can the race still happen with high concurrency?
Cache invalidation prevents stale reads AFTER settlement, but it cannot prevent two jobs from reading the cache at the same instant BEFORE either settles. In production, this is solved with distributed locks or optimistic concurrency control at the database layer. Cache invalidation reduces the window dramatically but does not eliminate it entirely.
✔️ Awesome, I've got everything!
Great. Double check you have saved src/worker.js with both getCachedBalance and invalidateBalanceCache in place.
ⓧ I'd like to double check the full code
Your src/worker.js is long, so scroll through and confirm these sections are present in order:
- Imports (Worker, connection, connectDB, Account, Transaction, dlqQueue).
- await connectDB() and FRAUD_FAILURE_RATE = 0.2.
- getCachedBalance function with Redis get/set and 60s TTL.
- invalidateBalanceCache function with connection.del.
- processTransaction using getCachedBalance(fromAccount) in validation.
- Settlement stage calling invalidateBalanceCache for both fromAccount and toAccount after the MongoDB updates.
- Worker instantiation, completed/failed event handlers, and DLQ routing.
Your worker now caches balance lookups in Redis and correctly invalidates stale data on settlement. Next up, you will stress-test the failure path by increasing the fraud check failure rate and routing exhausted jobs to a dead-letter queue for manual review.
Add Retries, Backoff, and a Dead-Letter Queue
Your pipeline processes transactions through a background worker. But what happens when the fraud check service is flaky and fails intermittently?
Without retries, a single transient failure permanently kills a valid transaction. Your queue is already configured with exponential backoff and a dead-letter queue for exhausted jobs. In this step, you will stress-test that machinery by cranking up the failure rate and watching retries, backoff delays, and DLQ routing happen in real time.
In this step, get ready to:
- Increase the fraud check failure rate to trigger visible retries with exponential backoff.
- Watch exhausted jobs route to the dead-letter queue with full failure context.
- Query the DLQ endpoint and restore normal operation.
Increase the failure rate to trigger retries
Your queue definition in src/queues/transaction.queue.js already configures 3 attempts with exponential backoff starting at 1000ms.
What is exponential backoff?
Instead of retrying immediately (which hammers a failing service), exponential backoff spaces out retries with increasing delays. With a base delay of 1000ms, BullMQ calculates: attempt 1 waits 1s, attempt 2 waits 2s, attempt 3 waits 4s.
This gives flaky services time to recover. If all 3 attempts fail, the job is considered exhausted.
At the current 20% failure rate, most transactions succeed on the first try. To reliably see retries and exhausted jobs, you need a much higher failure rate.
- In src/worker.js, find this line near the top of the file:
const FRAUD_FAILURE_RATE = 0.2;
- Change 0.2 to 0.6 so the line looks like this:
const FRAUD_FAILURE_RATE = 0.6;
- Save src/worker.js.
- Switch to the terminal running your worker process.
- Stop the worker by pressing Ctrl+C.
- Restart the worker by running:
npm run worker
Now send a transfer to see the retries in action. First, get your account IDs by running this in a new terminal (or the API terminal):
- Fetch your account IDs by running:
curl http://localhost:3000/accounts
- Copy the _id values for two accounts (Alice and Bob).
- POST a transfer using those IDs by running:
curl -X POST http://localhost:3000/transfer \
-H "Content-Type: application/json" \
-d '{"fromAccount": "[[FROM_ACCOUNT_ID="Alice _id from /accounts"]]", "toAccount": "[[TO_ACCOUNT_ID="Bob _id from /accounts"]]", "amount": 25}'
Before you look at the worker terminal, what do you expect to see? Will the transaction complete on the first try, or will the 60% failure rate force retries?
Switch to the worker terminal. With a 60% failure rate, you will likely see output like this:
Processing transaction abc123 (attempt 1)
Stage 1: Validating funds...
Cache HIT for account ...: $1000
Validation passed: $1000 >= $25
Stage 2: Running fraud check...
Fraud check FAILED (transient error)
Job 1 failed (attempt 1/3): Fraud check service unavailable
... (1 second delay) ...
Processing transaction abc123 (attempt 2)
Stage 2: Running fraud check...
Fraud check FAILED (transient error)
Job 1 failed (attempt 2/3): Fraud check service unavailable
... (2 second delay) ...
Processing transaction abc123 (attempt 3)
Stage 2: Running fraud check...
Fraud check passed
Stage 3: Settling transaction...
Transaction abc123 COMPLETED
What is happening in those logs?
- Each attempt N line shows BullMQ retrying the same job.
- The delays between attempts grow: 1s after the first failure, 2s after the second. That is the exponential backoff in action.
- If the fraud check passes on any attempt, the transaction completes. If all 3 attempts fail, the job is exhausted.
Not seeing retry attempts?
Make sure you saved src/worker.js after changing the failure rate AND restarted the worker with npm run worker. The old process uses the old 0.2 rate.
If the transaction succeeded on the first try, that is still valid (40% chance of passing). POST another transfer to trigger a failure.
Help me debug why my BullMQ worker is not retrying failed jobs.
Observe the dead-letter queue in action
When all 3 retry attempts fail, the worker's failed event handler catches the exhausted job and routes it to a separate queue for manual review.
What is a dead-letter queue?
A dead-letter queue (DLQ) is a holding area for messages that could not be processed after all retries. Instead of silently dropping the failed transaction, the DLQ preserves the full context: what went wrong, how many times it was attempted, and the original job data.
In production, engineers monitor the DLQ and either fix the underlying issue and re-queue, or manually resolve the failed transactions.
- POST several transfers to generate DLQ entries. Run this command 5 times (or use a loop):
curl -X POST http://localhost:3000/transfer \
-H "Content-Type: application/json" \
-d '{"fromAccount": "[[FROM_ACCOUNT_ID="Alice _id from /accounts"]]", "toAccount": "[[TO_ACCOUNT_ID="Bob _id from /accounts"]]", "amount": 10}'
- Wait about 10-15 seconds for all retries to complete. Watch the worker terminal to see jobs failing and being moved to the DLQ.
You should see lines like this in the worker logs for exhausted jobs:
Job 5 failed (attempt 3/3): Fraud check service unavailable
Moving to DLQ: abc456
- Query the dead-letter queue by running:
curl http://localhost:3000/dlq
You should see a JSON array of failed transactions, each containing the transaction ID, amount, failure reason, timestamp, and number of attempts made.
What does the DLQ response look like?
Each entry includes transactionId (which transaction failed), failureReason (why it failed), failedAt (when it was moved to the DLQ), and originalAttempts (how many times BullMQ tried before giving up).
This is everything an engineer needs to investigate and resolve the failure manually.
Restore normal operation and verify the full pipeline
You have confirmed that retries work with exponential backoff and that exhausted jobs route to the DLQ. Now restore the normal failure rate so the pipeline runs realistically.
- In src/worker.js, find this line:
const FRAUD_FAILURE_RATE = 0.6;
- Change 0.6 back to 0.2 so it reads:
const FRAUD_FAILURE_RATE = 0.2;
- Save src/worker.js.
- Stop the worker with Ctrl+C and restart it by running:
npm run worker
Before you send a final test transfer, predict: at a 20% failure rate with 3 retry attempts, what percentage of transactions do you think will eventually succeed?
- POST one more transfer to confirm normal operation:
curl -X POST http://localhost:3000/transfer \
-H "Content-Type: application/json" \
-d '{"fromAccount": "[[FROM_ACCOUNT_ID="Alice _id from /accounts"]]", "toAccount": "[[TO_ACCOUNT_ID="Bob _id from /accounts"]]", "amount": 15}'
- Watch the worker terminal. At 20% failure rate, the transaction is very likely to succeed (possibly after one retry). The odds of failing all 3 attempts are just 0.8% (0.2 x 0.2 x 0.2).
- Confirm the transaction completed by checking its status:
curl http://localhost:3000/transfer/[[TXN_ID="transactionId from the POST response"]]
You should see "status": "completed" in the response, confirming the pipeline works end-to-end with graceful retry handling.
Transaction stuck in pending or processing?
Make sure you restarted the worker after changing the failure rate back to 0.2. A stale worker process still uses the old 0.6 rate.
If balances are too low from previous test transfers, re-seed the database by running npm run seed in a separate terminal. This resets Alice, Bob, and Charlie to their starting balances.
Help me debug why my transaction is stuck.
Why this architecture matters
You now have a complete failure-handling pipeline. Transient errors retry automatically with backoff. Permanent failures land in a DLQ for manual review. No transaction is ever silently dropped.
This is the exact pattern used by payment processors like Stripe and payment gateways in production. The worker handles the happy path and the sad path without human intervention.
Your transaction pipeline now handles the full lifecycle: instant API responses, background processing with caching, retries with exponential backoff, and a dead-letter queue for failures. Next up, you will tackle the Secret Mission and add priority queues so VIP transfers jump ahead of standard ones.
Secret mission
Add Priority Queues for VIP Transfers
In real payment systems, not all transactions are equal. VIP customers expect faster processing. Can you prove that your pipeline already supports priority ordering, and build a test that shows VIP transfers jumping ahead of standard ones?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally via Docker, so there are no ongoing costs.
Resources you used:
- Docker container redis-txn (Redis 7.2, running on port 6379).
- Docker container mongo-txn (MongoDB 7, running on port 27017).
- Local project folder txn-pipeline on your Desktop.
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with the pipeline.
- Your Docker containers continue running in the background using minimal CPU and memory.
- Your project files remain on your Desktop, ready to use.
- You can restart the server and worker at any time with npm start and npm run worker.
Pause - I'll come back to this later
Stop the Docker containers to free up memory and ports. Your data and project files stay intact.
- Stop both containers by running:
docker stop redis-txn mongo-txn
When you are ready to resume, start them again by running:
docker start redis-txn mongo-txn
- Your MongoDB data (accounts and transactions) persists inside the containers.
- Your Redis cache keys will have expired (60-second TTL), but the queue data persists thanks to AOF.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes your containers, all MongoDB data, and your local project files.
- Remove both Docker containers and all their data by running:
docker rm -f redis-txn mongo-txn
- Delete the project folder from your Desktop.
macOS
rm -rf ~/Desktop/txn-pipeline
Windows
rmdir /s /q %USERPROFILE%\Desktop\txn-pipeline
- Docker, Node.js, and VS Code remain installed on your machine. These are general-purpose tools you will use in other projects.
That's a Wrap!
That's a Wrap!
Nice work! You just built a production-grade transaction processing pipeline that handles financial transfers asynchronously with retries, caching, and intelligent failure routing.
You've learned how to:
- Built an Express API that queues financial transactions through BullMQ and responds instantly instead of blocking for 2+ seconds, with a separate worker process handling the multi-stage pipeline.
- Added Redis caching with proper cache invalidation on balance lookups, experiencing the stale-cache bug firsthand before fixing it.
- Configured exponential backoff retries and a dead-letter queue for failed transactions, ensuring no transfer is silently dropped.
- Secret Mission: Implemented priority queues so VIP transfers jump ahead of standard ones in the processing pipeline.
Ready to quiz yourself?