I’ve been building web applications since the days of raw PHP and hand-rolled authentication. Over the years, I’ve watched the pendulum swing hard toward abstraction. Frameworks now promise to get you from zero to a working app in minutes. They handle routing, state, data fetching, and even deployment with a few CLI commands. The pitch is seductive: focus on your business logic, not the plumbing.
But here’s what the marketing doesn’t tell you. These frameworks draw a bright line between what’s easy and what’s effectively impossible. They’re optimized for demos, not for the messy reality of production software. The moment you step off the happy path, you’re fighting the very tool that was supposed to help you.

The Happy Path Is a Straightjacket
Most modern frameworks are built around a set of conventions. Follow those conventions, and everything feels magical. You define a route, return some JSON, and the framework handles serialization, caching headers, and error formatting. For the first 80% of a project, it’s genuinely productive.
The trouble starts when you need that remaining 20%. Maybe you need a custom response format that doesn’t map to the framework’s serializer. Maybe your authentication flow involves multiple steps that don’t fit the middleware model. Suddenly, you’re reading source code, overriding internal methods, and discovering that the clean abstraction you relied on is a thin veneer over a pile of assumptions.
I’ve seen teams spend days trying to make a popular framework handle a state machine that spans multiple requests. The framework’s session handling assumed a linear flow. The workaround? Subclassing several core components and maintaining a fork of the library. The “simple” solution became a maintenance burden that outlasted the feature it was meant to support.
When Abstractions Become Walls
Good abstractions hide complexity without limiting capability. A filesystem API lets you read and write bytes without knowing about disk sectors. If you need to, you can still do low-level operations. Framework abstractions are different. They often hide complexity by removing access to the underlying layer.
Take ORMs as an example. An ORM makes basic CRUD operations trivial. You write object-oriented code, and it generates SQL. For simple queries, this is a dream. But when you need a recursive CTE, a window function, or a query plan hint, you’re stuck. The ORM either doesn’t support it or forces you to write raw SQL through an escape hatch that feels bolted on. You’re no longer using a tool; you’re fighting it.
This pattern repeats across the stack. UI frameworks that make rendering a list of items easy become a nightmare when you need drag-and-drop reordering with optimistic updates and conflict resolution. The framework’s state management assumes a unidirectional data flow. That doesn’t accommodate the messy back-and-forth of real-time collaboration.

The Complexity Budget
Every project has a complexity budget. You can spend it on accidental complexity—the friction introduced by your tools—or on essential complexity, the inherent difficulty of the problem you’re solving. Frameworks often trade one for the other without being honest about it.
They reduce accidental complexity for the common cases but introduce massive accidental complexity for uncommon ones. The net result? A system where the easy parts are very easy and the hard parts are nearly impossible. That’s not a good trade. In most non-trivial applications, the hard parts are where the value lives. Anyone can build a CRUD app. The differentiation comes from the custom logic, the integrations, the performance optimizations—the parts the framework doesn’t help with.
I’ve started evaluating frameworks by asking one question: what does this make impossible? If the answer is “nothing,” it’s probably lying. If the answer is “a few things I definitely need,” I walk away.
The Leaky Abstraction Trap
Joel Spolsky’s law of leaky abstractions is old news, but it’s never been more relevant. Frameworks pile abstraction on abstraction. React abstracts the DOM. Next.js abstracts routing and server-side rendering. Each layer leaks in its own way, and when something goes wrong, you have to understand all of them to debug it.
I recently debugged a production issue where a page was rendering stale data despite correct cache headers. The root cause? An interaction between the framework’s static generation, a CDN’s edge caching, and a misconfigured service worker. Three layers of abstraction, each working as designed, combining to produce incorrect behavior. Fixing it required understanding the internals of all three.
The promise was that I wouldn’t need to think about caching. The reality was that I had to think about it more than if I’d implemented it myself.
Signs You’re Working Against the Grain
There are patterns I’ve come to recognize as indicators that a framework is overstaying its welcome. If your codebase has more lines of configuration and workaround code than business logic, that’s a red flag. If your team regularly says “the framework doesn’t let us do that,” you’ve lost agency over your own code.
Another tell is the presence of “eject” mechanisms or escape hatches. These are admissions by the framework authors that their abstraction isn’t complete. The problem is that once you eject, you lose the benefits you adopted the framework for in the first place. You’re left with a half-custom, half-framework Frankenstein that’s harder to maintain than either extreme.
I’ve seen projects where the build configuration was so complex that onboarding a new developer took weeks. They weren’t learning the application; they were learning the toolchain. That’s accidental complexity, and it’s a tax you pay every day.

Choosing Tools That Scale With Complexity
I’m not advocating for building everything from scratch. That’s a recipe for reinventing wheels badly. But I am advocating for libraries over frameworks, composability over convention, and explicit over magic.
A library solves a specific problem. You call it; it does its job. If it doesn’t do what you need, you replace it with another library or write your own. A framework owns your application. It calls your code. It dictates the structure, the lifecycle, and often the deployment model. That inversion of control is the source of the problem.
When I evaluate a tool now, I look for composability. Can I use it for one part of my application without adopting its entire ecosystem? Can I swap out its components independently? Does it expose low-level primitives that I can build on when the high-level API isn’t enough?
Frameworks that pass this test are rare, but they exist. They tend to be smaller, less hyped, and more focused. They don’t promise to do everything. They just do one thing well and get out of the way.
The Cost of Framework Churn
There’s another cost that doesn’t get talked about enough: the churn. Frameworks rise and fall on roughly a five-year cycle. The hot framework today is legacy tomorrow. Your application, if it’s successful, will outlive the framework it’s built on.
I’ve been through enough rewrites to know they’re almost never a good idea. But framework-driven rewrites are forced on you. The old version is unmaintained. The new version has breaking changes. The ecosystem moves on, and you’re left maintaining a codebase that no one wants to work on.
Libraries don’t have this problem to the same degree. A good library can be stable for a decade. If it does need to be replaced, the surface area is small. You swap it out without rewriting your entire application.
What I Do Instead
My approach now is to start with the bare minimum. I use a standard library for the language, a few well-chosen libraries for things like routing and database access, and I write glue code that’s specific to my application. The glue code is the application. It’s not boilerplate; it’s the unique logic that makes the thing work.
This takes longer at the start. There’s no CLI command to scaffold a project with my exact preferences. But by the time I hit the first hard problem, I’m ahead. I’m not fighting an abstraction. I’m just writing code that solves a problem.
The code is simpler, too. There’s no magic. A new developer can read it top to bottom and understand what’s happening. There’s no hidden control flow, no annotation-driven behavior, no build-time transformations that change the semantics of the code.
When Frameworks Do Make Sense
I’m not dogmatic about this. There are contexts where a framework is the right call. Building a prototype that will be thrown away? Sure, use whatever gets you to a demo fastest. Building a content site that fits the framework’s model exactly? Go for it. On a team with high turnover and you need a standardized way of doing things? A framework can provide useful guardrails.
But for the core product of a business—the thing that differentiates you and will be maintained for years—I default to skepticism. I assume the framework will get in my way, and I look for evidence that it won’t. Most of the time, I don’t find it.
FAQ
What’s the difference between a library and a framework?
A library is a collection of functions or classes that you call from your code. You’re in control. A framework is a skeleton that calls your code. It provides the structure, and you fill in the blanks. The inversion of control is the key distinction. With a library, you opt in to specific functionality. With a framework, you opt in to an entire way of building software.
Isn’t building without a framework just reinventing the wheel?
Not if you’re using libraries. You don’t need to write your own HTTP server or database driver. Those are solved problems with stable, well-tested libraries. What you’re writing is the code that connects those libraries to your business logic. That code is unique to your application anyway. A framework would force you to write it in a specific style; doing it yourself gives you control over the abstractions that matter.
How do you convince a team to move away from a popular framework?
Start by measuring the cost. Track how much time is spent fighting the framework versus building features. Document the workarounds and the places where the framework’s conventions were violated. Once you have data, the conversation shifts from “we should use the popular thing” to “the popular thing is costing us money.” Teams respond to evidence better than they respond to opinions.
What about hiring? Don’t frameworks make it easier to find developers?
They do, but that’s a short-term benefit. A developer who knows a specific framework can be productive on day one, but they may never understand the underlying platform. When the hard problems come, they’ll reach for framework-specific solutions rather than understanding the fundamentals. I’d rather hire someone who knows the language and the web platform well and let them learn our specific stack. That knowledge lasts longer than any framework’s popularity.