ES2015 made old async habits visible

Promises, modules, and block scoping improved M31V only after the build stopped hiding cancellation, cleanup, and error ownership inside callbacks.

ECMAScript 2015 arrived with enough new language surface to make JavaScript feel newly deliberate: modules, lexical declarations, classes, promises, iterators, generators, destructuring, and more.

M31V's build had been growing through callbacks. Read a directory, parse articles, inspect assets, render pages, write files, then invoke one final callback with an error if every branch had remembered to propagate it.

Promises made that code easier to compose. They also exposed how little the old build had said about ownership. Which work could run concurrently? Which failure should stop the release? What happened to writes already in flight? Who cleaned the candidate directory?

The new syntax helped only after those questions became explicit.

The callback tree encoded accidental order

The build began as one nested sequence:

readSources(function (error, sources) {
  if (error) return done(error);
  parseSources(sources, function (error, articles) {
    if (error) return done(error);
    renderArticles(articles, function (error) {
      if (error) return done(error);
      writeFeed(articles, done);
    });
  });
});

Nesting made dependencies visible and also serialized work that did not depend on each other. Error handling repeated at every level. One branch forgot return after calling done(error) and continued writing a partial artifact.

I first flattened the code mechanically with promises. The indentation improved while accidental order remained. A chain is not an architecture simply because it reads top to bottom.

I drew the build graph:

read + normalize sources
  -> render pages
  -> render feed
  -> render sitemap
  -> build search index
 
all outputs
  -> assemble candidate
  -> verify

Independent renderers could run together after normalization. Assembly waited for all of them. The language feature made that graph easier to express once the graph existed.

A promise represented one eventual result

The project adopted promises first at owned asynchronous boundaries. A function returned one eventual value or one failure instead of accepting a callback that might be invoked twice or never.

function buildContentGraph(sourceRoot) {
  return readSourceFiles(sourceRoot)
    .then(parseAndValidate)
    .then(resolveRelationships);
}

The contract narrowed behavior. Synchronous exceptions inside the chain became rejections. Callers could compose the returned result instead of passing continuation logic inward.

I avoided wrapping every event source as a promise. File watchers produced many changes over time. A progress stream had many updates. A promise fit a single build attempt or file read, not every asynchronous phenomenon.

This distinction prevented one abstraction from swallowing the project's lifecycle. A build attempt returned one result. The watch controller decided when to start another attempt and which completed result still belonged to current source.

Promises described completion. Ownership still lived in the surrounding controller.

Rejection needed one terminal owner

In the callback build, several branches could call the final callback. A renderer error and a concurrent asset error sometimes produced two messages, followed by cleanup that ran twice.

The promise-based attempt had one terminal handler responsible for marking the candidate failed and invoking cleanup. Internal functions added context and rethrew; they did not print a final message and continue.

return buildCandidate(context)
  .then(verifyCandidate)
  .then(recordSuccess)
  .catch(function (error) {
    return recordFailure(error)
      .then(function () { throw error; });
  });

The actual cleanup used a finally-like helper because the target promise environment did not yet give every modern convenience consistently. What mattered was that success and failure joined one owned release-attempt lifecycle.

Errors carried source diagnostics where known. An internal build error remained distinct from editorial validation. The terminal owner selected exit code and summary.

Flattening control flow made duplicate ownership easier to see and remove.

Page, feed, sitemap, and search renderers could run concurrently. Promise.all returned when all succeeded and rejected when one failed.

It did not cancel the other work automatically. A feed failure could reject the aggregate while page files continued writing into the candidate directory.

The first implementation caught the rejection and deleted that directory immediately. A late page write recreated part of it after cleanup.

I changed the contract. Renderers wrote into isolated temporary subdirectories or returned in-memory file descriptions. The attempt waited for every started branch to settle before final cleanup, even after one had failed. No branch wrote directly to the active release.

Where practical, a shared attempt state asked pending work to stop before expensive phases. In 2015 this was an application convention, not magical promise cancellation. Each owned function checked the state at safe boundaries.

Concurrency saved time only after side effects and cancellation limits were designed. Promise.all made parallel completion concise; it did not make partially completed work disappear.

Cleanup could not be an afterthought

M31V created temporary directories, opened file handles through libraries, and started a preview server during some workflows. The callback version distributed cleanup through success and error branches.

I gave each acquired resource an explicit disposer and collected them under the build attempt. Cleanup ran in reverse acquisition order and was safe to call more than once.

acquire temp directory -> register removal
open candidate log     -> register close
start preview server   -> register stop

The build result did not become successful until essential cleanup and manifest finalization completed. Nonessential diagnostic cleanup could warn without invalidating a verified artifact, according to policy.

This resembled the browser-session ownership lesson from T04P. New syntax did not eliminate resource lifecycle. It gave the asynchronous path a clearer spine on which lifecycle could hang.

A promise chain that ends without releasing what it acquired is merely flatter leaking code.

Modules replaced shared script order

M31V's generator loaded scripts in an order that created globals. paths.js needed to run before articles.js, which needed to run before feed.js. Tests sometimes passed because another test had already populated a global registry.

ES2015 modules offered explicit imports and exports. The 2015 runtime and browser landscape meant the project used a build step rather than assuming native module execution everywhere.

The source expressed ownership:

import { normalizeArticle } from './content/article';
import { buildCanonicalPath } from './publication/paths';
 
export function buildContentGraph(sources) {
  // ...
}

Modules clarified dependency at authoring time. The chosen tooling transformed or bundled them for the Node.js and browser targets M31V actually supported.

I did not make every helper public. Narrow exports made accidental cross-layer access visible. The feed renderer imported normalized article types and URL functions, not the source parser's internal state.

The migration improved architecture because it replaced implicit load order with declared relationships. import syntax alone would not have helped if every module exported one mutable registry.

Using modules and newer syntax before universal runtime support required transformation. That added configuration, source maps, dependency versions, and another failure mode to a publication system whose simplicity mattered.

I pinned the toolchain available to the project, recorded it in the build manifest, and tested emitted code on target environments. A syntax feature was not “supported” merely because the source parser accepted it.

The performance budget included browser code after transformation and minification. A concise source construct could emit more runtime code depending on target. Server-side generator modules had different deployment constraints from public preview JavaScript.

Source maps stayed out of the public artifact unless deliberately included under safe policy. Development builds favored debuggability; candidate artifacts favored verified target output.

This kept ES2015 adoption from becoming a free abstraction. The language improved source clarity while the toolchain became part of release reproducibility.

let and const exposed mutable assumptions

Replacing every var with const mechanically made the diff look modern and missed the point.

Block scoping corrected real bugs in loops and asynchronous callbacks. A renderer loop using var captured one binding and produced errors naming the final source file from every callback. A block-scoped binding gave each iteration the intended value.

const declared that a binding would not be reassigned. It did not make the referenced array or object immutable. The content graph could still be mutated through a constant binding.

I used const for stable references, let where reassignment belonged to the local algorithm, and avoided treating the declaration as a deep data guarantee. The normalized graph froze or copied structures at explicit boundaries where immutability mattered.

The migration review asked why a binding changed. Several accumulators became return values from small functions instead of outer variables modified across callbacks.

Block scoping was most useful when it forced mutable lifetime to shrink.

Arrow functions changed this, sometimes correctly

Arrow functions offered concise callbacks and lexical this. Converting every function could alter behavior where a library deliberately supplied a receiver.

M31V used arrows in array transformations and promise handlers that should inherit the surrounding context. It kept ordinary functions for object methods, constructors, and callbacks whose API defined dynamic this.

The earlier code often captured var self = this. Replacing that pattern with an arrow clarified intent. A plugin callback relying on this to reference a file context remained an ordinary function and received a test.

Conciseness was secondary. The function form communicated binding behavior.

I also avoided dense one-line chains whose error context became difficult to annotate. A named function often produced better stack information and easier profiling than an anonymous arrow nested inside several transformations.

New syntax expanded the vocabulary. Readability still required choosing the right word.

Destructuring made contracts visible and omissions easy

Destructuring was useful when a renderer needed a small, named subset of a normalized node:

function renderArchiveItem({ title, canonicalPath, description }) {
  // ...
}

The signature documented dependencies and made accidental access to source internals less likely.

Defaults required care. Writing { description = deriveDescription(body) } inside a renderer would reintroduce surface-specific meaning. Shared defaults belonged in normalization.

Rest and spread-like patterns available through tooling also tempted broad object copying. Copying a node and overriding canonicalPath could create an object whose stable ID and URL no longer agreed. Domain constructors were safer for meaningful transformations.

Destructuring improved local clarity. It did not replace validation or guarantee that external input contained those properties.

I kept it at trusted internal boundaries and let runtime parsers handle source data first.

Classes were not the migration goal

ES2015 class syntax made prototypes more familiar and inheritance easier to express. M31V did not need a class hierarchy for articles, renderers, or diagnostics.

Normalized content remained plain data. Renderers were functions over explicit input. A build attempt controller used an object with lifecycle where identity and resource ownership justified it.

I tested a base renderer class and removed it. Subclasses overrode methods for page, feed, and sitemap while sharing less behavior than the abstraction implied. Composition through URL, serialization, and diagnostic helpers was clearer.

The lesson was not that classes were bad. The language had gained a useful standard form. The project's architecture did not acquire inheritance needs merely because syntax arrived.

Adoption followed the problem. Promises addressed asynchronous composition. Modules addressed dependency ownership. Block scoping addressed binding lifetime. Classes waited for a domain relationship that deserved them.

Node.js versions and browsers in 2015 implemented ES2015 features at different rates. M31V maintained a small target matrix for source, transformed output, and required runtime built-ins.

Syntax transformation could convert modules or arrow functions. It did not automatically provide a Promise implementation in an environment without one. The project either used a known compatible runtime for the build or supplied a deliberately scoped polyfill for public code that required it.

I avoided loading a broad polyfill bundle on article pages for one minor enhancement. Progressive enhancement remained preferable where the interaction could use existing HTML.

The build server was easier to control than every reader's browser, so ES2015 adoption advanced faster in generator code than public runtime code.

This separation kept the publication readable without JavaScript while letting its tooling improve. “We use ES2015” was not one global capability claim.

Promise errors needed operational context

A rejected promise carrying ENOENT said a file was missing. It did not say whether the missing file was source, generated asset, temporary directory, or deployment target.

Each boundary added context once:

Failed to generate article image derivatives
source: content/articles/workshop.md:31
asset: content/images/workbench.jpg
cause: source file was not found

The error retained its cause and stack for verbose output. Promise chains made propagation easy; they did not create useful messages automatically.

I avoided catch-and-rethrow at every function, which produced repetitive wrappers. Ownership boundaries—source loading, normalization, rendering, artifact assembly, deployment—added the relevant layer.

Known editorial errors remained structured diagnostics rather than exceptional promise rejection. Internal failures rejected the build.

Asynchronous composition improved only when failure vocabulary improved with it.

Watch mode needed stale-result protection

During development, a source change started a new incremental build. A slower earlier build could finish after a faster later build and replace the preview with stale output.

Promises settled in completion order, not intent order. I assigned each build attempt an identity. The preview pointer advanced only if the completing attempt was still the latest requested candidate.

attempt 41 starts
attempt 42 starts after another edit
attempt 42 completes and becomes preview
attempt 41 completes later and is retained only for diagnostics

Where safe, the controller asked old attempts to stop before expensive output. It still guarded completion because cancellation was cooperative and incomplete.

This pattern had appeared in T04P recovery. New language syntax did not remove asynchronous relevance. Results needed identity tied to the intent that requested them.

Watch mode became correct when “finished” stopped meaning “current.”

I migrated one stage at a time and ran both paths over fixed source fixtures. Their normalized graphs and generated public artifacts should match except for deliberate improvements.

Tests covered:

  • one renderer rejection while siblings were in flight,
  • cleanup after partial temporary output,
  • a source edit starting a newer watch attempt,
  • a promise wrapper whose callback fired twice,
  • synchronous exception inside an asynchronous stage,
  • missing runtime support in the target environment,
  • stable module dependency boundaries in clean process runs.

The double-callback fixture was revealing. A promise settled once and hid the second call, preventing duplicate completion but also concealing a buggy library adapter. The wrapper logged or rejected the protocol violation in development instead of assuming the promise had fixed the source.

New abstractions can contain old bugs while making them harder to notice. Boundary tests kept the adapter honest.

After modeling dependencies, independent renderers ran concurrently and unchanged normalized nodes were cached. Candidate build time improved on representative archives.

I did not credit promises or ES2015 generally. The speed came from removing accidental serialization and making dependency boundaries explicit. The same graph could have been implemented with disciplined callbacks.

The clarity benefit was real. Promise composition expressed the graph with less continuation plumbing. Modules made dependencies inspectable. Block scoping prevented lifetime mistakes. The toolchain gave source access to these features before every target implemented them natively.

There was also cost: build configuration, transformed output, debugging across source maps, and dependency maintenance. The project accepted it because generator complexity had crossed the point where implicit script order and nested callbacks were more expensive.

Adoption was a trade, not a modernization ceremony.

Old habits were the important discovery

ECMAScript 2015 did not merely shorten M31V's JavaScript. It exposed assumptions the older style had made easy to ignore:

  • Nesting had confused sequence with dependency.
  • Callbacks had distributed terminal error ownership.
  • Concurrent work lacked cleanup coordination.
  • Script order had stood in for modules.
  • var had allowed bindings to outlive their intended blocks.
  • A completed async result had been mistaken for the current result.
  • Toolchain support had been mistaken for runtime support.

Promises, modules, and lexical declarations gave those problems better forms. They did not answer cancellation, side-effect isolation, relevance, or publication policy on their own.

That was the durable lesson of the migration. A language feature is most valuable when it makes an existing responsibility easier to name and harder to violate. It becomes dangerous when its elegance persuades us that the responsibility vanished.

ES2015 made the build cleaner. More importantly, it made the old asynchronous habits visible enough to replace deliberately.