DEV Community

Ranjith kumar Ramakrishnan
Ranjith kumar Ramakrishnan

Posted on

Exactly-Once Lambda Processing on At-Least-Once Event Sources — Without Adding a Table

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Claim the key. It writes a small ledger entry to Parameter Store under a name derived from your key, using Overwrite: false so the creation is atomic. If the key is already there and marked completed, it returns SKIPPED_COMPLETED and your work never runs. If another invocation holds a fresh in-flight claim, it returns SKIPPED_IN_FLIGHT.
  2. Run your work — but only if the claim was won.
  3. 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"
Enter fullscreen mode Exit fullscreen mode

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/*"
}
Enter fullscreen mode Exit fullscreen mode

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)