Day 2: Persist Transaction Data to Postgres — with Idempotent Writes
Welcome back, engineer. Yesterday, we bootstrapped our Node.js Express API, giving it a /transactions endpoint that could process incoming requests. It was a good start, but our transaction data was ephemeral, living only as long as our server process. Today, we fix that. We'll connect our API to a real database, PostgreSQL, to ensure our transaction data persists.
But persistence alone isn't enough. In distributed systems, network failures, server restarts, and client-side timeouts are not "edge cases" — they are the everyday reality. This chaos often leads clients to retry requests. If your server isn't prepared, a retried payment request can turn into a double charge, a retried order into duplicate items, or a retried data write into inconsistent analytics. This is how systems lose trust and, sometimes, millions of dollars.
Today, you'll learn to handle this chaos head-on by implementing idempotent writes. By the end of this lesson, your transaction API will not only persist data to PostgreSQL but will also gracefully handle retried requests without creating duplicate records, a crucial step toward building a truly robust service.
The Problem: When "At-Least-Once" Becomes "At-Least-Twice"
Many fundamental building blocks of distributed systems — message queues like Kafka, network protocols like TCP, and even HTTP clients — offer "at-least-once" delivery semantics. This means a message or request is guaranteed to be delivered at least once, but may be delivered multiple times.
Imagine a user attempts to make a payment. Their client sends a POST /transactions request.
The server receives the request, processes the payment logic, and successfully writes the transaction to the database.
Just as the server is about to send back the success response, a transient network glitch occurs, or the server process crashes.
The client never receives a response and, naturally, retries the request.
Without a mechanism to detect and handle this retry, the server will process the request a second time, creating a duplicate transaction in the database. This isn't theoretical; this exact scenario has led to costly errors in payment gateways and financial systems globally. For instance, early versions of some payment processors struggled with this, leading to customer complaints and complex reconciliation processes. The core issue is that the effect of the operation (creating a transaction) is not idempotent by default.
The Solution: Idempotency with a Key
An operation is idempotent if applying it multiple times produces the same result as applying it once. For our transaction API, this means: if you send the exact same POST /transactions request twice, only one transaction record should be created.
How do we achieve this? We introduce an Idempotency Key. This is a unique identifier generated by the client for each logical operation. The client sends this key as a header (e.g., Idempotency-Key: <unique-uuid>) with its request.
When our server receives a request:
It extracts the
Idempotency-Keyfrom the header.It checks if a transaction with that
Idempotency-Keyhas already been processed and successfully recorded in the database.If it has, the server simply returns the result of the original successful operation. It does not re-process or re-create the transaction.
If it hasn't, the server processes the request, records the transaction, and crucially, associates the
Idempotency-Keywith the newly created transaction before sending a success response.
This ensures that even if the client retries, the system "remembers" it already handled that specific logical operation and avoids duplication.
Architecture: Adding Postgres to the Mix
Today, we're adding PostgreSQL as our persistent storage layer. Our Node.js Express application will connect to it using the pg client library.
Diagram 1: Component Architecture. The client sends requests with an Idempotency-Key to the Node.js API, which then interacts with PostgreSQL for persistent storage and idempotent checks.
The Core: INSERT ... ON CONFLICT
PostgreSQL offers a powerful INSERT ... ON CONFLICT statement (often called "UPSERT"). This allows us to attempt an insert, and if a conflict arises on a unique constraint (like our idempotency_key), we can specify an alternative action, such as DO NOTHING or DO UPDATE. For idempotency, we'll DO NOTHING if the key already exists, then SELECT the existing record.
First, we need a table that supports this. Our transactions table will have an idempotency_key column with a UNIQUE constraint.
This schema is critical. The idempotency_key column is UNIQUE NOT NULL, making it impossible to store two transactions with the same key. The id is still a UUID, serving as the primary identifier for the transaction itself.
Next, in our API endpoint, the logic will look something like this:
This snippet is the heart of our idempotency. If the INSERT succeeds, we return 201 Created with the new transaction. If ON CONFLICT triggers DO NOTHING, result.rows will be empty. In that case, we explicitly SELECT the existing transaction and return it with a 200 OK status, signaling that the operation was already completed.
Control Flow for an Idempotent Write
Diagram 2: Flowchart for an Idempotent Write. The API checks for the Idempotency-Key in the database. If found, it returns the existing result; otherwise, it creates a new record.
State Changes for a Request
Diagram 3: State Machine for a single request with an Idempotency-Key. A new request transitions to processing. If successful, it completes. Subsequent requests with the same key are directly mapped to the Completed state, ensuring no duplicate processing.
The Failure Demo: Retries Without Duplicates
Now for the fun part: deliberately breaking things to prove our design. We'll simulate a client retry by sending a request, killing our server after the database write but before the response, and then sending the same request again. Your system should gracefully handle this, creating only one transaction record.
Before today, the system could not reliably persist transaction data to a database without risking duplicates on retries, and its data was ephemeral (in-memory). After today it can persist transaction data to PostgreSQL, and critically, it handles retried requests for the same transaction <em style="color: #475569; font-style: italic;">without</em> creating duplicate records, ensuring data integrity.
Here's how we'll prove it:
Start the service and database.
Send the first request:
*Expected output:* A201 Createdresponse with the transaction details.Immediately kill the Node.js server. (Simulates a crash after DB write, before response).
Send the exact same request again:
*Expected output:* A200 OKresponse with the *same* transaction details. The200 OKindicates that the operation was already completed.Verify in PostgreSQL:
*Expected output:* You should see *only one row* withidempotency_key: my-unique-tx-001.
This demonstrates that even with a simulated failure and retry, our database remains consistent, and no duplicate transaction is created.
Production Considerations and Trade-offs
While our laptop setup works, real-world systems deal with higher stakes and scale.
Idempotency Key Storage: We're currently storing idempotency keys directly in the
transactionstable. This is simple and highly consistent. For extremely high-throughput systems, checking a database for every request can add latency. Alternatives include:
Dedicated Idempotency Store (e.g., Redis): A fast in-memory cache like Redis can store
idempotency_key->response_payloadmappings. This is faster but introduces another dependency and consistency challenges (what if Redis fails or the DB write succeeds but Redis update fails?). For most services, the database approach is perfectly adequate and more robust.Trade-off: Simplicity and strong consistency (DB) vs. lower latency at very high scale (Redis + complex consistency).
Idempotency Key Expiration: Idempotency keys should not live forever. Eventually, you'll want to clean up old keys to prevent your database (or Redis) from growing indefinitely. How long should they live?
Consider client retry windows: If clients typically retry for 60 seconds, your keys should persist for at least that long. A common practice is to keep them for a few hours or even a few days, balancing storage cost with the longest expected retry/reconciliation window.
Implementation: Add a
TTL(Time-To-Live) orexpires_atcolumn to your table and run a background job to periodically delete expired keys.
Returning the Cached Response: Our current implementation returns the existing transaction details. For a truly robust idempotent API, you ideally want to return the exact same HTTP response (status code, headers, body) as the original successful request. This would require storing the full HTTP response alongside the idempotency key. This is more complex but ensures clients receive a consistent experience on retries. For this lesson, returning the existing transaction details is sufficient and simpler.
Assignment: Extend Idempotency to Status Updates
Our current idempotency protects against creating duplicate new transactions. But what if we want to idempotently update a transaction's status (e.g., from pending to completed)?
Your task:
Modify the API to support an idempotent PUT /transactions/:id/status endpoint. This endpoint should allow clients to update the status of a transaction (e.g., from pending to completed). If the client retries the same status update request (with an Idempotency-Key), the system should ensure the status is updated only once, and subsequent identical requests should return the currently known status without re-processing.
Success Criteria:
You can successfully update a transaction's status via
PUT /transactions/:id/statuswith anIdempotency-Key.If you retry the exact same
PUTrequest with the sameIdempotency-Key, the transaction's status in the database does not change again (if it was already updated), and the API returns a200 OKwith the current status.If you try to update the status with a different
Idempotency-Key(even if the target status is the same), it should be treated as a new, distinct update operation.
Solution Hints
You'll need a new table, or modify your existing
transactionstable, to storeidempotency_keyspecifically for update operations, or perhaps a separateidempotent_requeststable that records theidempotency_keyalong with theresource_id(transaction ID) and theoperation_type(e.g., 'update_status').The
INSERT ... ON CONFLICTpattern still applies. You'd use it to record the fact that a specific update operation (identified by itsIdempotency-Key) has been attempted or completed for a given transaction.Consider the state machine: A transaction's status can only move forward (e.g., pending -> completed, not completed -> pending). Your idempotent update logic should respect these transitions. If a client tries to move
completedtopending, that's an invalid state transition, not an idempotent success.The
ON CONFLICTclause can be used withDO UPDATE SETfor this scenario, specifically to update a record in anidempotent_requeststable that tracks the status of the idempotent operation itself.
Today, you've built a foundational piece of a robust distributed system: persistent storage with idempotent writes. This mechanism is critical for preventing data corruption and ensuring reliability in the face of inevitable failures. Tomorrow, we'll layer another essential defense: authentication. We'll secure our API using JSON Web Tokens (JWTs), ensuring that only authorized clients can interact with our hardened service.