Quick Story
Built as part of a backend engineering assessment. Completed in ~30 hours before travel commitments.
The company later turned out to be collecting submissions rather than hiring, but the project became one of my strongest learnings in idempotency and transaction systems.
Overview
Vaultex was built as part of a backend engineering assessment.
The goal was to design transaction APIs with reliability, concurrency protection, idempotency, and auditability in mind.
Technical Decisions & Tradeoffs
1. Mutex Locking
Implemented mutex locking using local process memory.
Why?
The assessment timeline was limited, and I wanted to focus on demonstrating the concept rather than building distributed infrastructure.
Limitation
This solution works only for a single application instance.
In a horizontally scaled environment, multiple instances would not share the same lock state.
Production Alternative
Redis-based distributed locking.
Lesson
Engineering decisions should be made relative to business requirements, timelines, and constraints.
2. Idempotency Strategy
Implemented idempotency protection for transaction requests.
Decision
Cached only successful transaction responses.
Why?
If a transaction fails due to a temporary issue, the client should be able to retry the request.
Caching failed responses would prevent legitimate retries.
Benefit
Supported safe retry behavior while preventing duplicate successful transactions.
3. Idempotency Key Placement
Stored idempotency keys in request headers instead of request bodies.
Reasoning
I wanted a clear separation of concerns:
Headers → Metadata
Body → Business Data
Benefit
Improved API design consistency.
4. Mutex Check Before Database Calls
Checked mutex locks before executing database queries.
Why?
There is no reason to query the database if the request is already blocked by a lock.
Benefit
Reduced unnecessary database operations and improved efficiency.
Challenges & Mistakes
Started Coding Before Designing
The biggest mistake I made during the assessment was starting implementation before fully defining:
API contracts. Architecture. Request flow. Data flow.
Impact
This led to:
Rewrites. Refactoring. Confusion during implementation. Lost development time.
Lesson
Spending time on architecture and API design before implementation saves significant effort later.
What I Learned
Idempotent Replay Responses
Learned how clients can identify whether a response originated from:
New Request
or
Previously Processed Request
using replay metadata.
Financial System Design
While researching transaction systems, I learned why financial applications often prefer:
Soft Deletes. Audit Trails. Data Integrity Controls.
over direct data removal.
Write-Through Cache
Learned how write-through caching strategies are used to maintain consistency between cache and database layers.
Evolved
From API to Full Web Application
Vaultex evolved from a backend-only assessment project into a full web application where users can log in, manage transactions, and view an analytics dashboard.
Key decisions I made during this evolution:
- Removed auth-related code from the codebase and integrated my Auth Service instead.
- Removed RBAC so the project could function as a personal tool rather than a multi-role system.
- Built interactive labs so users can test concurrency and idempotency behavior without any local setup.
Idempotency lab — send the same create request multiple times with one key; only one transaction should be stored
Idempotency lab output — 1 fresh create (201), 19 replays (200) returning the same transaction ID
Concurrency lab — fire concurrent expense requests to test per-user write locking and 409 rejection when the lock is held
Concurrency lab output — 2 succeeded (201), 18 blocked with 409 when colliding writes hit the lock
Concurrency Lab Surprise
After implementing mutex locking, I tested the concurrency labs by firing 20 concurrent requests and expected only a small number to be processed while the rest would be rejected.
The results were the opposite of what I expected — around 15 requests were processed and only 5 were rejected.
TOCTOU Root Cause
Deep diving into the concurrency labs led me to Time of Check to Time of Use (TOCTOU).
The issue was the gap between checking a user's status and acquiring the lock for that user. My initial flow looked like this:
if (status === 'idle') {
next()
}
await checkForIdempotency()
await checkBalance()
userId[status] = 'processing'
The two async operations between the idle check and setting processing allowed multiple requests to pass the check at the same time.
Fix: Acquire Lock Immediately
To fix this, I acquire the processing lock synchronously the moment the status is confirmed idle:
export function tryAcquireUserLock(userId, balanceCache, idempotencyKey = null) {
const entry = balanceCache[userId];
if (entry?.status === "processing") {
if (
idempotencyKey &&
entry.processingIdempotencyKey &&
entry.processingIdempotencyKey === idempotencyKey
) {
return "same_key";
}
return "busy";
}
if (entry) {
entry.status = "processing";
entry.processingIdempotencyKey = idempotencyKey;
entry.lastUpdatedAt = Date.now();
} else {
balanceCache[userId] = {
status: "processing",
processingIdempotencyKey: idempotencyKey,
lastUpdatedAt: Date.now(),
};
}
return "acquired";
}
When the lock is busy, the middleware returns a 409 response. I chose rejection over maintaining a backend queue because queuing would add complexity and infrastructure maintenance that was unnecessary for a personal tool at my current scale.
Lock Lifecycle via Express Hooks
I release the lock using hooks on the Express response object so a user's status does not stay stuck on processing indefinitely.
res.on('finish', release)— runs after the response has been fully sent to the client. This is the normal path: the request finished processing, so the lock can be released and the status returns toidle.res.on('close', release)— runs when the connection is closed before the response completes — for example, if the client cancels the request or the connection drops. Without this, an interrupted request would leave the lock held and block all future requests for that user.
Both hooks call the same release function, guarded by a released flag so the lock is only cleared once:
let released = false;
const release = () => {
if (released) return;
released = true;
releaseUserLock(userId, balanceCache);
};
res.on("finish", release);
res.on("close", release);
The mutex still lives in local process memory. For horizontal scaling, I would need to move to Redis or another distributed locking mechanism.
Idempotency-Aware Mutex
Once concurrency handling worked, idempotency retries started behaving like concurrency failures.
The mutex was not idempotency-key aware — it treated every in-flight request the same regardless of whether the idempotency key matched. A retry with the same key would get rejected even though it should be allowed through.
Each user entry in the in-memory balanceCache object tracks both lock state and the key currently being processed:
if (entry) {
entry.status = "processing";
entry.processingIdempotencyKey = idempotencyKey;
entry.lastUpdatedAt = Date.now();
} else {
balanceCache[userId] = {
status: "processing",
processingIdempotencyKey: idempotencyKey,
lastUpdatedAt: Date.now(),
};
}
When a request arrives while status is already processing, the lock check now compares idempotency keys before rejecting:
if (entry?.status === "processing") {
if (
idempotencyKey &&
entry.processingIdempotencyKey &&
entry.processingIdempotencyKey === idempotencyKey
) {
return "same_key"; // allow retry with matching key
}
return "busy"; // different key — reject with 409
}
This way, concurrent requests with different keys are blocked, but a legitimate retry with the same idempotency key can proceed.
Additional Improvements
- Built an endpoint to clear lab-related transactions so they do not clutter the user's transaction history.
- Added a
setIntervalcleanup mechanism to remove in-memory cache entries older than 24 hours when their status is idle, so the cache does not grow indefinitely. - Moved balance cache rebuilding to POST/PATCH/DELETE instead of rebuilding lazily the first time a user ID appears in the cache.
Future Improvements
Vaultex currently models a user's financial state around a single balance. The planned next step is evolving it from a single-balance transaction system into a more realistic financial platform — where users maintain separate balances and workflows like reimbursement are first-class concepts.
That increase in model complexity also increases the consistency and concurrency problems the system must solve. The core engineering question stays the same: how does the system maintain transactional correctness as the financial model grows?
Multi-Balance Accounts
Vaultex currently tracks one balance per user. The planned enhancement is to support multiple balance types — Cash, Bank Account, UPI Wallet, and potentially others — so users can maintain and transact against each independently. Real users hold money in different sources with different purposes; transactions would explicitly identify which balance they operate on.
Reimbursement
Reimbursement is planned as one of those use-case types: a user spends personally for something that should eventually be reimbursed by another party, such as a company.
- User pays ₹1,000 from their Cash balance.
- A separate reimbursement record is created for ₹1,000, linked to the expense. Each entry is its own database record — supporting multiple partial reimbursements and optional payer info — not an update to the original transaction.
- The system maintains a clear relationship between the expense and its reimbursement history.
Technologies Used
Last updated August 10, 2026