Миграция устаревшего асинхронного кода в Async/Await

Перенос устаревшего асинхронного кода на Async/Await без нарушения работы производственной среды.

JavaScript’s callback model was the only mechanism for non-blocking I/O when Node.js appeared in 2009. By the time Promises landed in ES6 and async/await followed in ES2017, most production codebases had years of callback-based logic already in service: nested error-first handlers, shared mutable state threaded through closures, retry logic embedded in anonymous functions three levels deep. The syntax changed. The running code did not. Today most engineering teams inherit exactly this situation: a codebase where the new patterns and the old ones coexist, where the team wants to migrate but the system cannot stop while they do it.

Migrate Your Async Codebase Safely

SMART TS XL and identifies migration risk across your entire codebase before you change a line.

Исследуй сейчас

The good news is that the migration does not require a rewrite. Callbacks, Promises, and async/await are interoperable at well-defined boundaries. Node.js ships util.promisify precisely to bridge the gap between error-first callbacks and Promise-returning functions. Wrapper layers let old and new code coexist during transition. Incremental migration, one module at a time, keeps production running while the codebase moves forward. The challenge is not the transformation itself but doing it systematically: understanding which callbacks are safe to convert first, which anti-patterns will cause bugs if naively rewritten, and how to validate that each converted function behaves identically to the one it replaced.

Understanding the Callback Model and Why It Breaks Down at Scale

The Node.js callback convention is simple: functions that perform asynchronous work accept a callback as their last argument. The callback receives an error as its first argument and the result as its second. Every standard library function in Node.js follows this convention: fs.readFile, http.get, child_process.execи сотни других.

Javascript

// Standard Node.js error-first callback pattern
const fs = require('fs');

fs.readFile('config.json', 'utf8', (err, data) => {
  if (err) {
    console.error('Failed to read config:', err);
    return;
  }
  const config = JSON.parse(data);
  console.log('Config loaded:', config);
});

This pattern is straightforward for a single operation. It breaks down when operations must be chained, because each subsequent operation must be initiated inside the previous callback. A three-step sequence of read, transform, and write produces three nested levels of callback. A five-step sequence produces five. This is the structure developers call “callback hell” or the “pyramid of doom,” and it is not just an aesthetic problem. Deeply nested callbacks make error handling inconsistent (each level must independently check its error argument), make execution order difficult to reason about, and make refactoring dangerous because the data flow is implicit rather than explicit.

The more critical problem for production systems is that callbacks have no native mechanism for coordination. Running two asynchronous operations and waiting for both to complete requires manual counter tracking. Running a sequence of operations over an array requires recursive patterns or third-party libraries like async. None of this complexity is visible in the function signatures: a callback-based API and a concurrent orchestration built on top of it look identical from the outside, until they fail.

What Callback Hell Actually Looks Like

A real-world callback pyramid from a Node.js service that reads user data, validates permissions, and logs the access:

Javascript

// Three-level callback pyramid -- representative of real legacy code
function getUserReport(userId, callback) {
  db.query('SELECT * FROM users WHERE id = ?', [userId], (err, user) => {
    if (err) return callback(err);
    if (!user) return callback(new Error('User not found'));

    permissions.check(userId, 'read:reports', (err, allowed) => {
      if (err) return callback(err);
      if (!allowed) return callback(new Error('Permission denied'));

      auditLog.write({ userId, action: 'read:reports' }, (err) => {
        if (err) return callback(err);
        // Finally, the actual work
        callback(null, buildReport(user));
      });
    });
  });
}

Error handling repeats at every level. The indentation makes the data flow visually complex. Adding a fourth step requires another nested level. Testing this function requires mocking all three dependencies in order, and simulating an error at the third level requires mocking the first two to succeed. Every one of these characteristics becomes worse as the chain grows.

Using util.promisify to Bridge Callbacks and Promises

Node.js 8.0 introduced util.promisify, which converts any function that follows the standard error-first callback convention into a function that returns a Promise. This is the correct starting point for any async/await migration in a Node.js codebase.

Javascript

const { promisify } = require('util');
const fs = require('fs');

// Convert Node.js built-ins
const readFile = promisify(fs.readFile);
const writeFile = promisify(fs.writeFile);

// Now usable with async/await
async function processConfig(path) {
  const data = await readFile(path, 'utf8');
  const config = JSON.parse(data);
  config.lastLoaded = Date.now();
  await writeFile(path, JSON.stringify(config, null, 2), 'utf8');
  return config;
}

util.promisify handles the error-first convention automatically: if the callback receives a non-null first argument, the returned Promise rejects with that error. If the callback receives a null first argument and a result, the Promise resolves with the result. For the vast majority of Node.js core APIs and third-party libraries that follow this convention, no custom wrapping is needed.

Promisifying Custom Callback Functions

For custom functions that follow the error-first convention but are not Node.js built-ins, util.promisify works identically:

Javascript

const { promisify } = require('util');

// Your existing callback-based function
function fetchUserFromDB(userId, callback) {
  db.query('SELECT * FROM users WHERE id = ?', [userId], (err, rows) => {
    if (err) return callback(err);
    callback(null, rows[0] || null);
  });
}

// Promisified version -- no changes to the original function needed
const fetchUser = promisify(fetchUserFromDB);

// Use in async context
async function getUser(userId) {
  const user = await fetchUser(userId);
  if (!user) throw new Error(`User ${userId} not found`);
  return user;
}

This approach is important: util.promisify wraps the original function without modifying it. The original callback version continues to work. Callers that have not yet been migrated continue to use the callback version. Callers that have been migrated use the promisified version. This coexistence is what makes incremental migration possible.

Handling Non-Standard Callback Signatures

Some older libraries pass multiple result values to their callbacks, which util.promisify resolves into the first value only. For these cases, the util.promisify.custom symbol allows defining a custom promisification:

Javascript

const { promisify } = require('util');

// A function that passes two results to its callback
function parseData(input, callback) {
  // callback(err, parsedData, metadata)
  callback(null, { value: input.trim() }, { length: input.length });
}

// Custom promisification that returns both results
parseData[promisify.custom] = (input) => {
  return new Promise((resolve, reject) => {
    parseData(input, (err, data, meta) => {
      if (err) reject(err);
      else resolve({ data, meta });
    });
  });
};

const parseDataAsync = promisify(parseData);
const result = await parseDataAsync('  hello  ');
// result === { data: { value: 'hello' }, meta: { length: 9 } }

Converting Callbacks to Promises: The Step-by-Step Pattern

For code that cannot be handled by util.promisify directly, the manual Promise wrapper is the migration path. The pattern is consistent:

Javascript

// Step 1: Original callback-based function
function checkPermission(userId, resource, callback) {
  acl.check({ userId, resource }, (err, result) => {
    if (err) return callback(err);
    callback(null, result.allowed);
  });
}

// Step 2: Promise wrapper (coexists with the original)
function checkPermissionAsync(userId, resource) {
  return new Promise((resolve, reject) => {
    checkPermission(userId, resource, (err, allowed) => {
      if (err) reject(err);
      else resolve(allowed);
    });
  });
}

// Step 3: async/await consumer
async function authorizeRequest(userId, resource) {
  const allowed = await checkPermissionAsync(userId, resource);
  if (!allowed) {
    throw new Error(`${userId} does not have access to ${resource}`);
  }
}

The three-level callback pyramid from earlier, rewritten with async/await:

Javascript

// After migration: same logic, linear structure
async function getUserReport(userId) {
  const user = await db.queryAsync('SELECT * FROM users WHERE id = ?', [userId]);
  if (!user) throw new Error('User not found');

  const allowed = await permissions.checkAsync(userId, 'read:reports');
  if (!allowed) throw new Error('Permission denied');

  await auditLog.writeAsync({ userId, action: 'read:reports' });

  return buildReport(user);
}

The error handling is now handled by the single try/catch at the call site rather than repeated at every level. The indentation is flat. Adding a fourth step requires one more await line. Testing requires mocking each dependency independently, in any order.

Параллельное выполнение с Promise.all и Promise.allSettled

One of the most common mistakes when migrating from callbacks to async/await is sequential execution of operations that could run in parallel. Callbacks made parallel execution complex enough that many developers defaulted to sequential chains. Async/await makes parallelism look sequential, which can mislead developers into writing code that is slower than its callback predecessor.

Javascript

// WRONG: sequential execution -- each awaits the previous result
async function loadDashboardData(userId) {
  const profile = await fetchProfile(userId);       // 100ms
  const orders  = await fetchOrders(userId);        // 150ms
  const reviews = await fetchReviews(userId);       // 80ms
  return { profile, orders, reviews };              // Total: ~330ms
}

// RIGHT: parallel execution with Promise.all
async function loadDashboardData(userId) {
  const [profile, orders, reviews] = await Promise.all([
    fetchProfile(userId),
    fetchOrders(userId),
    fetchReviews(userId),
  ]);
  return { profile, orders, reviews };              // Total: ~150ms
}

Promise.all rejects if any Promise in the array rejects. When independent operations can fail without affecting each other and the caller needs to know about all failures, Promise.allSettled is the correct tool:

Javascript

// Promise.allSettled: runs all, reports success or failure per operation
async function syncAllSources(userId) {
  const results = await Promise.allSettled([
    syncFromGitHub(userId),
    syncFromJira(userId),
    syncFromSlack(userId),
  ]);

  const failed = results
    .filter(r => r.status === 'rejected')
    .map(r => r.reason.message);

  if (failed.length > 0) {
    console.warn('Some syncs failed:', failed);
  }

  return results
    .filter(r => r.status === 'fulfilled')
    .map(r => r.value);
}

This pattern has no natural equivalent in callback-based code without a manual counter or a library like async.parallel. Migrating to Promise.allSettled is often one of the highest-leverage changes in a legacy async codebase.

Avoiding the await-in-loop Anti-Pattern

The sequential-instead-of-parallel mistake appears most often inside loops:

Javascript

// WRONG: sequential -- processes items one at a time
async function processOrders(orderIds) {
  const results = [];
  for (const id of orderIds) {
    const result = await processOrder(id);  // blocks until each completes
    results.push(result);
  }
  return results;
}

// RIGHT: parallel -- all orders processed concurrently
async function processOrders(orderIds) {
  return Promise.all(orderIds.map(id => processOrder(id)));
}

// RIGHT (with concurrency limit): parallel but bounded
const pLimit = require('p-limit');
const limit = pLimit(5);  // max 5 concurrent

async function processOrders(orderIds) {
  return Promise.all(
    orderIds.map(id => limit(() => processOrder(id)))
  );
}

The await-in-loop pattern is one of the most common regressions introduced during async/await migration of callback-based code, because callbacks forced developers to think about concurrency explicitly while async/await obscures it.

Error Handling in Async/Await: Replacing Callback Error Propagation

Callbacks propagate errors by convention: the first argument of every callback is an error or null. This works but requires every caller to manually check the error argument. Async/await propagates errors through the Promise rejection mechanism, which integrates with JavaScript’s native try/catch.

Javascript

// Callback error propagation: repeated at every level
function processPayment(orderId, callback) {
  validateOrder(orderId, (err, order) => {
    if (err) return callback(err);  // propagate
    chargeCard(order.amount, (err, charge) => {
      if (err) return callback(err);  // propagate again
      updateInventory(orderId, (err) => {
        if (err) return callback(err);  // propagate again
        callback(null, charge.id);
      });
    });
  });
}

// Async/await: error propagation is automatic
async function processPayment(orderId) {
  const order  = await validateOrder(orderId);   // throws on error
  const charge = await chargeCard(order.amount); // throws on error
  await updateInventory(orderId);                // throws on error
  return charge.id;
}

A critical consideration during migration is preserving error context. Callback-based code often passes errors that have been augmented with contextual information at each level. When migrating to async/await, ensure that error wrapping preserves this context:

Javascript

// Preserving error context during async migration
async function processPayment(orderId) {
  let order;
  try {
    order = await validateOrder(orderId);
  } catch (err) {
    throw new Error(`Payment validation failed for order ${orderId}: ${err.message}`);
  }

  try {
    const charge = await chargeCard(order.amount);
    await updateInventory(orderId);
    return charge.id;
  } catch (err) {
    // Attempt rollback, then rethrow with context
    await refundCharge(order.amount).catch(console.error);
    throw new Error(`Payment processing failed for order ${orderId}: ${err.message}`);
  }
}

Unhandled Promise Rejections

Callback-based code silently swallows errors when the error argument is ignored. Async/await produces unhandled Promise rejections, which in Node.js 15+ cause process termination by default. This is a breaking change when migrating: code that previously failed silently will now crash.

Javascript

// This produces an unhandled rejection in Node.js 15+
async function riskyOperation() {
  throw new Error('Something failed');
}

riskyOperation(); // Promise rejected, but rejection is not caught

// Fix: always await or chain .catch()
await riskyOperation();              // throws, caller handles it
riskyOperation().catch(console.error); // handles inline

Audit all fire-and-forget async function calls during migration. Any call to an async function whose return value is not awaited or chained with .catch() is a potential silent failure in the callback world but an unhandled rejection crash in the async/await world.

Migrating EventEmitter Patterns to Promises and Async Iterators

Node.js EventEmitters are a form of callback pattern where multiple callbacks are registered for named events. They are common in streams, network connections, and custom event buses. Direct migration to async/await requires wrapping the event-based API.

Javascript

const { EventEmitter } = require('events');

// Original EventEmitter-based pattern
function fetchDataLegacy(source) {
  const emitter = new EventEmitter();
  setTimeout(() => {
    emitter.emit('data', { records: [1, 2, 3] });
    emitter.emit('end');
  }, 100);
  return emitter;
}

// Usage: callback registration
const stream = fetchDataLegacy('api');
stream.on('data', chunk => console.log('received', chunk));
stream.on('error', err => console.error('error', err));
stream.on('end', () => console.log('done'));

Converting a one-time event to a Promise is straightforward:

Javascript

// Converting a single-event completion to Promise
function waitForEvent(emitter, successEvent, errorEvent = 'error') {
  return new Promise((resolve, reject) => {
    emitter.once(successEvent, resolve);
    emitter.once(errorEvent, reject);
  });
}

async function fetchData(source) {
  const emitter = fetchDataLegacy(source);
  const data = await waitForEvent(emitter, 'data');
  await waitForEvent(emitter, 'end');
  return data;
}

For streams that emit multiple data events, Node.js provides events.on which returns an async iterator, allowing the full stream to be consumed with for await...of:

Javascript

const { on } = require('events');

async function processStream(readable) {
  for await (const chunk of on(readable, 'data')) {
    await processChunk(chunk);
  }
}

Node.js readable streams are also directly iterable as async iterables since Node 10:

Javascript

const fs = require('fs');

async function countLines(filePath) {
  let lines = 0;
  const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
  for await (const chunk of stream) {
    lines += chunk.split('\n').length - 1;
  }
  return lines;
}

TypeScript Async/Await Migration Patterns

TypeScript codebases have additional considerations when migrating to async/await. Return types must be updated from callback signatures to Promise<T>, and the compiler enforces that await is only used inside async functions.

машинопись

// Before: callback signature
function fetchUser(
  id: string,
  callback: (err: Error | null, user: User | null) => void
): void {
  db.findOne({ id }, callback);
}

// After: async/await signature with proper return type
async function fetchUser(id: string): Promise<User> {
  const user = await db.findOneAsync<User>({ id });
  if (!user) throw new Error(`User ${id} not found`);
  return user;
}

TypeScript’s strict null checks interact with async code in a way that catches common migration errors. If a function previously returned User | null through a callback and the migration changes it to Promise<User> (throwing instead of returning null), TypeScript will catch callers that checked for null but no longer need to, and callers that do not check for errors they now need to handle.

For legacy TypeScript code that uses the @types/node callback signatures, util.promisify is fully typed and infers the correct Promise return type for Node.js built-in functions automatically.

Incremental Migration Strategy for Production Systems

A production system cannot be migrated all at once. The incremental approach converts one module at a time, validates it, then moves to the next. The key is maintaining a clean boundary between converted and unconverted code at every stage.

The migration order should follow dependency direction: convert the deepest dependencies first, then work upward toward the callers. This ensures that by the time a higher-level function is converted, its dependencies are already returning Promises, and the wrapper layer is no longer needed.

Javascript

// Stage 1: Promisify the data layer (deepest dependency)
const db = {
  queryAsync: promisify(db.query.bind(db)),
  insertAsync: promisify(db.insert.bind(db)),
};

// Stage 2: Convert the service layer (depends on db)
class UserService {
  async getUser(id) {
    return db.queryAsync('SELECT * FROM users WHERE id = ?', [id]);
  }
  async createUser(data) {
    return db.insertAsync('users', data);
  }
}

// Stage 3: Convert the controller layer (depends on service)
// -- only after Stage 2 is validated and deployed
async function handleGetUser(req, res) {
  try {
    const user = await userService.getUser(req.params.id);
    res.json(user);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
}

This staged approach is directly supported by analysis tooling. As discussed in анализ данных и потоков управления, understanding how data flows through asynchronous layers before modifying them is the prerequisite for safe incremental refactoring. The dependency direction determines which modules are safe to convert first and which must wait until their dependencies are already migrated.

Wrapper Layers for Backward Compatibility

During migration, some callers will still expect callback-based APIs. The util.callbackify function is the inverse of util.promisify: it converts an async function back to an error-first callback interface:

Javascript

const { callbackify } = require('util');

// New async implementation
async function fetchUserAsync(id) {
  return db.queryAsync('SELECT * FROM users WHERE id = ?', [id]);
}

// Backward-compatible callback version for unconverted callers
const fetchUser = callbackify(fetchUserAsync);

// Old callers continue to work unchanged
fetchUser(userId, (err, user) => {
  if (err) return handleError(err);
  render(user);
});

// New callers use the async version directly
const user = await fetchUserAsync(userId);

This bidirectional compatibility means conversion is not a flag-day event. Individual modules can be converted on any sprint without coordinating with every caller simultaneously.

Common Async/Await Pitfalls During Migration

Missing await on Async Function Calls

The most common migration error is calling an async function without awaiting it. This is invisible to the JavaScript runtime unless the function rejects, and the rejection becomes an unhandled Promise rejection rather than a thrown error.

Javascript

// Bug: missing await -- function runs but result is a Promise, not the user
async function updateUserName(id, name) {
  const user = fetchUser(id);  // BUG: forgot await, user is a Promise object
  user.name = name;            // setting .name on a Promise, not a user
  await saveUser(user);        // saves the Promise object
}

// Fix
async function updateUserName(id, name) {
  const user = await fetchUser(id);
  user.name = name;
  await saveUser(user);
}

TypeScript and ESLint’s no-floating-promises rule catch this pattern automatically. Adding this lint rule during migration is strongly recommended.

Async Functions in Array Methods

Array.prototype.forEach does not await async callbacks. This produces the same sequential-but-wrong behavior as await-in-loop, except the code appears to work while silently running all callbacks concurrently without awaiting any:

Javascript

// Bug: forEach does not await async callbacks
async function processAll(ids) {
  ids.forEach(async (id) => {
    await processItem(id);  // these run concurrently, forEach completes immediately
  });
  // function returns before any processItem completes
}

// Fix: use Promise.all with map
async function processAll(ids) {
  await Promise.all(ids.map(id => processItem(id)));
}

Try/Catch Does Not Catch Async Errors Outside Await

A try/catch block only catches errors from awaited expressions. An async function call without await will not have its rejection caught by a surrounding try/catch:

Javascript

// Bug: the rejection from riskyOp() is not caught
async function run() {
  try {
    riskyOp();  // not awaited -- rejection escapes the try/catch
  } catch (err) {
    console.error(err);  // never reached
  }
}

// Fix: await inside the try block
async function run() {
  try {
    await riskyOp();
  } catch (err) {
    console.error(err);
  }
}

Testing Async Code After Migration

Migrated async functions require async test cases. Modern test frameworks support this natively.

Javascript

// Jest async test patterns
describe('UserService', () => {
  // Pattern 1: async/await in test
  test('fetches user by id', async () => {
    const user = await userService.getUser('user-123');
    expect(user.id).toBe('user-123');
  });

  // Pattern 2: testing rejection
  test('throws when user not found', async () => {
    await expect(userService.getUser('nonexistent'))
      .rejects.toThrow('User nonexistent not found');
  });

  // Pattern 3: parallel setup
  beforeAll(async () => {
    await db.connect();
    await db.seed(testData);
  });

  afterAll(async () => {
    await db.cleanup();
    await db.disconnect();
  });
});

When validating that a migrated function produces identical output to its callback predecessor, comparison testing is effective:

Javascript

// Comparison test: callback version vs. async version must agree
test('async version matches callback version output', async () => {
  const callbackResult = await promisify(fetchUserLegacy)('user-123');
  const asyncResult    = await fetchUserAsync('user-123');
  expect(asyncResult).toEqual(callbackResult);
});

This test pattern is particularly useful during the transition period when both versions are running in parallel. As examined in the context of impact analysis before making code changes, validating that a refactored function produces identical outputs to its predecessor is the evidence-based confirmation that the migration is correct before the old version is removed.

Как SMART TS XL Supports Safe Async Migration at Scale

For enterprise codebases where callback chains span multiple files, services, and teams, the first requirement for safe migration is a complete map of the existing asynchronous dependencies: which functions call which, what data passes between them, which chains are critical paths for the application’s core operations, and which are isolated utilities that can be migrated independently.

SMART TS XL constructs this dependency map by parsing the entire codebase, not just individual files. It resolves how callback-based functions reference each other across module boundaries, identifies which shared state is threaded through closures, and visualizes the execution chains that must remain intact through migration. This structural analysis provides the input for the staged migration approach described in this guide: which modules are at the bottom of the dependency graph and can be converted first, and which modules must wait until their dependencies are already migrated.

Платформа анализ воздействия capability extends this to change assessment. Before converting a callback-based module to return Promises, impact analysis identifies every other module in the codebase that calls it with a callback interface. These callers are the migration scope for the next stage: they must either be converted simultaneously, or a backward-compatible wrapper using util.callbackify must be maintained until they are converted. Without this enumeration, migrations proceed with unknown scope and produce unexpected breakage when callers that were not identified encounter a Promise where they expected a callback.

For codebases that mix JavaScript with TypeScript, or that call into backend services in other languages, SMART TS XLАвтора cross-language dependency analysis provides visibility into the full execution path, not just the JavaScript layer. A callback chain that terminates in a call to an external service written in Java or Python has dependencies that single-language tooling cannot see, and migration planning that ignores those dependencies is incomplete. The визуализация зависимостей которая SMART TS XL provides makes those cross-boundary relationships visible before any migration changes are made.

Sustaining the Migration: From Callback to Async/Await Across the Full Codebase

The transition from callbacks to async/await is not completed when the first module is converted. It is completed when the last wrapper layer is removed and the codebase has no remaining callback convention in its core logic. Getting there requires discipline across the migration period: new code must be written in async/await, wrapper layers must be treated as temporary, and ESLint rules must enforce that callback-style functions are not introduced in converted modules.

The practical markers of a completed migration are: no util.promisify calls in application code (they were needed only for the transition period), no (err, result) => patterns in core business logic (they have been replaced by try/catch in async functions), no manual Promise constructors where async/await would suffice, and Promise.all in every place where independent operations were previously run sequentially.

Each of these is measurable through static analysis, which means progress can be tracked and reported objectively rather than estimated. For teams operating at scale, the combination of automated static analysis for finding callback patterns and dependency analysis for scoping each migration stage is what makes the difference between a migration that completes in a defined timeline and one that persists indefinitely because the scope was never fully known.