The Framework You Didn't Mean to Write
In the AI era, teams are accumulating a private fork of their framework one hotfix at a time. Models are outcome oriented, the fixes land on runtime behavior that belonged upstream, and when it finally breaks in production the maintainer gets the blame.
@KinfishT
Something breaks. A date renders one way on the server and another way in the browser. You paste the error into your agent, and ninety seconds later there's a fix: a small wrapper around the framework's formatter that normalizes the timezone before the value reaches the component.
It works. The test goes green. You ship.
That wrapper is now part of your framework.
Nobody wrote that down. It has no version, no changelog, no docs page, no deprecation policy. But every date in the application now flows through code your framework doesn't know exists, and that the framework's next release has never once been tested against.
Do that two hundred times over a year and you haven't been building a product on a framework. You've been building a framework, quietly, in the shape of your product, one hotfix at a time.
The Anatomy of a Hotfix
Most of these start the same way.
A behavior is wrong, or missing, or surprising. It's genuinely unclear whether it's your bug or the framework's. The framework's source is not in your context window. What is in your context window is a failing test and a person who wants it to pass.
So the fix goes where the fix can go: your code.
// utils/fetch-with-retry.ts
// the framework's fetch drops the abort signal on redirect,
// so we re-attach it. added during the checkout incident.
export async function fetchWithRetry(url: string, init?: RequestInit) {
// ...
}
That comment is better than most. Usually there is no comment. Usually there's a function named safeFetch or getUserActually or renderPageV2, and the reason it exists left the company eight months ago.
The individual patch is almost always correct. It solves the problem. That's what makes this hard to argue against, and it's why I want to be careful about how I say the rest of this.
The Model Is Outcome Oriented. That's the Whole Problem.
An agent asked to fix a failing test will fix the failing test. That is the job as posed, and models are now very good at it.
What a model almost never does is stop and say: this is a bug in your framework, here is the upstream issue, you should pin the previous version and wait for the patch.
It happens. It is rare.
The reasons are all individually reasonable. The framework's source usually isn't in context, so the model is reasoning about a black box from its symptoms. "This is upstream's fault" is not a fix, and a fix is what was requested. A local workaround is the shortest path to green. And the model has no memory of the four previous patches you applied for related reasons, so it cannot notice that it's about to add a fifth.
Put those together and the default resolution for a framework bug is a local workaround that lives in your repository forever.
The model isn't wrong to do this. It's answering the question it was asked. The question is just narrower than the situation.
You Are Not Patching Your Code. You Are Patching the Runtime.
This is the part I keep coming back to, and it's the part I'd want someone to take away from this.
Almost nobody sets out to fork a runtime. But that is what a lot of these patches actually are.
Look at what the fixes touch. How requests get handled. When caches invalidate. How a stream is consumed before something else can read it. What survives a redirect. Which order effects run in. That is runtime behavior, and runtime behavior is the framework's job.
When you patch it locally, you haven't configured your framework. You've modified it from the outside: without touching its source, without running its test suite, and without anything anywhere recording that you did.
What that bug actually needed was a change upstream, in the runtime, made by someone who could see every other thing the runtime has to stay correct about. What it got instead was a change downstream, in your repository, made by a model optimizing for one green test.
So teams end up maintaining a private fork of their framework's runtime behavior. A fork with no fork. No branch, no diff against upstream, no name, and no one assigned to it.
That's what worries me. Not that people build their own framework. It's that they build one out of runtime patches, one green diff at a time, and never once make the decision to.
Patches Compose, and Nobody Asked Them To
The first patch is not the problem. The eleventh is.
Here's the ordinary product flow I keep coming back to. A user signs in. Middleware resolves their organization. A page reads their subscription. A mutation changes something. The cache needs to know. A webhook arrives later and changes it again.
Now add a patch at the session layer, because sessions didn't refresh correctly on a soft navigation. Add another at the cache layer, because revalidation fired twice. Add a third in the webhook handler, because the framework's body parser consumed the stream before you could verify the signature.
Every one of those was a good afternoon's work. Individually they are fine.
Then someone adds a feature that touches sessions, caching, and webhooks at once, which is to say someone adds a completely ordinary feature. And now the work isn't building the feature. The work is an archaeology project: figuring out which of the three patches still applies, which one silently stopped being necessary two framework versions ago, and which one is now actively fighting the thing you're trying to add.
That's the tax. It doesn't arrive as a bug. It arrives as everything taking longer for reasons nobody can point at.
The Upgrade You Can No Longer Take
The tax has a ceiling, and this is where you hit it.
Every patch is written against one version's behavior. It isn't a fix for the framework, it's a fix for how the framework behaved on the afternoon you wrote it. When the framework moves, each patch does one of three things. It breaks loudly, which is the good outcome, because you find out. It keeps working, which is fine. Or the bug it worked around gets fixed upstream, and your patch stays in the code path anyway, either solving a problem that no longer exists or quietly fighting the real fix.
That third case is the one that hurts. The abort signal your wrapper re-attaches is now attached twice. The revalidation you forced by hand runs alongside the one the framework finally got right. Nothing throws. Something is just subtly wrong under load, six weeks later.
So a version bump stops being a version bump. It becomes a research project across every patch you've accumulated, most of them undocumented, several written by people who have since left. Nobody schedules that, so it gets deferred. Deferred long enough, you're two majors behind and the upgrade is now genuinely large, which is an excellent reason to defer it again. That loop is where frameworks go to die inside companies.
Migration isn't the escape hatch either. Your patches encode assumptions about the framework you'd be leaving. You didn't only couple yourself to the framework. You coupled yourself to its bugs.
The sharp end of this is security, and it's boring in the way real risk usually is. Not upgrading means not taking security fixes. Log4Shell is the reference point already in this piece: the vulnerability was bad, but what turned it into a months long global event was how long it took organizations to actually move. A lot of them couldn't, for exactly this reason.
A framework you can't upgrade isn't really a framework anymore. It's a snapshot you maintain by yourself, with a version number that stopped moving. Which is the private fork from earlier, wearing a name tag that says otherwise.
The Judgment You Are Not Getting
This is the part that actually worries me, and I think about it because of my own work.
Building FarmJS, and before that maintaining Better Auth across a lot of different frameworks, taught me something about what framework authors are really doing. The features are the visible part. The invisible part is judgment about which edge cases matter.
An author who has lived with a codebase for years knows that the obvious fix breaks streaming. Knows that a particular API is shaped awkwardly because three other things depend on its current shape. Knows which of the twelve plausible behaviors is the one that stays correct when you add concurrency, or a second renderer, or a deployment target with a different runtime.
That judgment almost never ships as a feature. It ships as things that quietly don't break.
When a model patches around a framework, none of that is in the room. It optimizes the case in front of it, correctly, with no view of the other eleven cases the framework was balancing. It doesn't know the behavior it just "fixed" was load bearing for something else. It can't know. It's working from symptoms.
So the patch layer you accumulate has the opposite property of a good framework. A framework is a set of decisions made with the whole system in view. A patch layer is a set of decisions each made with exactly one symptom in view.
At small scale those look identical. At scale they are not.
And Then People Blame the Framework
Eighteen months later something breaks in production. It's subtle, it only shows up under load, and it takes four days to track down.
Then comes the part I have a problem with. The postmortem says the framework broke in production. The thread says the framework isn't ready for scale. Someone writes that they're migrating off it.
But you patched eleven runtime behaviors, each fix blind to the other ten. The framework being described in that thread hasn't run unmodified in that application for a year. What failed was the interaction between a stack of local runtime modifications that no one, including the people who added them, could see as a whole.
That's not the maintainer's bug. It's being reported as one.
Open source exposes maintainers to this by design. The source is readable, so it's blameable, and the person whose name is on it absorbs the complaint whether or not the complaint is about their code. That has always been part of the deal. What's changed is the volume.
The curl project is the clearest case. Its maintainers were flooded with AI-generated vulnerability reports through their bug bounty, confident and well formatted and largely fictional. Their founder described the triage burden as effectively a denial of service on the project's attention. A single fabricated report running a few hundred lines could take an hour to disprove, and they were arriving several times a day. Eventually they required reporters to disclose whether AI was involved, then shut the bounty down entirely, then reopened it when quality improved even as volume kept climbing. The specific observation I'd underline: models are good at spotting something suspicious and bad at judging whether it matters, and because everyone prompts the same models, maintainers get the same wrong report many times over.
Log4Shell showed the underlying economics. A logging library sitting in an estimated three billion devices was maintained by a handful of volunteers with no security budget. During the response one maintainer worked 22 hour days, unpaid, and later said plainly what it was like to be criticized by people who contributed neither money nor a patch.
The XZ Utils backdoor showed what that pressure enables. The maintainer was a single person, burnt out, unsupported, dealing with his own health. The attacker's approach wasn't primarily technical. Accounts that didn't belong to real people spent months complaining that patches weren't being merged fast enough and suggesting the project needed someone new in charge. That manufactured impatience is what opened the door.
Put those together and the pattern is clear enough. Maintainer attention is the scarcest resource in this ecosystem, and we keep spending it on things that aren't bugs.
Models change the arithmetic. Anyone can now generate a confident, plausible, nicely formatted claim in seconds. The cost of producing a report collapsed. The cost of evaluating one did not move at all.
I don't think the answer is that open source should close up. But the asymmetry got much worse in about two years, and "read the source, it's free" was never a complete answer to it.
None of that means the framework is innocent. Plenty of the time it genuinely is a framework bug, and I say that as someone who ships them. Maintainers get defaults wrong, abstractions leak, a behavior changes between minor versions and nobody notices it was load bearing until it isn't. Frameworks are software and software has bugs.
Upstream bugs just carry more leverage. A bug in your application affects your application. A bug in a framework affects everyone using it at once, somewhere none of them can properly fix, and a thousand teams end up writing their own version of the same workaround. Small on the maintainer's side, enormous by the time it lands. Which is why the patching habit hurts both directions: when the model routes around a real bug instead of naming it, the maintainer never learns it exists, and it survives because it was easy to avoid.
That's an argument for reporting more, not for reporting faster. Flooding a tracker is its own kind of harm, and curl is what that looks like at scale. Do the work first. Reproduce it, cut it to the smallest case, rule out your own patches, search the tracker. Then make a developer's judgment about whether you actually believe it's theirs.
And if you get that call wrong, if it turns out to have been yours the whole time, that's worth sitting with rather than shrugging off. It means your read was off, and the next one probably will be too. Watch your own steps before you ask someone to go checking theirs.
And sometimes you do all of that, you were right that it's theirs, and the answer is still no. A maintainer with a strong reason usually has one. They know what the change breaks, or who depends on the current behavior, or why the obvious fix isn't. A reasoned no isn't obstruction. It's the judgment you went looking for, arriving in a form you didn't want.
What you do next is the real decision. Taking that no and going off to rebuild the thing yourself puts you back in a room with nobody but a model optimizing for green. You'll get something that works, on your machine, this week. You won't get the reasoning that made the no correct.
Green is not the same as right. It never was. It's worth saying out loud now that green has become so cheap to produce.
That cuts on the other side of the table too, at whoever clicks approve. A passing test doesn't tell you a behavior is correct. It tells you the behavior matches an assertion, and someone wrote that assertion. Usually the same someone, or the same model, that wrote the change it's vouching for.
If the behavior is tree === false and you'd rather it were true, you don't have to fix anything. You can write the test that says true. The suite goes green. Nothing about the system improved, the boolean just moved. So a checkmark that looks like outside verification is often self certification: a judgment call collapsed into a comparison whose terms the submitter chose.
Approving properly is slower and doesn't compress. Run it. Read what the change touches, not only what it edits. Work out who depends on the current behavior and whether they'd notice. Then condense all of that into a yes or a no. Tests are a floor. They tell you nothing broke that somebody thought to assert, which is worth having and is not the same as telling you the change should have been made.
The Build Tool You Wrote on a Tuesday
Not all of this is patching. Some of it is ambition, and the ambitious version is genuinely better.
Someone notices the build takes six minutes. Two days later there's a custom pipeline that does it in ninety seconds. That's a real win. I like that people do this, and models have made it dramatically more achievable than it was.
The catch is what you now own.
Your entire workflow runs on it. It's on the critical path for every engineer, every CI run, every deploy. When it breaks at 2am, it isn't a vendor's problem.
And it will grow past what you meant it to be. You started because of one thing you wanted to improve, maybe two. But to make that one thing work you needed a watch mode. Then source maps, or debugging became guesswork. Then a resolver that understands one unusual import style used in exactly one package. Then caching, then a path fix for the one engineer on Windows. None of that was your use case. All of it is now yours to maintain.
The model is what makes each of those steps cheap, which is exactly why the scope keeps growing. When adding a capability costs an afternoon, you add capabilities. Cost is what used to stop you.
Here's the specific trap. Getting a model to understand runtime behavior well enough to make a local change is easy now, and getting better fast. What stays hard is blast radius. A change that is completely correct for the case in front of it can still break something three steps away, because the thing you've built has a broader scope than the problem you built it for. That gap between the scope of the fix and the scope of the tool is where the failures live.
Good unit tests catch a lot of this, and if your tests are strong a model will handle most of it well. What tests don't catch are the edges, and a build toolchain is almost entirely edges: the symlinked dependency, the case insensitive filesystem, the CI runner on a different Node version, the import that only resolves in production.
So it's a real trade, not a mistake. You get speed now and you owe maintenance forever. Worth making on purpose. Expensive to make by accident.
"I Don't Care Which Framework"
You hear this constantly now, and it's usually said like it's a sign of maturity. It isn't a defensible position, and nothing about the last two years made it one.
The reasons you picked a framework before models could write code have not moved. Does this fit what we're building. Does it hold up when we grow. Can we hire for it. Can we get out of it later. Those are the same questions about the same product, sitting on a different point in the timeline. A model showing up doesn't answer a single one of them.
Changing framework when one stops accelerating you is fine, and it always was. What shouldn't change is the standard you apply. Switching because you thought about it and concluded something is a decision. Shrugging is not the same move performed faster.
Here's the part that actually worries me. When you don't choose, the model does, and it chooses what it knows best. That's familiarity, not fit. It has the most training data on the most popular thing, which is a fact about the internet rather than a fact about your product. It doesn't know your product. It certainly doesn't know what your product looks like in three years, which is the exact horizon a framework choice is supposed to cover.
Forcing a different one doesn't escape this, it just changes the shape. The model rebuilds the primitives it already knows, in your framework's vocabulary, and you end up with an opinionated API nobody designed wearing the name of a framework that doesn't work that way.
Take streaming. Ask for it and the model can produce something that works, without necessarily knowing which runtimes support Node streams, which don't, and where those APIs diverge. Everything is fine until a library reaches for the API your runtime doesn't have. That's a small instance of a large category, and the infrastructure versions of it are considerably worse.
If you're building something small and disposable, patch it and ship it. Genuinely fine, and I'd do the same. But if other people depend on this, "the AI can handle it" isn't an argument. It's a way of not making one.
And delegating this well takes stronger opinions than doing it yourself, not weaker ones. You have to decide how the API should behave, how the primitives fit together, what the libraries you depend on actually need, what portability is worth to you. That means learning the primitives, the web APIs, the runtime you're standing on. You can't hand that part off either, because "the model can learn it" puts you straight back in the same loop. Models know a great deal. Judgment isn't knowledge, and you can't delegate judgment to something that doesn't have any.
Which is the real argument for maintainers. That learning is a full job and it isn't your product's job. Someone has to hold it. Forking a framework and shaping it to your workflow is legitimate, and open source exists precisely so you can. Just be clear about what you bought: you didn't only get the code, you hired yourself into the maintainer's role. Doing that on purpose is a strategy. Doing it by accident is the rest of this article.
Where That Judgment Comes From
I've leaned on the word judgment throughout this piece without saying where it actually comes from. It isn't talent and it isn't years served. It's what accumulates when you know how the thing works underneath.
It's also what seniority quietly turned into. It used to be measured by what you could build. Building isn't the scarce part anymore, so what's left is whether you can look at something that works and still tell whether it's right. Seniority is taste now.
Better Auth is the clearest example I have. It works because it holds an opinion about what auth is, and that opinion exists because I had to form one about the APIs that came before it. You don't get there without knowing how sessions behave, how cookies actually work, how client and server keep auth state in sync, where a session really lives. None of that ships as a feature. It's the thing that makes the features come out right.
Organizations are the concrete case. NextAuth didn't support them. Better Auth does, and it does because the underlying opinion left room for them. Here's the distinction I care about: today you can absolutely have a model build you an organizations feature, and it will work. It will work for your use case. What you cannot get that way is something you could offer as a product, because a product has to be right for the use cases you haven't seen yet.
That gap between "works for me" and "correct in general" is this whole article compressed into a single feature.
FarmJS started the same way. I was tired of Next.js, so I fixed the things that bothered me. But fixing them meant understanding them first. How the dev tooling works. How the APIs should be shaped. How to stay opinionated about infrastructure while still being versatile about where things actually deploy. How the build output gets optimized, how the build pipeline itself works, and what happens at each step of the runtime. Knowing those things and not knowing them are different states to be in, and the only way into the first one is caring enough to go and look.
You can do that learning with a model now, faster than before, and I do. Read everything, then use the thing, feel where it's wrong, and form your own view out of that. The model accelerates the learning. It doesn't do the learning for you, and it isn't the one holding the opinion afterwards.
So none of this is an argument against letting models do most of the work. They're good and getting better quickly. It's an argument that the part you can't hand over is the part that decides whether what you built is any good. Taste is just primitive knowledge that compounded.
I Am Not Telling You Not To Do This
I want to be clear, because it would be easy to read this as a complaint about people shipping fast.
Some of what you patch genuinely is yours. Every real product has things the framework can't know about: your auth model, your tenancy rules, your particular deployment, the vendor who does something strange with content types. Writing your own layer for that isn't a failure, it's the job.
And being outcome oriented is not a character flaw. Products are judged by whether they work. A patch that ships on Tuesday is worth more than a correct upstream fix that lands in six weeks. I'd usually take the patch too.
A framework you chose has a name, a version, docs, an issue tracker, and someone who thinks about it on purpose. A framework you accumulated has none of that. It has the same power over your application and none of the accountability. It shapes your runtime behavior, your production edge cases, and your scaling characteristics, and it does all of that from a directory called utils that nobody has read end to end.
The bloat isn't the real cost. The forgetting is.
What I'd Actually Do
Make the layer visible. If you have a patch layer, and you do, give it a directory and a name. Not utils. Something that says what it is, like framework-patches or compat. A new engineer should be able to see the shape of what you've added without reading every call site. You can't maintain a thing you can't see.
Know which layer you're patching. A formatting helper and the request lifecycle are not the same risk. Something leaf shaped, that nothing else calls, is cheap to patch and easy to reverse. How requests, sessions, caching or streams behave is the ground everything else in the application stands on, and those are the patches that compose badly. Before you accept a fix, look at where it lands. If it lands on the runtime, treat it as a change to your framework, because that's what it is.
Write down why, not what. The code says what it does. The comment should say which version needed it and what symptom it fixed. A patch with a reason attached can be deleted later. A patch without one is permanent, because nobody will ever feel safe removing it.
Ask the framework question out loud. When something breaks, it costs about a minute to ask whether this is actually a framework bug and to check the issue tracker before patching. Models won't volunteer this. They answer it well when asked. That single habit is most of the fix. And when the answer is yes, report it, once you've reduced it to a case you'd defend. A workaround you keep to yourself is a bug that stays in the framework for everyone else. A report you never checked is someone else's afternoon.
Re-check patches on upgrade. A meaningful share of any patch layer is dead: workarounds for bugs that were fixed upstream a year ago. Upgrades are the natural moment to delete, and almost nobody uses them that way.
Choose frameworks that are cheap to patch correctly. This matters more in the AI era, not less. A small, predictable, well documented API surface is one where both you and your model can reason about consequences. A large surface with a lot of implicit behavior is one where every patch is a guess. Well established is one good answer, because the edge cases are known and written down. Minimal is the other, because there's less to guess about. What you want to avoid is large and clever.
The Part That Actually Matters
Frameworks did not stop mattering when models got good at writing code. The choice got more consequential, because the cost of patching around a bad fit collapsed to nearly zero, and things that are cheap to do accumulate.
The old failure mode was picking a framework that couldn't do what you needed, knowing it, and suffering visibly. The new one is picking anything at all and letting a year of small, locally correct runtime patches turn it into something private, undocumented, and specific to you, without ever making a decision about it. Then calling the result a framework bug when it finally gives way.
You are going to build your own framework either way. That has always been true and it's more true now.
Build it on purpose, keep it small enough to hold in your head, and when it breaks, find out whose bug it actually is. Then fix yours, or report theirs.