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
UnhandledPromiseRejectionWarningto 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:
- The error appears in logs with no structured context (no domain, no operation name, no correlation ID).
- It is not routed through the platform's
captureErrorpipeline, so it does not appear in error-tracking dashboards. - 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
| Behaviour | Before | After |
|---|---|---|
| Blocks route handler response | No | No |
| Discards return value | Yes | Yes |
| Catches thrown rejections | No | Yes |
Routes errors to captureError | No | Yes |
| Structured context in error logs | No | Yes (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()overvoidwhen the function can throw. - Pass structured context to
captureError— at minimum adomainandoperation. - 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" })
);