PerformanceCI/CDTurborepoNext.jsDevOpsArchitecture

How I Reduced a Client's Build Time from 6 Hours to 15 Minutes

Six hours per deployment. No caching. No parallelization. Developers who had stopped deploying on Fridays out of fear. Here's the exact audit and the fixes that changed everything.

L

Lazar Kapsarov

April 12, 2025 · 11 min read

The first thing the lead developer told me when I joined this project: "We don't deploy on Fridays."

Not because of policy. Because a broken Friday deployment meant six hours of CI before you'd know if the fix worked. Nobody wanted to be the person who broke production at 4 PM and then watched a progress bar until 10.

Six hours. For a Next.js application that wasn't even particularly large.

This is what I found, what I fixed, and how we got to 15 minutes.

The Audit

Before touching anything, I spent two days just watching. Watching the CI pipeline run. Reading the logs. Mapping every step and how long it took.

This is the step most developers skip. They see a slow build and immediately start changing things. Then they can't tell what actually helped.

I wanted a baseline. Here's what I found.

The Pipeline as I Found It

Total: ~6 hours (varies 5h 40m – 6h 20m)

Step 1:  Install dependencies          → 18 minutes
Step 2:  Type checking                 → 24 minutes
Step 3:  Lint                          → 31 minutes
Step 4:  Unit tests                    → 2h 10m
Step 5:  Integration tests             → 1h 45m
Step 6:  Build (production)            → 52 minutes
Step 7:  E2E tests (Playwright)        → 48 minutes
Step 8:  Deploy to staging             → 12 minutes
Step 9:  Smoke tests                   → 14 minutes

(Steps run sequentially, one after another)

Looking at this, three things jumped out immediately.

Everything ran sequentially. Type checking finished, then linting started. Linting finished, then tests started. Six hours of work that could have been partially parallelized was instead queued in a single lane.

Dependencies were installed fresh every run. No caching. Eighteen minutes of npm install on every single pipeline run, even when package.json hadn't changed in weeks.

Tests took four hours combined. This warranted its own investigation.

Problem 1: Zero Caching

The dependency install was the first quick win.

# Before — in GitHub Actions
- name: Install dependencies
  run: npm install

That's it. No cache. No lockfile optimization. Just a full reinstall every time.

# After
- name: Cache node_modules
  uses: actions/cache@v4
  with:
    path: |
      ~/.npm
      node_modules
    key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-node-

- name: Install dependencies
  run: npm ci

The cache key is a hash of package-lock.json. If the lockfile hasn't changed, the cache is restored and npm ci completes in about 30 seconds instead of 18 minutes.

Result: 18 minutes → 30 seconds on cache hit runs (which was the vast majority).

Problem 2: Sequential Everything

The next issue was the linear pipeline. Type checking, linting, and tests have no dependency on each other. There's no reason type checking needs to finish before linting starts.

GitHub Actions supports parallel jobs natively.

# Before — one long sequential job
jobs:
  pipeline:
    steps:
      - typecheck
      - lint
      - test
      - build
      - deploy

# After — parallel where possible
jobs:
  install:
    steps:
      - cache & install

  quality:
    needs: install
    strategy:
      matrix:
        task: [typecheck, lint]
    steps:
      - run: npm run ${{ matrix.task }}

  test:
    needs: install
    steps:
      - run: npm run test

  build:
    needs: [quality, test]
    steps:
      - run: npm run build

  deploy:
    needs: build
    steps:
      - deploy to staging

Type checking and linting now run simultaneously. Tests run in parallel with both. Build only starts when quality checks and tests pass. The critical path gets dramatically shorter even though the total work hasn't changed.

Result: Roughly 55 minutes of sequential quality steps became ~25 minutes of parallelized ones.

Problem 3: The Test Suite

Four hours of tests was the number that demanded real investigation. I asked to see the test suite.

What I found was a combination of problems that had accumulated over two years of development without anyone ever auditing the tests themselves.

Problem 3a: Tests Hitting a Real Database

The integration test suite was running against a live staging database over the network. Every test that inserted a record, read it back, and cleaned it up was paying the round-trip latency of an actual database call.

// What I found — hitting real database in unit tests
describe("UserService", () => {
  it("creates a user", async () => {
    const user = await UserService.create({
      email: "test@example.com",
      name: "Test User",
    });
    // This hit a real Postgres database over the network
    expect(user.id).toBeDefined();
    await db.delete(users).where(eq(users.id, user.id));
  });
});

These weren't integration tests. They were unit tests with production dependencies. Slow, fragile, and meaningless as a safety net because they were testing the database, not the service logic.

// After — mocked database in unit tests
vi.mock("@company/database", () => ({
  db: {
    insert: vi.fn().mockReturnValue({
      values: vi.fn().mockReturnValue({
        returning: vi
          .fn()
          .mockResolvedValue([{ id: "mock-id", email: "test@example.com" }]),
      }),
    }),
  },
}));

describe("UserService", () => {
  it("creates a user", async () => {
    const user = await UserService.create({
      email: "test@example.com",
      name: "Test User",
    });
    expect(user.id).toBe("mock-id");
  });
});

Proper unit tests: no network, no database, no cleanup. Microseconds per test instead of hundreds of milliseconds.

Problem 3b: No Test Parallelization

The test runner was Vitest, which supports parallel execution natively. It was configured to run tests sequentially.

// vitest.config.ts — before
export default defineConfig({
  test: {
    pool: "forks",
    poolOptions: {
      forks: {
        singleFork: true, // ← this was killing performance
      },
    },
  },
});

// vitest.config.ts — after
export default defineConfig({
  test: {
    pool: "threads",
    poolOptions: {
      threads: {
        maxThreads: 4,
        minThreads: 2,
      },
    },
  },
});

With parallelization enabled and the database calls removed, the unit test suite went from 2 hours 10 minutes to 11 minutes.

Problem 3c: Integration Tests Without Isolation

The integration tests — the ones that legitimately needed a real database — were sharing state. Test A would insert records. Test B would query and accidentally pick up Test A's records. Tests were interdependent in ways nobody had intended, and to compensate, the test suite had grown to cover every possible state combination defensively.

The fix: a fresh database transaction per test, rolled back at the end.

// vitest.setup.ts
import { db } from "@company/database";
import { sql } from "drizzle-orm";

beforeEach(async () => {
  await db.execute(sql`BEGIN`);
});

afterEach(async () => {
  await db.execute(sql`ROLLBACK`);
});

Each test runs inside a transaction that never commits. The database is always clean. Tests are independent. And with proper isolation, a third of the defensive duplicate tests were revealed to be unnecessary — they were removed, shrinking the test suite considerably.

Integration test result: 1h 45m → 18 minutes.

Problem 4: The Build Itself

The production build was taking 52 minutes for a Next.js app. This was the longest individual step after the tests.

I looked at what was being built.

The app had accumulated over 200 npm packages. A significant number of them were being bundled into the client-side JavaScript — including packages that had no business being in the browser. I ran @next/bundle-analyzer and what came back was a picture of neglect: moment.js (with all locales), lodash (full build), several large charting libraries loaded on every page regardless of whether that page used them.

// next.config.ts — added bundle analyzer
import bundleAnalyzer from "@next/bundle-analyzer";

const withBundleAnalyzer = bundleAnalyzer({
  enabled: process.env.ANALYZE === "true",
});

export default withBundleAnalyzer({
  // existing config
});

Running ANALYZE=true npm run build showed exactly where the weight was.

Fixes:

  • Replaced moment with date-fns (tree-shakeable, only import what you use)
  • Replaced full lodash with individual lodash-es imports
  • Added dynamic imports for charting libraries so they only load on pages that use them
// Before
import { BarChart } from 'recharts'

// After — dynamic import, only loads when the component renders
const BarChart = dynamic(() => import('recharts').then(m => m.BarChart), {
  ssr: false,
  loading: () => <ChartSkeleton />
})

Beyond the bundle, the build itself wasn't using persistent caching between runs.

// next.config.ts
export default {
  experimental: {
    turbotrace: {
      logLevel: "error",
    },
  },
  // Ensure Next.js uses persistent cache
  cacheHandler:
    process.env.NODE_ENV === "production"
      ? require.resolve("./cache-handler.js")
      : undefined,
};

In CI, restoring .next/cache between runs meant incremental builds instead of full rebuilds when only a few files changed.

- name: Cache Next.js build
  uses: actions/cache@v4
  with:
    path: .next/cache
    key: nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.ts', '**/*.tsx') }}
    restore-keys: |
      nextjs-${{ hashFiles('**/package-lock.json') }}-

Build result: 52 minutes → 8 minutes on incremental builds.

Problem 5: Turborepo Wasn't Being Used

The project was already a monorepo — but it was using a basic Yarn workspaces setup without a build orchestrator. Every CI run rebuilt every package from scratch regardless of what had changed.

I introduced Turborepo with remote caching:

// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "remoteCache": {
    "enabled": true
  },
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "!.next/cache/**", "dist/**"],
      "cache": true
    },
    "test": {
      "dependsOn": ["^build"],
      "outputs": ["coverage/**"],
      "cache": true
    },
    "lint": {
      "cache": true
    },
    "type-check": {
      "dependsOn": ["^build"],
      "cache": true
    }
  }
}

With remote caching linked to Vercel:

npx turbo link

After the first full run, every subsequent run that hadn't changed the relevant source files got cache hits. A PR that only touched the web app didn't rebuild the packages/ui component library. A PR that only touched documentation didn't run the application test suite.

Turborepo knew what changed. It only built and tested what was affected.

The Final Numbers

Before:
Total pipeline: ~6 hours
  Dependencies:        18 min
  Type check:          24 min
  Lint:                31 min
  Unit tests:         2h 10m
  Integration tests:  1h 45m
  Build:               52 min
  E2E tests:           48 min
  Deploy + smoke:      26 min
  (All sequential)

After:
Total pipeline: ~15 minutes
  Dependencies (cached):    30 sec
  Type check + Lint (||):    6 min
  Unit tests (parallel):    11 min  ← runs alongside quality checks
  Integration tests:        18 min  ← runs alongside quality checks
  Build (incremental):       8 min
  E2E tests (parallel):     12 min
  Deploy + smoke:            5 min
  (Critical path: ~15 min)

The work didn't get faster. There's the same amount of type checking, linting, testing, and building. What changed: things that could run simultaneously now run simultaneously, things that could be cached are cached, and things that were doing unnecessary work stopped doing it.

What This Changed for the Team

The 15-minute pipeline changed behavior immediately.

Developers started deploying more frequently. Smaller PRs. Faster feedback loops. The psychological weight of "I have to wait six hours to know if this works" had been suppressing iteration speed in ways that weren't visible in any metric but were obvious once they were gone.

Friday deployments came back. Small ones, confident ones — not the anxious, fingers-crossed deployments of before.

The lead developer who told me about the Friday rule on my first day sent me a message four weeks after the work was done: "We deployed at 4:30 PM on a Friday and nobody even mentioned it. That's how it's supposed to work."

That's the result that matters. Not the minutes. The confidence.


Lazar Kapsarov is a frontend architect at PrismaFlux Media. If your team's CI/CD pipeline is blocking your velocity, book a free strategy call — I'll audit your pipeline and show you exactly where the time is going.

:: NEXT STEP

Have a frontend architecture problem?

Book a free 30-minute strategy call. I'll audit your current setup and show you exactly where you're losing time and money.

Book Free Strategy Call →