A JavaScript codebase ages faster than almost anything else you'll work on. Five years is a generation. The build tool that shipped your first version has been deprecated twice. The framework's idiomatic patterns from 2020 are now footnotes in a migration guide. Half the dependencies you picked because they had 30k stars have been quietly abandoned. The "temporary" Express 4 service is still running, and Express 5 came out, and now Express 6 is on the horizon. Node 14 left LTS, then 16, then 18, then 20. The TypeScript version your linter expects is two majors behind the one your IDE ships with.
None of that is the hard part.
The hard part is that the code itself, the actual logic that earns the company money, also drifts. Three engineers ago, someone needed a quick fix and reached across two modules. The PR was approved. Now there are eleven places that reach across, the boundary is a fiction, and refactoring the thing on either side requires a meeting. Multiply that by six years and you don't have a codebase anymore. You have a sediment layer.
This piece is about not ending up there. It's not a list of best practices in the abstract, those age worse than the code. It's about the small, almost-boring decisions that compound: where the boundaries live, which lint rules are worth fighting over, how you decide a dependency is worth taking on, the tests that survive the next refactor, and the docs that actually get read. Most of it is dull. That's the point. Boring code that's still readable in 2032 is the whole game.
Boundaries Are The Thing That Actually Decays
If you only fix one category of problem in a long-lived JS codebase, fix this one. Everything else, the lint rules, the tests, the docs, is downstream of whether the boundaries hold.
A "boundary" here means: this folder is allowed to depend on those folders, and nothing else. The HTTP layer can import from the service layer. The service layer can import from the data layer. The data layer can't import from the HTTP layer. Five years in, this is the rule that everyone agreed on in week one and nobody can quite remember the shape of anymore.
The reason it decays is that the language doesn't help you. JavaScript's module system is flat. import { something } from "../../../other-feature/internals/private-helper.js" works exactly as well as importing from a public entry point. There's no internal keyword, no package private modifier, no shouting from the compiler. The only thing standing between you and the worst possible architecture is the next reviewer's attention span, and the next reviewer is tired.
The cure is not "discipline." Discipline is what people without enforcement call the thing they wish they had. The cure is to make the boundary something a machine checks on every PR.
In practice, three layers of enforcement compound nicely:
1. Workspaces, even when you don't ship multiple packages. A monorepo with npm/pnpm/yarn workspaces lets you split your codebase into named packages (@yourorg/api, @yourorg/services, @yourorg/data) that explicitly declare what they depend on in package.json. The moment @yourorg/data tries to import from @yourorg/api, it fails to resolve, not because anyone wrote a rule, but because it isn't in the dependency list. You get architectural enforcement as a side effect of how the package manager works. This is the single biggest leverage point. The setup cost is one afternoon. The payback is years.
2. A linter rule that bans cross-boundary imports. ESLint's no-restricted-imports (or eslint-plugin-boundaries for more complex topologies) can encode "modules in this glob may only import from these globs." It's the seatbelt: workspaces are the structural enforcement, the lint rule catches the cases where someone tried to be clever inside one package.
3. A public surface per module. Each feature folder has an index.ts that re-exports exactly the things outsiders are allowed to call. Everything else is deep-imported only by accident, and that accident is what the lint rule catches. Five years from now, you can change anything not in index.ts without worrying about the blast radius.
The smell to watch for, in any code review: a relative import that goes up three or more levels (../../../). That's almost always a boundary violation in disguise. Either the file is in the wrong place, or it's reaching for something it shouldn't have.
Tests Are A Contract With Your Future Self, Not Your Coworkers
Most advice about testing is written for greenfield projects, where the question is "what coverage target should we hit." That's the wrong frame for a codebase you want to keep alive for ten years. The right frame is: which tests will still be true, and still be useful, after three major refactors?
Coverage percentages tell you nothing about that. A codebase with 90% coverage made up entirely of unit tests against private functions is a maintenance trap. Every refactor breaks dozens of tests that were never really about behavior, just about implementation. The team learns that tests are an obstacle, and the next big change ships with the tests commented out.
A better mental model: tests are written at the layer where the contract is stable. Stable means: the inputs and outputs would be the same regardless of how you implemented the inside. For a web app, that almost always means the HTTP boundary, given a request, you get a response, and what happens in between is implementation detail. For a library, it's the public API. For a worker, it's the input message and the side effects (rows written, events emitted).
This is why the modern advice has shifted toward the "testing trophy" shape, heavy on integration, lighter on unit. Not because unit tests are bad, but because unit tests anchored to internals are bad. A unit test on a pure function that transforms a date string is fine, it'll be true in 2032. A unit test on a class method that mocks out four collaborators is a fossil before the PR is even merged.
In a JavaScript project specifically, a few habits compound:
Use a real test runner, not a custom rig. Vitest in 2024-2026 has become the obvious default for most projects, it's fast, its config inherits from Vite, and its API is Jest-compatible enough that migration is mostly find-and-replace. Jest still works fine for projects that already use it. The thing to avoid is hand-rolled scripts that "feel lighter", they always grow into a worse version of the runner you should have used.
Hit a real database in your integration tests. Mocking the database is the single worst leverage move in JS testing. The mock passes; the migration drops a column; the test still passes; production breaks. Spin up a real Postgres in Docker, wrap each test in a transaction that rolls back, and accept the 30 seconds it adds to your test suite. You'll get it back the first time you avoid a bad deploy.
Don't snapshot-test things that aren't actually snapshots. Snapshot tests are great for rendered output that should not change without a designer noticing. They are terrible for objects that include timestamps, IDs, or anything else that legitimately varies. A codebase that gets into the habit of --updateSnapshot to make CI green has effectively turned off its tests.
Write the test that would have caught the last bug. When you fix a production bug, the question to answer in the PR isn't "did I fix it", your reviewer can see that. It's "what test will catch the next one like this." If you can't answer, the fix is incomplete.
The compounding effect of these habits is subtle. A team that writes integration tests against the HTTP boundary can rewrite their entire service layer over a long weekend without the test suite caring. A team that's spent five years writing unit tests against mocked collaborators cannot, and so they don't, and the codebase keeps drifting.
Lint Rules Worth Fighting About
The argument over ; vs no ; is the boring kind of lint debate. The interesting debates are about rules that change how the code is written, not just how it's formatted. Formatting is a solved problem, run Prettier, accept its opinions, never look back. Eight years of bikeshedding solved by one config file.
The rules that earn their keep are the ones that catch a real category of bug, every time, with no judgement call. Here are the ones worth being annoying about:
@typescript-eslint/no-floating-promises. Easily the highest-leverage rule for TS codebases. Catches every await-you-forgot. In an async-heavy Node service, this rule alone prevents an entire class of production bugs, the silent one where a promise rejection becomes an unhandled rejection three event-loop ticks later and the request hangs. Turn it to error. Don't disable it for "convenience." If you need fire-and-forget, write void doTheThing() explicitly, so the next reader knows you meant to.
@typescript-eslint/no-misused-promises. The sibling rule. Catches if (someAsyncFn()) (which checks truthiness of a Promise object, always truthy) and array methods that get async callbacks where sync was expected. Both are bugs that pass code review every time without it.
no-restricted-syntax for legacy patterns you want to retire. If your team has decided that lodash.cloneDeep is banned in favor of structuredClone, encode that. If certain Date constructors are off-limits because they have timezone footguns, encode that. The point isn't the specific rule, it's that the decision to ban something has to live in the linter, not in a wiki page nobody reads.
eslint-plugin-import with no-cycle. Circular imports between modules are the leading indicator of a boundary collapse. They also produce real, observable runtime bugs in Node ESM under certain load orders. Turn this rule on. Fixing the few cycles that come up is almost always worth it.
@typescript-eslint/no-explicit-any. Hard mode rule, but the right one for the long term. Set to warn if the team isn't ready; set to error once they are. Every any is a hole in the type system, and after a few years every hole accumulates a small cult of // @ts-ignore comments around it.
no-restricted-imports for boundary enforcement (covered above).
The rules that are not worth fighting over: anything that's purely stylistic and Prettier has an opinion about, anything that needs five eslint-disable-next-line comments per file to live with, anything that fires on third-party code shapes you can't change. A linter that everyone hates gets disabled, locally, then in CI, then in spirit.
TypeScript's compiler is half a linter. Don't forget it exists. "strict": true should be on. "noUncheckedIndexedAccess": true is worth its weight in gold, it catches the arr[0] returning undefined case that bites every JS developer at some point. "exactOptionalPropertyTypes": true is a stricter step beyond that, useful once a codebase is otherwise clean. These aren't lint rules but they belong in the same conversation: they're decisions made once, enforced forever, that close off categories of bug.
Dependencies: A Policy, Not A Vibe
The single most underrated maintainability decision in JS land is "what is the cost of adding this dependency." Every team has an answer in their head. Very few teams have written it down. Without writing it down, the answer drifts depending on who's reviewing.
A dependency policy doesn't have to be long. A page is plenty. Here's the shape of one that survives turnover:
1. Adding a dependency requires a PR comment that answers three questions.
- What's the minimum surface area you actually need? (Often you need 30 lines of code from a package that ships 12MB.)
- Is the package actively maintained? (Last release within ~12 months, open issues are getting responses, the maintainer hasn't published a breakup letter.)
- What's the cost of removing it later? (Is its API leaking through your code, or is it hidden behind one adapter?)
If the answer to "minimum surface area" is "one function we could write ourselves in an afternoon," write it yourself. The left-pad and is-odd jokes are jokes for a reason. Every package added is a future incident: a supply-chain compromise, a license change, a breaking semver-major you didn't notice, a maintainer who quit. The right number of dependencies is not zero, but it's lower than the average JS project has.
2. Renovate or Dependabot, set up on day one. This is the single most underrated piece of infrastructure. A bot that opens a PR every time a dependency releases an update keeps your tree shallow and current. The trick is configuration: group patch and minor updates into one PR (per week or per month), keep majors as individual PRs you actually read. Auto-merge the green ones for devDependencies once the team trusts the test suite. Without this, the codebase accrues a year of pending updates, the upgrade becomes a project, and the project gets indefinitely deferred.
3. A lockfile, committed, no exceptions. package-lock.json, pnpm-lock.yaml, or yarn.lock, pick one, commit it, never delete it to "fix" an install error. Running npm install without an existing lockfile in a production deploy is how you ship a different version of a transitive dependency than the one you tested.
4. A pinning policy that matches what you actually want. Caret ranges (^1.2.3) on direct dependencies are fine for application code; the lockfile pins the resolved version. Exact pins (1.2.3) are right for libraries you publish, where consumers will compose your version range with theirs. The pathology to avoid is mixing the two: a project where some dependencies are pinned exact and others are floating, with no documented reason, just historical accident.
5. Audit, but don't auto-fix. npm audit produces a wall of noise; npm audit fix --force produces a tree of unrelated breakage. The middle ground is reading the report, fixing the things that genuinely affect your runtime (front-end packages used in the server bundle, server packages with remote-exploit advisories), and explicitly ignoring the rest with a justification. Tooling like socket.dev or osv-scanner does a better job here than the built-in audit.
6. Lockfile drift in CI. Run npm ci (or pnpm install --frozen-lockfile) in CI, never npm install. The former fails if package.json and the lockfile disagree; the latter quietly mutates the lockfile and lets the discrepancy ship. This one-character difference catches more "works on my machine" bugs than any debugger.
The Node version story. Decide which Node major you target, write it in engines.node and .nvmrc, and re-evaluate twice a year, when the next LTS ships and when your current LTS approaches end-of-life. Node releases an even-numbered major every April that enters LTS in October; LTS is maintained for thirty months from the major release. (Node 20 LTS, for example, ends in April 2026; Node 22 in April 2027.) Skipping versions until your runtime is on a dead branch is the most expensive form of "we'll get to it later." A team that upgrades every other April pays about half a day each time; a team that defers for four years pays a sprint of incident-driven panic.
Docs That Pay Rent
Most documentation in JavaScript projects is written once, never read, and quietly diverges from the code over the next two years until it's actively misleading. The fix is not "write more docs." It's to be ruthless about which docs are worth the maintenance cost.
The ones that pay rent:
A README that answers four questions. What does this project do, in one sentence. How do I run it locally, including any required env vars and external services. How do I run the tests. How do I deploy. That's the whole README for most projects. Anything beyond that is either a separate doc or premature. A new engineer should be able to clone, install, and have something running in under fifteen minutes, the README is the script for that.
An ARCHITECTURE.md. One page. Names the major modules, draws the boundaries (in ASCII or prose, both fine), and explains the one or two big decisions that aren't obvious from reading code. "We chose Postgres over MongoDB because most of our reads are joins over five entities." "We split the worker out from the API because the worker holds long-lived WebSocket connections to a third-party and the API needed to be horizontally scalable independently." The point is to compress the months of context a new engineer would otherwise have to absorb from standups into something they can read in five minutes.
ADRs (Architecture Decision Records) for choices the team will second-guess later. ADRs are short, usually under a page, and they record: the decision, the context, the alternatives considered, and the consequences. The format isn't sacred; the discipline is. Six months from now, when someone asks "why do we use Drizzle instead of Prisma," the answer should be a numbered file in docs/adr/, not a Slack search. The cost of writing an ADR is twenty minutes. The cost of not writing one is that the question gets re-litigated every time a new engineer joins.
Code comments that explain why, not what. A comment that says // loop through the users next to a for loop is noise. A comment that says // We deliberately don't deduplicate here — duplicates in this list are how downstream reconciliation detects retries. is gold. The rule of thumb: if removing the comment would make a future reader misunderstand a non-obvious decision, the comment earns its place. If removing it would make no difference, delete it.
JSDoc on the public surface of every module. Not exhaustive, just on the things that show up in someone else's import statement. TypeScript types cover the shape; JSDoc explains the intent. Editors render it on hover. It's the cheapest possible developer experience improvement, and it survives refactors because it lives next to the code.
No docs for things that change weekly. Don't document your sprint goals in the repo. Don't document the current org structure. Don't put a list of who owns what subsystem in OWNERSHIP.md if that list changes every quarter, use the CODEOWNERS file, which has real teeth (it auto-requests reviewers), instead of prose that lies six months in.
The general rule for documentation in a long-lived project: a doc must be cheap enough to keep current that someone actually will. A 50-page wiki page that takes two hours to update is going to be wrong. A six-line ADR that takes ten minutes is going to be right. Length is correlated with rot. Treat it as a cost.
The Boring Habits That Compound
There's a category of habit that doesn't fit any of the above sections but that, over five years, is the difference between a codebase that ages well and one that doesn't. None of them are clever. All of them are slightly annoying to enforce until they're automatic.
One CI pipeline. Not seventeen. Tests, lint, type-check, build, all of it lives in one pipeline that runs on every PR. Multiple parallel jobs are fine; multiple pipelines that each have their own caching configuration are a maintenance disaster. The pipeline's job is to be the single source of truth for "is main green right now." If engineers ever have to remember which checks are required for which kinds of changes, you've lost.
A "merge queue" or equivalent. Once a PR is approved, it should not be the engineer's job to babysit it through CI. GitHub merge queues, Bors, Mergify, pick one. Without it, you get the "I'll merge after standup" delay that turns a 10-minute merge window into a half-day. Multiply by twenty engineers and the cost is enormous.
Auto-formatting on save and on commit. Prettier (or Biome, the newer Rust-based alternative that's starting to take real share around 2025) configured at the editor level and as a pre-commit hook. The goal is for formatting discussions to literally not happen, the moment the file is saved, it's correct, and the moment it's committed, it's enforced.
A package.json scripts block that's a finished menu. npm run dev, npm run build, npm run test, npm run lint, npm run typecheck, and ideally npm run check that runs lint + typecheck + tests in one go. Engineers learning the codebase shouldn't have to read the README to figure out what commands to run. The scripts block is the README for most day-to-day work.
Errors with breadcrumbs, not just messages. Every error in production should carry enough context to identify the request that produced it without grepping logs. Structured logging (pino, winston with JSON output) plus request IDs plus error wrapping. A codebase where every error is a string with no context is a codebase that takes six hours to debug what should take six minutes, every time.
A feature-flag mechanism that exists before you need it. When you eventually need to ship a risky change in pieces, you don't want to be designing the flag system on the same day. Pick something, LaunchDarkly, Unleash, Flagsmith, or a homegrown table backed by your config service, and have it wired through your app before the first feature actually needs it. The first real use is what teaches you whether your design works.
A pre-commit hook that runs the cheap checks. lint-staged + husky runs Prettier and the linter on changed files in under a second. Cheap enough that no one notices; valuable enough that no broken-formatting PRs reach review.
Owning your build tooling, but lightly. A vite.config.ts or tsconfig.json you actually understand is worth ten preset packages you don't. The pathology to avoid is the opposite: a six-thousand-line custom Webpack config maintained by one person who left in 2022 and that no one dares to touch. Prefer mainstream tools used in their default modes; fork them only when you have a real reason.
What "Maintainable" Actually Means Five Years In
A maintainable JavaScript project is not one that's clean. It's one where the things that matter, the things you'll need to change to keep the product alive, are changeable. The boundary you can move. The dependency you can swap. The test that still tells you whether the system works. The doc that's still true. The lint rule that still fires. The teammate, three engineers from now, who can ship a feature in their second week instead of their second month.
None of this is about achieving a perfect state. JavaScript projects never reach a perfect state. They reach a workable state, and the question is whether they stay workable as the world around them changes. The answer is almost always: yes, if someone keeps repainting the lighthouse.
The good news is that the cost of maintenance is mostly fixed. An hour a week per engineer, spent on the boring things, closing the gaps that opened over the last sprint, reading the Renovate PRs, fixing the lint rule that started complaining, writing the one-paragraph ADR, deleting the stale comment, keeps a project in good shape essentially indefinitely. Skipping that hour for a quarter doesn't show up immediately. Skipping it for two years shows up as a rewrite proposal.
You don't avoid the rewrite proposal by being brilliant. You avoid it by being slightly more disciplined than the average codebase for slightly longer than feels worth it. That's the whole game. The lighthouse is fine. Someone just has to keep repainting the bands.





