All Docs
FeaturesMaking Tax DigitalUpdated March 11, 2026

Engineering Note: Closing the Duplicate Submission Race Window (v1.0.406)

Engineering Note: Closing the Duplicate Submission Race Window

Release: v1.0.406 · Category: Reliability / Data Integrity


Background

When a user clicks Submit on a quarterly return, the platform's submission.submit tRPC mutation sets the submission status to submitting in the database and then enqueues an Inngest event (hmrc/quarterly-submit) that drives the actual HMRC API call.

The status field acts as a guard — once a submission is marked submitting, subsequent invocations of the mutation should be blocked. In practice, however, there is a small but real window between the inngest.send() call and the database write completing where two events could be enqueued if:

  • A user double-clicks the submit button quickly enough, or
  • A network retry fires before the first request's DB update is visible.

The Inngest worker itself was already safe: it raises a NonRetriableError if it detects the submission has already been processed. But that safety net is downstream — duplicate events were still reaching the queue, generating unnecessary processing load and creating redundant entries in the audit log.

The Fix

The root cause was simple: inngest.send() was being called without an id field.

Inngest supports event deduplication via a stable, user-supplied event id. When two events arrive with the same id within Inngest's deduplication window, only the first is processed. By deriving the id directly from the immutable summaryId, the guarantee becomes:

No matter how many times the tRPC mutation fires for a given submission, exactly one hmrc/quarterly-submit event will ever be processed.

// src/lib/routers/submission.ts

// Before (no deduplication at enqueue time)
await inngest.send({
  name: 'hmrc/quarterly-submit',
  data: eventPayload,
})

// After (idempotent enqueue keyed on summaryId)
await inngest.send({
  name: 'hmrc/quarterly-submit',
  data: eventPayload,
  id: `hmrc-submit-${summary.id}`,
})

Why summaryId and Not Date.now()?

Using Date.now() as a suffix (e.g. submit-${summary.id}-${Date.now()}) would produce a different id on every call, defeating the purpose of deduplication entirely. A timestamp-based key would only help if both events happened within the same millisecond — an unreliable assumption.

The summaryId is the correct anchor because it is:

  • Stable — it never changes for a given quarterly submission.
  • Unique — one summaryId maps to exactly one HMRC submission attempt.
  • Deterministic — any number of retries or race conditions produce the same key.

Impact

AreaBeforeAfter
Duplicate events enqueued on double-clickPossiblePrevented
Audit log noise from duplicate runsPresentEliminated
Inngest worker safety (NonRetriableError)✅ Present✅ Still present (defence in depth)
HMRC double-submission riskMitigated by workerMitigated at enqueue + worker

This change adds a second layer of protection (enqueue-time deduplication) on top of the existing worker-level guard, making the quarterly submission pipeline fully idempotent end-to-end.


Part of the ERR-series reliability improvements. Filed as ERR-19.