All Docs
FeaturesMaking Tax DigitalUpdated March 11, 2026

Engineering: Fixing Unhandled Promise Rejections in the Inngest Cold-Start Path (ERR-08)

Engineering: Fixing Unhandled Promise Rejections in the Inngest Cold-Start Path (ERR-08)

Release: v1.0.398 Area: API Infrastructure / Error Handling


Background

During cold starts of the Inngest API route (src/app/api/inngest/route.ts), two async initialisation functions are invoked at module evaluation time:

  • syncBatchRegistry() — synchronises the batch job registry with Inngest.
  • runStartupChecks() — performs health and configuration checks on startup.

Both were called using the void operator — a common pattern for intentionally discarding a Promise's return value when you want non-blocking, fire-and-forget execution.

void syncBatchRegistry();
void runStartupChecks();

This pattern is valid when nothing can go wrong, but it becomes a reliability hazard when the underlying functions can throw.


The Problem

When a void-prefixed async call throws an exception, the resulting rejected Promise has no handler attached. Node.js tracks unhandled rejections and, depending on the runtime version and configuration, will either:

  • Emit an UnhandledPromiseRejectionWarning to stderr, or
  • Terminate the process entirely (the default behaviour in Node.js 15+).

In a serverless or edge environment like this platform uses, an UnhandledPromiseRejection during a cold start means:

  1. The error appears in logs with no structured context (no domain, no operation name, no correlation ID).
  2. It is not routed through the platform's captureError pipeline, so it does not appear in error-tracking dashboards.
  3. The failure is difficult to correlate with the request that triggered the cold start.

Diagnosing why a cold start failed becomes a manual log-archaeology exercise.


The Fix

The solution is straightforward: attach a .catch() handler to each call. The handler routes failures through captureError with structured metadata that identifies exactly where the failure originated.

// Before
void syncBatchRegistry();
void runStartupChecks();

// After
syncBatchRegistry().catch(err =>
  captureError(err, { domain: "inngest", operation: "syncBatchRegistry" })
);
runStartupChecks().catch(err =>
  captureError(err, { domain: "inngest", operation: "runStartupChecks" })
);

What changes

BehaviourBeforeAfter
Blocks route handler responseNoNo
Discards return valueYesYes
Catches thrown rejectionsNoYes
Routes errors to captureErrorNoYes
Structured context in error logsNoYes (domain, operation)

The fire-and-forget characteristic is fully preserved. Neither call blocks the route handler from responding. The only change is that failures are no longer silently discarded.


Why This Matters for MTD Submissions

The Inngest route is the entry point for all background job processing on this platform — including quarterly HMRC submission jobs, bank feed sync tasks, and audit trail writes. A cold-start failure in this route can silently stall an entire submission pipeline without any actionable error surfacing to the operator.

With this fix, any failure during cold-start initialisation will appear immediately in the error-tracking dashboard with enough context (domain: "inngest", operation: "syncBatchRegistry" / "runStartupChecks") to diagnose and act on it.


General Guidance

When writing fire-and-forget async calls at module scope or in route handlers:

  • Prefer .catch() over void when the function can throw.
  • Pass structured context to captureError — at minimum a domain and operation.
  • Never use bare void asyncFn() in cold-start or initialisation paths where failures would be unobservable.
// Avoid
void doSomethingAsync();

// Prefer
doSomethingAsync().catch(err =>
  captureError(err, { domain: "your-domain", operation: "doSomethingAsync" })
);