Skip to main content
You're offline. Create, edit, and delete are not supported while offline.

Vaultex

7 min readLast updated August 10, 2026
Active Project
Type:
Personal Tool
Status:
Active
Project:
Solo Project
Table of contents

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.

Debugging

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.

Debugging

Write-Through Cache

Learned how write-through caching strategies are used to maintain consistency between cache and database layers.

Evolved

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 storedIdempotency 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 IDIdempotency 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 heldConcurrency 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 lockConcurrency 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 to idle.
  • 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 setInterval cleanup 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.

  1. User pays ₹1,000 from their Cash balance.
  2. 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.
  3. The system maintains a clear relationship between the expense and its reimbursement history.

Technologies Used

Node.jsExpressPostgreSQLMutex LockingIdempotency

Last updated August 10, 2026

Related Projects

Lakshay Mahajan

Backend Engineer focused on building reliable systems with Node.js, TypeScript, and AWS.

Connect

© 2026 Lakshay Mahajan