# How we cut our Next.js deploys from 14.5 minutes to 5
Author: Sai Krishna
Author URL: https://superblog.ai/blog/author/sai-krishna/
Published: 2026-11-13
Category: Engineering
Category URL: https://superblog.ai/blog/category/engineering/
Meta Title: How We Cut Our Next.js Deploys from 14.5 Minutes to 5
Meta Description: Our Next.js dashboard took 14.5 minutes to deploy. We measured every step and got it to 5: a cache that never worked, slow build traces, Turbopack and more.
Tags: jamstack, nextjs, developer, performance
Tag URLs: jamstack (https://superblog.ai/blog/tag/jamstack/), nextjs (https://superblog.ai/blog/tag/nextjs/), developer (https://superblog.ai/blog/tag/developer/), performance (https://superblog.ai/blog/tag/performance/)
URL: https://superblog.ai/blog/faster-nextjs-deploys/

![Our Next.js deploys are 2.9x faster](https://prod.superblogcdn.com/site_cuid_ckox4in4f002nl8lhcib41g2u/images/nextjs-deploys-2-9x-faster-1794533555134-compressed.png)

Superblog is a JAMstack blogging platform built with a focus on speed and SEO. Customers write in our dashboard, and we build their blogs into static sites. The dashboard is the part this post is about.

Every push to master deploys the dashboard. The median deploy over our last 28 runs took 14.5 minutes (Max 25 minutes). A deploy now takes about 5 minutes; the latest one took 4 minutes 58 seconds.

Stage

Before

After

Install dependencies

~100s

8s

`next build`

~3 min

16s

Image build, push and checks

~75s

51s

Migrations job

48s

2s (now a step)

Rollout on ECS

~5 min

1m45s

**Push to done**

**14.5 min**

**5 min**

None of this needed new infrastructure. Most of it was measuring where the time went and finding that several things we believed about our pipeline were not true.

## The app

The dashboard is a Next.js 16 app on the Pages Router. Some numbers, so you can compare it with your own:

- 171,286 lines of TypeScript in 1,053 files, not counting tests.

- 2,821 unit tests in 133 files. They run in about 15 seconds.

- 44 pages, 41 API routes and about 250 oRPC procedures.

- 189 dependencies, which install as 947 packages and 1.6 GB of `node_modules`.

- 8,000+ commits.


It runs as one ECS task on AWS Fargate (ARM), with a cloudflared sidecar that connects it to Cloudflare through a tunnel. CI runs on GitHub Actions with Ubicloud runners.

## How a deploy worked

1. A type check and the unit tests run in one job.

2. In parallel, a Docker build runs `npm ci` and `next build`, then pushes an ARM image.

3. A check starts the image and loads every package in it, so a native binary built for the wrong platform fails here instead of in a customer's request.

4. A job applies Prisma migrations to production.

5. A job registers a new ECS task definition, updates the service, and waits for the rollout to finish.


Each step is there for a reason, and we kept all of them. We only changed how long each one takes.

## Step zero: measure every step

We pulled the step timings for recent runs from the GitHub API, and the rollout events from ECS. One clean run, with no queueing, took 13.9 minutes:

Step

Time

`npm ci` inside Docker

73s

Copy `node_modules` into the build stage

27s

`next build`: compile

43s

`next build`: "Collecting build traces"

115s

Export, push, cache write, package check

~75s

Waiting for runners between jobs

~60s

Migrations job (2s of real work)

48s

ECS: new task starts and passes health checks

~2 min

ECS: waiting for the old task to stop

~2.5 min

Two lines stood out. Nobody needed the last 2.5 minutes. And the build spent more time collecting traces than compiling.

## 1\. Stop waiting for the old task to stop (saved 2.5 minutes)

The deploy job waited for ECS to report the rollout as `COMPLETED`. ECS only reports that after the old task has stopped, and the new task was serving traffic about 2.5 minutes before that.

Only one thing needs the old task gone: a destructive migration, such as dropping a column, which we apply after the rollout so no old code is left to read it. So the job now finishes as soon as the new task is running and the public health check returns 200. It still waits for `COMPLETED` when a destructive migration is pending.

## 2\. The `npm ci` cache that never worked (saved about 100 seconds)

The workflow used the GitHub Actions cache for Docker layers with `cache-to: type=gha,mode=min`. A comment above it explained that `min` keeps the `npm ci` layer, because that layer only changes when `package-lock.json` changes.

That comment was wrong. `mode=min` only exports the layers of the final stage of a multi-stage build. Our `npm ci` ran in a `deps` stage, so it was never cached. Every deploy installed 947 packages from scratch, then copied `node_modules` into the next stage.

`mode=max` would cache it, but we had tried that before. It spent about a minute per build uploading layers that change on every commit.

We moved the build out of Docker instead. The runner restores `node_modules` and `.next/cache` from the Actions cache, runs `next build`, and the Dockerfile only copies in the standalone output. The restore takes about 10 seconds.

This meant moving the runtime image from Alpine to Debian slim. Native binaries installed on the Ubuntu runner are linked against glibc, and Alpine uses musl. The image grew from 155 MB to 173 MB.

### We broke a deploy doing this

The first deploy with the new image never took traffic. The new task started, and its health check failed until the job timed out.

The old Dockerfile started the server with the shell form `CMD HOSTNAME=0.0.0.0 node server.js`. We changed it to the exec form `CMD ["node", "server.js"]` and set `HOSTNAME` with `ENV` instead. Fargate sets `HOSTNAME` to the task's own hostname, which overrides the image's `ENV`. Next.js then listened on that address only, and the health check calls `localhost`.

It worked in local Docker, because local Docker does not override `HOSTNAME` that way. Production stayed on the previous task the whole time, because ECS never moved traffic to a task that failed its health check. The fix was to set the variable on the command line again, with `exec` so Node stays process 1 and receives `SIGTERM`.

## 3\. Build traces: 2 minutes of parsing JavaScript

With output set to `standalone`, `next build` traces every file the server needs, so the output can run without the full `node_modules`. On our ARM runner, "Collecting build traces" took 115 to 133 seconds. Compiling took 43.

A CPU profile of a local build showed where the time went. Almost all of it was in `@vercel/nft`, the tracer: walking syntax trees, parsing with acorn, and about 27 seconds of `readlink` calls, most of which failed. It parsed 13,295 files.

The count was high because the Pages Router leaves every dependency external by default. The tracer follows each one into `node_modules`, file by file.

We tried `bundlePagesRouterDependencies: true`. It bundles dependencies into the server chunks, and the tracer then followed 5,880 files. Locally, tracing dropped from 45 seconds to 25, and the whole build from 96 to 54. A few packages needed to stay external: a native binding, a CommonJS package that required an ESM-only one, and an old SDK that resolves its own modules by name at runtime.

We reverted it before it shipped. It changes how every server dependency loads in production. Our smoke tests passed, but we could not test every logged-in flow, and the gain was about a minute. That was not worth the risk to a system that works.

## 4\. Turbopack (the build went from 2m22s to 16s)

Next.js 16 builds with Turbopack by default. We had pinned webpack with `--webpack` because the config had a custom `webpack()` function. A Turbopack build stopped on three errors, and none of them were about that function. They had two causes:

1. An old email SDK used an AMD `define()` form that Turbopack cannot parse. It belonged to a provider we had already moved away from, so we deleted the integration.

2. Two WebAssembly code formatters in our editor, for Go and shell, export names with dots in them, such as `syscall.seek`. Turbopack generated invalid JavaScript for both. We switched all seven of these formatters from their `bundler` entry, which imports the `.wasm` as a module, to their `web` entry, which fetches it on demand. Two other formatters already worked that way.


With those fixed, a clean local build took 16 seconds instead of 96. We also removed `@next/bundle-analyzer`, because it injects a webpack config, and Next refuses a Turbopack build when one is present.

### The bug that only Turbopack found

Testing the branch, the custom domain page crashed with `TypeError: (void 0) is not a function`.

Our `tsconfig.json` had a `paths` entry that pointed the `psl` package at its own type declaration file. It existed because `psl`'s `exports` map does not point TypeScript at its types. Webpack ignored the alias at runtime. Turbopack applies tsconfig `paths` to real imports, so `import { parse } from 'psl'` loaded a `.d.ts` file with no code in it.

Three server routes imported `psl` the same way, so this would have broken in production too. We removed the alias and declared the types in a local `.d.ts` instead.

If you are moving to Turbopack, check every `paths` entry in your tsconfig that points into `node_modules`.

## 5\. Smaller changes that added up

**Health check timing.** The container health check ran every 15 seconds with 3 retries. Our server is ready about 2 seconds after it starts, and the first check only runs one interval later. We changed it to every 5 seconds with 9 retries. A task is still marked unhealthy only after about 45 seconds of failures, so a slow but live task is not killed any sooner. The next rollout was about 55 seconds faster.

**Polling.** The deploy job polled ECS every 10 seconds. It now polls every 5.

**A flaky package check.** The check that loads every package in the image spawns a child process per package. It resolved each child on the `exit` event, and `exit` can fire before the child's output has been read. With more parallelism, it reported 12 to 20 harmless packages as failures per run. Waiting for `close` fixed it, and running the children from a queue instead of fixed batches made the check about 15% faster.

**One fewer job.** Migrations ran in their own job, on their own runner, which spent 35 seconds on setup for a 2 second migration. They now run as the first steps of the deploy job, before the AWS credentials are loaded, so the database URL and the AWS keys are never in the same steps. That saved about 30 seconds.

## What we did not do

- **Lazy image loading (SOCI).** The image pull takes 7.5 seconds, so it could save at most that, and building the index would add more time than that to every build.

- **Bigger runners.** The slow part, tracing, was mostly a single core. Turbopack solved it instead.

- **Bundling server dependencies.** Covered above.


## Where the 5 minutes go now

Stage

Time

Runner start-up and waits between jobs

~45s

Build job: cache restore, `next build`, image push, package check

1m51s

Migrations

2s, plus 14s of setup

ECS: about 48s before the task exists, then pull, start, health check

1m45s

The biggest remaining piece is ECS. About 48 seconds of it is AWS placing the task, which we do not control. Getting much lower would mean different infrastructure, and 5 minutes is fast enough for us.

## What we would tell another team

1. Measure each step before you change anything. Our two biggest wins were a 2.5 minute wait nobody needed and a cache that had never worked.

2. Read the comments in your pipeline as claims to verify. Ours described a cache that did not exist.

3. Test container changes on the platform that runs them. Local Docker and Fargate disagreed about one environment variable.

4. When a slow step is a black box, profile it. "Collecting build traces" looked like Next.js internals until a profile showed it was parsing 13,000 files.

5. Revert what you cannot verify. We dropped a change that saved a minute because we could not test it well enough.


* * *

If your company needs a blog that loads fast and ranks, without a CMS to maintain, [try Superblog](https://superblog.ai).


---
This blog is powered by Superblog. Visit https://superblog.ai to know more.
---

