Disclaimer: This blog post is automatically generated from project documentation and technical proposals using AI assistance. The content represents our development journey and architectural decisions. Code examples are simplified illustrations and may not reflect the exact production implementation.
The Promise Modernization Challenge
Caroline was reviewing some of our WebSocket handling code when she noticed something interesting. “You know,” she said, pointing at a nested Promise constructor, “Node.js has some new promise features we should be using.”
I looked over her shoulder at the code. She was right—we were still using patterns from the early async/await days. With Node.js 25.2.0 in production, we had access to Promise.try(), Promise.withResolvers(), and promise-based timers. Claude suggested we audit the codebase for modernization opportunities.
“Let’s make our promises cleaner,” Caroline grinned. “No more callback wrapper hell.”
Three Modern Promise Patterns
Pattern 1: Promise.try() for Error Handling
The first pattern we tackled was error handling in our CQRS mixins. Previously, we had this pattern:
async executeCommand(command: Command) {
try {
const result = await handler.execute(command);
return result;
} catch (error) {
this.logger.error(error, "Command execution failed");
throw error;
}
}
The problem? If handler.execute() threw a synchronous error before returning a promise, we’d miss it. Promise.try() solves this by wrapping both sync and async errors uniformly:
async executeCommand(command: Command) {
try {
const result = await Promise.try(() => handler.execute(command));
return result;
} catch (error) {
this.logger.error(error, "Command execution failed");
throw error;
}
}
Caroline applied this pattern across our command handlers, query handlers, and WebSocket message parsers. “Now we catch everything,” she said. “No more uncaught exceptions slipping through.”
Pattern 2: Promise.withResolvers() for Event-Driven Code
Our simulator script had the classic “resolve/reject outside the Promise” pattern:
function sendShotsViaWebSocket(matchId, rally) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(`${socketEndpoint}/matches/${matchId}/record`);
ws.addEventListener('open', () => {
console.log('WebSocket connected');
sendNextShot();
});
ws.addEventListener('close', () => resolve());
ws.addEventListener('error', (error) => reject(error));
});
}
With Promise.withResolvers(), we eliminate the awkward constructor wrapper:
function sendShotsViaWebSocket(matchId, rally) {
const { promise, resolve, reject } = Promise.withResolvers();
const ws = new WebSocket(`${socketEndpoint}/matches/${matchId}/record`);
ws.addEventListener('open', () => {
console.log('WebSocket connected');
sendNextShot();
});
ws.addEventListener('close', () => resolve());
ws.addEventListener('error', (error) => reject(error));
return promise;
}
“This is so much cleaner,” Caroline observed. “The resolve/reject functions are in the same scope as the WebSocket setup. No more variable hoisting.”
Pattern 3: Promise-Based Timers
The biggest win came from replacing our custom sleep wrappers. We had this pattern scattered across the codebase:
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
await sleep(1000);
Node.js 15+ provides node:timers/promises for this:
import { setTimeout } from 'node:timers/promises';
await setTimeout(1000);
We updated our retry logic in retry.mts:
export async function retryHealthCheck(
url: string,
serviceName: string,
maxRetries = 5
): Promise<void> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(`${url}/health`);
if (response.ok) return;
} catch (error) {
if (attempt === maxRetries) throw error;
const delay = attempt * 1000;
console.log(`Retrying in ${delay}ms...`);
await setTimeout(delay); // No more custom sleep!
}
}
}
Claude pointed out a bonus: “Promise-based timers support AbortController out of the box. You can cancel them.”
Implementation Journey
Phase 1: Low-Risk Wins (30 Minutes)
Caroline started with the safest changes—replacing custom sleep functions. She updated retry.mts first, then searched for all instances of custom delay wrappers:
grep -r "new Promise.*setTimeout" app/
Three files had the pattern. She replaced them all with import { setTimeout } from 'node:timers/promises'.
“Tests still pass,” she confirmed. “Time for bigger changes.”
Phase 2: CQRS Mixins (2 Hours)
The CQRS layer processes commands and queries from our event sourcing system. We have handlers that can be sync or async, which made error handling tricky.
Caroline wrapped both executeCommand() and executeQuery() with Promise.try():
async executeCommand<T extends Command>(command: T) {
const handler = this.commandHandlers.get(command.type);
if (!handler) {
throw new Error(`No handler registered for command: ${command.type}`);
}
try {
const result = await Promise.try(() => handler.execute(command));
this.logger.trace({ msg: "Command executed", type: command.type, result });
return result;
} catch (error) {
this.logger.error(error, "Command execution failed");
throw error;
}
}
“Now if a handler throws synchronously—like a validation error—we catch it,” Caroline explained. “Before, those errors would bubble up as unhandled rejections.”
Phase 3: WebSocket Server (1 Hour)
Our WebSocket server in websocket/index.mts parses JSON and writes PCM audio. Both operations can fail synchronously:
if (isJSON(buffer)) {
try {
const value = await Promise.try(() => JSON.parse(buffer.toString()));
app.logger.trace({ msg: 'Received JSON command:', command: value });
// ... handle commands
} catch (e) {
app.logger.error(e, 'Error processing JSON');
}
return;
}
if (isPCMAudio(buffer)) {
try {
await Promise.try(() => out?.write(buffer));
} catch (e) {
app.logger.error(e, 'Error writing PCM data');
}
return;
}
“JSON.parse() throws synchronously if the input is malformed,” Caroline noted. “Wrapping it in Promise.try() means we always hit the catch block, even for sync errors.”
Phase 4: Advanced Features (3 Hours)
Claude suggested we go further: “You should add AbortController support to your retry functions.”
Caroline was intrigued. “How does that work?”
Claude explained: “Promise-based timers accept an AbortSignal. If you abort the controller, the timeout throws an AbortError.”
We updated our retry function:
export interface RetryOptions {
url: string;
serviceName: string;
maxRetries?: number;
signal?: AbortSignal; // New!
}
export async function retryWithBackoff(options: RetryOptions): Promise<void> {
const { url, serviceName, maxRetries = 5, signal } = options;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(`${url}/health`, { signal });
if (response.ok) return;
} catch (error) {
if (error.name === 'AbortError') {
throw new Error(`Health check aborted: ${serviceName}`);
}
if (attempt === maxRetries) throw error;
const delay = attempt * 1000;
console.log(`Retrying in ${delay}ms...`);
await setTimeout(delay, undefined, { signal }); // Cancellable!
}
}
}
Now we can cancel long-running retry loops:
const controller = new AbortController();
const healthCheck = retryWithBackoff({
url: 'http://text-service:8000',
serviceName: 'Text Classifier',
signal: controller.signal,
});
// Cancel after 10 seconds
setTimeout(() => controller.abort(), 10000);
try {
await healthCheck;
} catch (error) {
if (error.name === 'AbortError') {
console.log('Health check timed out');
}
}
Caroline was impressed. “That’s way better than the old race() pattern we were using.”
Async Iterators for Periodic Tasks
Claude had one more trick: “You can use setInterval() as an async iterator.”
Our realtime processor had a traditional interval pattern:
setInterval(async () => {
try {
await processScores();
} catch (error) {
console.error('Error processing scores:', error);
}
}, 5000);
The problem? No clean shutdown. Caroline rewrote it with async iterators:
import { setInterval } from 'node:timers/promises';
const controller = new AbortController();
async function startProcessor() {
for await (const startTime of setInterval(5000, Date.now(), {
signal: controller.signal,
})) {
try {
await processScores();
console.log('Scores processed');
} catch (error) {
console.error('Error processing scores:', error);
}
if (shouldStop()) break; // Clean exit
}
}
// Graceful shutdown
process.on('SIGINT', () => {
console.log('Shutting down...');
controller.abort();
});
“Now we can cancel the interval cleanly,” Caroline said. “No more zombie timers after shutdown.”
Results and Lessons Learned
After four phases of modernization, here’s what we achieved:
Code Quality Improvements
| Pattern | Files Updated | LOC Removed | LOC Added | Net Change |
|---|---|---|---|---|
| Promise-based Timers | 3 files | 15 lines | 3 lines | -12 lines |
| Promise.try() | 5 files | 0 lines | 10 lines | +10 lines |
| Promise.withResolvers() | 2 files | 8 lines | 6 lines | -2 lines |
| AbortController | 2 files | 0 lines | 25 lines | +25 lines |
| Async Iterators | 1 file | 12 lines | 15 lines | +3 lines |
| Total | 13 files | 35 lines | 59 lines | +24 lines |
Net result: We added 24 lines but gained significantly better error handling and cancellation support.
Error Handling Wins
- WebSocket JSON Parsing: Previously, malformed JSON caused unhandled rejections. Now all errors are caught uniformly.
- Command Handlers: Synchronous validation errors are now properly caught and logged.
- Retry Loops: Can be cancelled mid-retry using
AbortController.
Developer Experience
Caroline’s verdict: “This is way more readable. No more nested Promise constructors.”
Claude agreed: “Modern JavaScript features exist for a reason. Use them.”
Performance Impact
We measured zero performance regression. Promise.try() and Promise.withResolvers() compile to the same bytecode as manual Promise construction in V8. Promise-based timers are slightly faster because they avoid the Promise constructor overhead.
Compatibility Notes
Before modernizing, check your Node.js version:
- Promise.try(): Requires Node.js 23+
- Promise.withResolvers(): Requires Node.js 22+
- Promise-based Timers: Requires Node.js 15+
We’re running Node.js 25.2.0, so all features were available. If you’re on an older version, you can polyfill Promise.try() and Promise.withResolvers():
// Polyfill for Promise.try() (Node < 23)
if (!Promise.try) {
Promise.try = function (fn) {
return new Promise((resolve) => resolve(fn()));
};
}
// Polyfill for Promise.withResolvers() (Node < 22)
if (!Promise.withResolvers) {
Promise.withResolvers = function () {
let resolve, reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
}
What’s Next
Caroline has more ideas: “We should convert our remaining setInterval() callbacks to async iterators.”
Claude suggested: “You could also modernize your event emitters with async iterators.”
I’m just happy our error handling is more robust. No more mysterious unhandled rejections in production.
Mermaid Diagram: Promise Modernization Flow
graph TB
subgraph "Old Patterns"
OLD_TIMER["Custom sleep()<br/>function"]
OLD_RESOLVE["Promise constructor<br/>with resolve/reject"]
OLD_ERROR["try/catch with<br/>async/await only"]
end
subgraph "Modern Patterns"
NEW_TIMER["node:timers/promises<br/>setTimeout()"]
NEW_RESOLVE["Promise.withResolvers()"]
NEW_ERROR["Promise.try()<br/>+ async/await"]
end
subgraph "Benefits"
TIMER_BENEFIT["✅ AbortController support<br/>✅ No wrapper needed<br/>✅ Standard API"]
RESOLVE_BENEFIT["✅ Cleaner scope<br/>✅ No variable hoisting<br/>✅ Event-driven friendly"]
ERROR_BENEFIT["✅ Catches sync errors<br/>✅ Unified error handling<br/>✅ No unhandled rejections"]
end
OLD_TIMER --> NEW_TIMER
OLD_RESOLVE --> NEW_RESOLVE
OLD_ERROR --> NEW_ERROR
NEW_TIMER --> TIMER_BENEFIT
NEW_RESOLVE --> RESOLVE_BENEFIT
NEW_ERROR --> ERROR_BENEFIT
classDef old fill:#ffebee,stroke:#c62828,stroke-width:2px
classDef new fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
classDef benefit fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
class OLD_TIMER,OLD_RESOLVE,OLD_ERROR old
class NEW_TIMER,NEW_RESOLVE,NEW_ERROR new
class TIMER_BENEFIT,RESOLVE_BENEFIT,ERROR_BENEFIT benefit
Takeaways
- Modern JavaScript features exist for a reason—use them when your Node.js version supports them.
- Promise.try() catches sync and async errors—perfect for CQRS handlers and parsers.
- Promise.withResolvers() cleans up event-driven code—no more nested Promise constructors.
- Promise-based timers support cancellation—use
AbortControllerfor clean shutdown. - Async iterators replace setInterval callbacks—better control flow and error handling.
Caroline summed it up best: “Promises are the foundation of async JavaScript. Might as well use the best tools available.”
Claude agreed: “And your error logs will thank you.”