The Real Cost of Technical Debt: A Case Study

I inherited a three-year-old Next.js app in November. The previous team of five had shipped fast for two years, then slowed to a crawl. New features took weeks. Bugs appeared randomly. Two engineers quit. Here's what we found and how we fixed it.

The Numbers Up Front

MetricBeforeAfter (3 months)Change
Time to add a page8 days1.5 days81% faster
Open bugs47883% reduction
Test coverage12%68%+56%
Build time11 min3 min73% faster
Dev onboarding3 weeks4 days72% faster
Monthly incidents6183% reduction

The debt had a number. We just hadn't measured it yet.

The Biggest Culprits

1. Copy-pasted API logic (43 files, 12 variations)

Every component fetched data differently. Some used fetch. Some used axios. Some used a custom hook that hadn't been updated in 18 months. Two used GraphQL. The authentication header logic existed in 17 places.

// This pattern appeared 12 times with minor variations
const fetchUser = async (id) => {
  const token = localStorage.getItem('token'); // Different key in 3 files
  const res = await axios.get(`/api/users/${id}`, { // Sometimes fetch
    headers: { Authorization: `Bearer ${token}` } // Sometimes "Bearer", sometimes "Token"
  });
  return res.data; // Sometimes .user, sometimes .data.user
};

What we did: One data layer. React Query for all server state. One apiClient with interceptors. Deleted 2,100 lines of duplicated code. 2. The 4,000-line component A single Dashboard.tsx file that handled analytics, user management, notifications, and settings. It used 37 useState hooks. Effects triggered other effects. It took three hours to understand what it actually did.

// Not even exaggerating
function Dashboard() {
  const [tab, setTab] = useState('analytics');
  const [analyticsData, setAnalyticsData] = useState(null);
  const [users, setUsers] = useState([]);
  const [notifications, setNotifications] = useState([]);
  const [settings, setSettings] = useState({});
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [filter, setFilter] = useState('all');
  // ... 30 more state variables
  
  useEffect(() => {
    // Fetch analytics
    // Fetch users
    // Fetch notifications
    // All in one effect
    // No cleanup
  }, []);
  
  useEffect(() => {
    // This one triggered re-fetches
    // Caused infinite loops twice in production
  }, [analyticsData, users, notifications]);
  
  // 400 more lines
}

What we did: Split into six route-level pages. Each page had its own loading and error states. The dashboard page dropped from 4,000 lines to 180. The child components each under 300 lines. 3. ## State stored in URL query params for everything The previous team discovered Next.js useSearchParams and went wild. Filters, sort order, pagination, modal open state, form draft data — everything lived in the URL. This caused massive re-renders on every keystroke.

// This ran on every character typed
const [search, setSearch] = useQueryState('search');
const [filter, setFilter] = useQueryState('filter');
const [page, setPage] = useQueryState('page');
const [sort, setSort] = useQueryState('sort');
const [modalOpen, setModalOpen] = useQueryState('modal');
const [formData, setFormData] = useQueryState('form'); // 4kb of JSON in the URL

<input 
  onChange={(e) => setSearch(e.target.value)} // Debounce? Never heard of it
/>

What we did: Kept URL state for shareable things (filters, page, sort). Moved everything else to Zustand or component state. Added debouncing to search inputs. The re-render count dropped by 94%.

  1. No tests except "it compiles"

Twelve percent coverage. Most of that was the initial setup file that Jest generated. One API change broke five features and we didn't know until a user reported it.

What we did: Wrote integration tests for critical paths first (checkout, login, payment). Added unit tests for utility functions. Made tests a CI requirement. The first week of tests caught 23 bugs we didn't know existed.

  1. The package.json nightmare

{
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "next": "13.4.0",
    "zustand": "^4.3.0",
    "zustand": "4.4.0", // Yes, twice
    "lodash": "4.17.21",
    "lodash": "^4.17.21",
    "moment": "2.29.4",
    "dayjs": "1.11.0", // Both moment AND dayjs
    "axios": "1.4.0",
    "node-fetch": "2.6.0", // In a Next.js app
    // 47 more packages
  }
}

Duplicate packages. Multiple date libraries. A node-fetch dependency in Next.js 13 (which has fetch built in). Versions pinned inconsistently.

What we did: Cleaned duplicates. Removed unused packages (17 of them). Updated Next.js to 14.2 (then 15 after testing). Switched to pnpm for faster installs. The node_modules size dropped from 850MB to 420MB.

**The Refactor Roadmap That Worked

We didn't rewrite everything. That would have killed us. Instead:

Month 1: Stop the bleeding

  • Added error boundaries and monitoring (Sentry)

  • Fixed the most common user-reported bugs

  • Wrote tests for the checkout flow (highest revenue impact)

  • Set up CI to prevent new debt

Month 2: Build the runway

  • Created the single API client

  • Migrated three routes to React Query

  • Split the dashboard monolith into pages

  • Added TypeScript to untracked files

Month 3: Pay down principal

  • Removed the URL state abuse

  • Deleted dead code (1,700 lines)

  • Updated all dependencies

  • Documented the new patterns

Month 4+: Normal maintenance

  • Incrementally add tests

  • Refactor as we touch files (boy scout rule)

  • No more copy-paste

What I'd Tell My Past Self

If I could go back to day one of this project:

  1. Measure before touching anything. I spent two weeks refactoring code that barely mattered. The dashboard component was bad, but the API duplication was the real killer. I should have profiled first.

  2. Get tests around the scary parts. I was afraid to refactor the payment flow because there were no tests. So I wrote tests first. Then refactoring became safe. Do this in reverse next time.

  3. Don't rewrite everything. I wanted to. Badly. The code was ugly. But the business needed features. The hybrid approach (touch only what you must) shipped value faster and still fixed the problems.

  4. Technical debt has a sprint velocity. We measured our velocity: 8 story points per week before refactor, 22 after. That's a number the business understands. "We're losing 60% of our speed" got budget approval. "The code is bad" did not.

The Hard Truth

Some debt is worth keeping. We found a janky CSV export function that everyone hated. Rewriting it would have taken three days. But it ran once a week for one user. We left it alone. Nobody has complained.

The debt you pay is the debt in the critical path. Everything else can wait forever.

My team stopped calling it "technical debt" halfway through. We started calling it "the reason we're slow." That reframing helped. Debt sounds abstract. Slow is concrete. And slow is what finally got us permission to fix it.