Every AWS engineer eventually meets this bug.
A Lambda function does something with a side effect — it transitions a record's status, sends a notification, posts a ledger entry, expires an authorization. It works perfectly in testing. Then one day, in production, it does that thing twice. A customer gets two emails. A record gets processed twice. A status flips, then flips again.
You didn't write a loop. You didn't deploy twice. So what happened?
At-least-once is the default, and it will find you
The event source delivered the same logical event more than once. This isn't a rare edge case or a misconfiguration — it's the documented contract of nearly every AWS event source:
- EventBridge guarantees at-least-once delivery. Occasional duplicate deliveries are expected behavior.
- SQS standard queues are explicitly at-least-once.
- Scheduled rules can fire more than once around a boundary.
- Automatic retries re-invoke your handler after a transient error — even if the first invocation actually succeeded but the response was lost.
- Concurrent invocations can pick up the same event from two directions at once.
For a handler that's purely a read, none of this matters. For a handler with side effects, every one of these is a duplicate waiting to happen. And in regulated, financial, or safety-relevant systems, a duplicated side effect isn't a cosmetic glitch — it's a doubled notification, a double-processed expiration, a contradictory state change that someone downstream has to untangle.
The fix is idempotency: make sure the same logical event produces its side effect exactly once, no matter how many times it's delivered.
The usual answer, and a lighter one
The standard way to add idempotency on AWS is a DynamoDB table with a conditional write and a TTL. It works well and it's the right tool at high throughput. But it also means provisioning a table, managing its capacity, wiring up TTL, and adding a dependency — for what is often a very low-volume concern (lifecycle jobs, scheduled processors, moderate event streams).
There's a lighter primitive already sitting in every AWS account: SSM Parameter Store. It has strong per-parameter consistency, it needs nothing provisioned, and — crucially — PutParameter with Overwrite: false is atomic. That single property is enough to build an idempotency ledger: two racing invocations both try to create the same claim, exactly one wins, and the loser gets ParameterAlreadyExists.
I wrapped this into a tiny, dependency-light library: ssm-idempotency-guard.
npm install ssm-idempotency-guard
The whole thing in one call
import { createIdempotencyGuard, Outcome } from "ssm-idempotency-guard";
// Create once per container, reuse across invocations.
const guard = createIdempotencyGuard();
export async function handler(event) {
// A STABLE, DETERMINISTIC key for the logical unit of work.
// The same event must always produce the same key.
const key = `${event["detail-type"]}:${event.detail.recordId}:${event.detail.effectiveDate}`;
const { outcome, result } = await guard.runExactlyOnce(key, async () => {
// ---- your side-effecting work ----
await expireRegistration(event.detail.recordId);
await notify(event.detail.recordId);
return { expired: event.detail.recordId };
});
if (outcome === Outcome.SKIPPED_COMPLETED) {
console.log("duplicate delivery ignored:", key);
}
return result;
}
Retries, duplicate deliveries, and concurrent invocations for the same key will not run your work twice. That's the entire idea.
How it actually works
Under the hood, runExactlyOnce does three things:
-
Claim the key. It writes a small ledger entry to Parameter Store under a name derived from your key, using
Overwrite: falseso the creation is atomic. If the key is already there and marked completed, it returnsSKIPPED_COMPLETEDand your work never runs. If another invocation holds a fresh in-flight claim, it returnsSKIPPED_IN_FLIGHT. - Run your work — but only if the claim was won.
- Complete or release. On success it marks the key completed so later duplicates are suppressed. On failure it releases the claim, so a legitimate retry can re-process rather than being silently blocked.
Two design choices worth calling out, because they're the difference between a toy and something you can put in production:
Crash recovery. If an invocation claims a key and then dies before completing, that claim would block the event forever. So claims carry a timestamp and an in-flight TTL (default 15 minutes). After that window, a stale claim can be taken over. Set the TTL comfortably above your worst-case handler duration.
Failure semantics. runExactlyOnce biases toward exactly-once on success and at-least-once on failure — it releases the claim if your work throws. If your side effect isn't naturally retry-safe, you can use the lower-level claim / complete / release primitives and mark completion only after the side effect is durably committed.
Choosing a good key
The idempotency key is the whole contract, and it has exactly one rule: it must be deterministic — derivable purely from the event, identical every time the same logical event arrives.
"RegistrationExpiry:REG-4821:2026-03-01"
"InvoicePosted:INV-99123"
"CylinderRequal:RIN-2049:2026-06-15"
Combine the event type, the target record, and whatever discriminator makes each logical occurrence unique. Avoid anything that varies per delivery — a receipt timestamp, a message ID — because then every duplicate looks brand new and deduplication does nothing.
The IAM you'll need
Scoped to your prefix:
{
"Effect": "Allow",
"Action": ["ssm:GetParameter", "ssm:PutParameter", "ssm:DeleteParameter"],
"Resource": "arn:aws:ssm:*:*:parameter/idempotency/*"
}
When to reach for something else
Be honest about the boundaries:
- Very high throughput (thousands of writes/sec against one key space) — Parameter Store has account-level throughput limits; DynamoDB with conditional writes and TTL is the better fit.
- Already all-in on DynamoDB — the same conditional-write pattern applies there; this library exists for the common case where you'd rather not add a table.
For lifecycle automation, scheduled processors, and moderate event-driven workloads — which is a huge share of real serverless systems — the zero-infrastructure version is often exactly right.
Try it
The package is MIT-licensed and open source:
The repo includes a runnable Lambda example and a scheduled cleanup sweeper, plus a test suite that proves exactly-once under duplicate delivery and one-executes-one-skips under concurrency — using an in-memory fake, so it runs with no AWS account.
If you've fought the duplicate-side-effect bug, I'd genuinely like to hear how you handled it — drop a comment.
Written by Ranjith Kumar Ramakrishnan (ORCID: 0009-0007-4924-3264), an enterprise systems architect focused on AI-integrated, serverless architectures for public-safety-critical regulatory systems.
Top comments (0)