A Migration Is a Second Runtime Before It Is a Replacement
A recent platform migration reached an encouraging point before anything moved to production. The application built for a second runtime, a local Worker health endpoint returned HTTP 200, the deployment package fit under its project budget, and the existing Node build still passed. The branch could be merged cleanly. It was real progress, but it was not a cutover.
That distinction matters. Engineering teams often describe a migration as movement from one platform to another. The diagram shows an old box, a new box, and an arrow between them. In practice, the safest part of a migration happens before the arrow. For a while, there are two runtime contracts, two build paths, and a growing body of evidence about where they agree and where they do not.
I have started thinking of that phase as adding a second runtime rather than replacing the first one. The wording changes the work. Replacement language encourages a finish-line mindset. A second-runtime mindset asks whether the new target can coexist with the production path, whether its limitations are explicit, and whether each step can be reversed without pretending that unfinished work is traffic-ready.
Preserve the working path
The application in this case is a React Router web application with an existing Node and Fly deployment path. The experiment added a Cloudflare Worker preview target. The normal build remained in place, along with the Fly configuration and Dockerfile. A separate build command enabled the Cloudflare-specific path. The preview configuration used a preview-only Worker name, allowed a workers.dev URL, and declared no production routes.
Those details are not clerical. They define the blast radius. A preview target that can accidentally claim a production route is not meaningfully isolated. A migration branch that breaks the existing build has already converted an experiment into production risk. Keeping the original path healthy made the new runtime additive. It could earn trust without demanding it.
The verification sequence reflected that boundary. The Cloudflare configuration contract ran. Focused Worker tests ran. The Cloudflare build ran. Wrangler packaged the application in dry-run mode. TypeScript passed. Then the ordinary Node build ran again. The new target did not receive credit merely for building itself; it also had to demonstrate that it had not damaged the runtime already carrying the application.
That last check is easy to omit. Migration work naturally pulls attention toward the destination. But compatibility is a property of the whole transition, not only the new artifact. When a codebase serves two runtimes, both are part of the acceptance criteria.
Turn platform constraints into executable policy
The Worker package exposed another useful lesson. Platform limits should become tests as soon as they affect architecture. During the preview work, Cloudflare's documented upload limit became a concrete package budget of 3,072 KiB gzip. A verification script runs Wrangler with --dry-run, reads the reported gzip size, and fails if the package exceeds that budget.
This changed bundle size from a note in a runbook into an executable boundary. The migration initially produced a package over the budget. Cloudflare-only aliases then replaced several AWS SDK paths with explicit preview stubs. Those stubs fail clearly because direct object-storage commands, multipart uploads, and presigning are not supposed to move into the preview runtime. The existing API path continues to own those operations.
After that change, the package measured 3,059.64 KiB gzip during pre-publication verification, leaving 12.36 KiB under the project budget. That margin is fragile, and the runbook says so. The point is not that the bundle became comfortably small. The point is that the constraint became measurable and regressions became visible before deployment.
A good migration does not hide an unsupported capability behind an optimistic abstraction. It either implements the capability, routes it through an existing service, or fails at a deliberate boundary. The preview stubs make that boundary visible. They also remain conditional, so the ordinary Node build continues to use the real AWS SDK.
This pattern is more useful than forcing every dependency to behave identically everywhere. Runtime portability is not the absence of differences. It is the explicit management of differences.
A green build is evidence, not readiness
The preview accumulated substantial proof: a clean merge path, configuration checks, focused tests, TypeScript, both runtime builds, package-size enforcement, a local Worker health response, and a dry-run package. None of that made the preview ready for production traffic.
Several open questions remained. A public preview would need access control. The preview still pointed toward production API backends, so broad testing would require staging services or carefully bounded data. Browser requests from a workers.dev origin could require a CORS policy change or a same-origin proxy. Database-backed routes, authentication, sessions, uploads, streaming behavior, signed URLs, static assets, and image-processing paths still needed integration proof. Account-specific Worker size and CPU limits also had to be confirmed.
This is where migration reporting often becomes misleading. Teams collapse several states into one word: done. But buildable, deployable, testable, shareable, and ready for production traffic are different states. Each requires different evidence. A package can pass a dry run and still lack runtime configuration. A health route can pass while database-backed rendering fails. A preview can work for one engineer and still be unsafe to expose publicly.
The better approach is to name the state precisely. In this case, the branch was safe to merge as preview scaffolding. It was not ready for a production cutover. That sentence is less exciting than saying the migration is complete, but it is much more operationally useful.
Use asymmetry instead of pretending the runtimes match
The old and new targets do not need to be symmetrical on day one. In fact, forcing symmetry too early can create worse architecture. A Worker runtime has different process, filesystem, binary, network, and execution constraints than a Node server. Some dependencies can move directly. Some need adapters. Some should stay behind an API boundary. Some may never belong at the edge.
The migration therefore becomes an exercise in assigning responsibilities. Server rendering can move through a portable stream-based entry. Cloudflare-specific plugins can activate only during the Worker build. Native or large dependencies can receive narrow compatibility layers. Storage operations can remain behind the established backend until there is a reason to move them. The production deployment can continue unchanged while the preview target proves individual routes.
This is not a compromise to apologize for. It is a design tool. The asymmetry reveals where the application has clean boundaries and where runtime assumptions have leaked into business logic. Every conditional alias or isolated adapter is also a question: is this a temporary bridge, or is this capability correctly owned elsewhere?
The answer should come from observed behavior, not migration enthusiasm. If moving a dependency creates a large bundle, weakens a security boundary, or duplicates a service that already works, keeping it behind an API may be the better architecture. A platform migration should improve system boundaries, not merely relocate code.
Make cutover the final decision, not the first objective
The most useful artifact from a migration may be the evidence ladder it creates. First, the configuration proves the new target cannot claim production routes. Then both builds prove coexistence. Focused tests prove the Worker entry and compatibility boundaries. A package budget proves the artifact fits the declared platform constraint. Local runtime checks prove the Worker can answer a real request. Route-level integration tests can then prove sessions, data access, uploads, and streaming. Only after those layers pass does production routing become a sensible discussion.
This sequence preserves optionality. The branch can be reviewed and improved without changing DNS. The preview can be protected before it is shared. Unsupported paths can remain on the established runtime. If the account limits or runtime behavior make the target uneconomical, the experiment can stop without a rollback from production.
That is the deeper value of treating migration as a second runtime. It replaces the big-bang arrow with a series of bounded proofs. It also makes stopping a valid outcome. Not every successful preview must become a full replacement. Sometimes the work reveals that the new runtime is best for a subset of routes, an edge layer, or a future application with different constraints.
Platform migrations are often presented as declarations of destination. I think they are better understood as exercises in preserving reversibility while collecting evidence. The production path should remain boring, the preview path should remain constrained, and the gap between them should be documented in tests rather than optimism. A cutover is safest when it arrives as the last consequence of the work, not the first promise.
