Why 'Perfect' is the Enemy of 'Shipped'
Stop polishing code nobody uses. Perfection is a liability in SaaS. A deep dive into why technical debt is actually leverage when you're starting out.
You look at your GitHub repository. It’s sitting there. Private. 45 commits. The last one was three weeks ago. The README is beautiful, the linting rules are strict, and the folder structure is something you’d see in a text book about Clean Architecture.
But nobody is using it.
It lives in the graveyard of localhost. It dies a silent death because you weren't satisfied with the way you handled error boundaries in Next.js, or because you thought your database schema wasn't "future-proof" enough for the million users you don't have yet.
I see this every day. I see it in the peers I study with here in Ireland, and I see it in the indie hacking communities on Twitter/X. We are obsessed with the craft of coding to the detriment of the art of shipping. We treat software engineering like we're building a bridge that needs to stand for a hundred years, when really, we're building a lemonade stand to see if anyone is even thirsty.
This isn't about writing bad code. It's about writing relevant code. It's about understanding that in the world of SaaS and Cloud Engineering, perfection is not an asset. It is a liability. It is the single heaviest anchor you can tie around your neck.
The Scalability Trap (Day One Delusions)
I am currently deep in the weeds of a Masters in Cloud Computing. We talk about high availability, fault tolerance, load balancing, and auto-scaling groups. We look at diagrams of Netflix's architecture and marvel at the complexity.
And then I see developers applying these same principles to a side project that has zero users.
You do not need Kubernetes. You do not need a microservices architecture communicated via Kafka event buses for a To-Do app. When you are starting out, your biggest risk isn't that your server will crash because you have too many users. Your biggest risk is that you will never launch.
I've seen people spin up complex AWS environments using Terraform just to host a static site.
resource "aws_s3_bucket" "b" { bucket = "my-overengineered-bucket" tags = { Name = "My bucket" Environment = "Dev" } } resource "aws_cloudfront_distribution" "s3_distribution" { # ... 50 lines of configuration ... }
They spend three days debugging IAM permissions and CloudFront invalidations. Meanwhile, the guy who just dragged his folder into Vercel or Netlify has been live for 70 hours and has already collected five email addresses.
In the cloud world, we call this "undifferentiated heavy lifting." AWS coined that term to sell you managed services, but it applies to your code too. If you are building your own authentication system because you don't like how Supabase handles sessions, you are doing undifferentiated heavy lifting. You are solving a problem that has been solved, poorly, instead of solving the unique problem your business proposes.
Scalability is a luxury problem. It is a problem you earn. If you crash because you have too many users, that is a champagne problem. I would kill to have my server crash from real traffic. Until then, a single t3.micro instance or a serverless function is all the "architecture" you need.
The "Clean Code" Paralysis
I come from a background of shipping high-volume WordPress sites. I’ve built hundreds of them. Let me tell you something about the WordPress ecosystem: the code is often ugly. It’s messy. It’s PHP mixed with HTML, global variables everywhere, and !important tags scattered like confetti.
But it powers 40% of the web. It works. It makes money.
Now that I'm working with Next.js, TypeScript, and the modern stack, I see a different kind of disease. It's the obsession with abstraction. We don't just write a button. We create a Button component that accepts variants, sizes, and icon props, wrapped in a ThemeProvider.
We install Shadcn UI (which is fantastic, don't get me wrong) and then spend four hours customizing the radius in the tailwind.config.js file.
Here is what actual shipping looks like. It looks like hard-coding values. It looks like repeating yourself.
The Perfect Approach (That never ships):
// abstract-factory-button-builder.ts interface IButtonProps extends React.HTMLAttributes<HTMLButtonElement> { variant: 'primary' | 'secondary' | 'ghost'; size: 'sm' | 'md' | 'lg'; isLoading?: boolean; leftIcon?: React.ReactNode; } const Button: React.FC<IButtonProps> = ({ variant, size, ...props }) => { // ... complex logic to merge classes using clsx and tailwind-merge return <button {...props} /> }
The Shipped Approach:
// page.tsx <button className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700" onClick={handleSubmit} > Sign Up </button>
Is the second one scalable? No. If you change your brand color, you have to find and replace. Does it matter right now? No. Because you don't have a brand yet. You have an idea and a repo.
DRY (Don't Repeat Yourself) is a guideline, not a law. In the early stages of a product, WET (Write Everything Twice) is often better because it decouples your components. You can change the dashboard button without breaking the landing page button. Abstraction breeds coupling, and coupling breeds rigidity. Rigidity kills speed.
Infrastructure as Code vs. Infrastructure as ClickOps
Moving into Cloud Engineering, I've fallen in love with Terraform and CDKTF. Being able to define your infrastructure in TypeScript is powerful. But for an Indie Hacker or a solo dev, it is often a trap.
I spent a week once trying to get a perfect CI/CD pipeline set up for a project. I wanted it so that when I pushed to main, it would run tests, build the Docker container, push to ECR, and update the ECS service. I felt like a real engineer. I felt professional.
But I hadn't written the actual application feature yet.
I was optimizing the delivery mechanism for an empty box.
There is no shame in "ClickOps" (clicking around in the AWS or Azure console) for your MVP. Spin up the RDS instance manually. Copy-paste the connection string. Who cares if it's not reproducible? You aren't reproducing it. You are validating it.
Once you have paying customers, then you backfill the Terraform. You import the existing resources into state. That is a valid workflow. "Technical Debt" implies you have to pay it back with interest. But in the early days, that debt is actually a loan you take out to buy speed. If the business fails, you default on the loan and it costs you nothing. You only have to pay it back if you succeed.
The Tech Stack Wars: A Distraction
I see so many debates about Supabase vs. Firebase. Next.js vs. Remix. SQL vs. NoSQL.
I use Supabase. I love it. It gives me a Postgres database, Auth, and Storage wrapped in a nice API. It fits my mental model. But if you know Firebase better, use Firebase. If you know Laravel, for the love of God, use Laravel.
The user does not care about your tech stack. They care about their problem. They don't check your HTTP headers to see if you're using Server Actions or a REST API.
When I switched from WordPress to the "Modern Stack," I felt this pressure to use the absolute bleeding edge. I needed to use the Next.js App Router immediately. I needed to use Server Components for everything. I spent days fighting with hydration errors and caching strategies.
I forgot the primary lesson of freelancing: The deliverable is the product, not the code.
If you are spending 80% of your time wrestling with your tools and 20% building features, you have chosen the wrong tools for your current skill level or context. It doesn't matter if the tool is "better" on paper.
Here is a reality check on database schema design. I used to agonize over normalization. Third normal form or bust.
Now? I throw a JSONB column into Postgres and keep moving.
CREATE TABLE users ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), email TEXT UNIQUE, metadata JSONB -- The "I don't know what I need yet" column );
Is it efficient to query deep into a JSONB column? Not really. Is it faster than running migrations every time I decide I need to store a user's Twitter handle? Absolutely.
Fear is the Root Cause
Let’s get raw for a second. Why do we do this? Why do we over-engineer?
It’s fear.
It is the fear of being judged by other engineers. We are afraid that if someone sees our code, they will think we are juniors. We are afraid that if we don't use the latest tech, we are becoming obsolete.
But mostly, it is the fear of the market.
As long as you are "refactoring," you don't have to launch. As long as you are "optimizing," you don't have to face the reality that nobody might want what you built. Coding is safe. It’s a controlled environment where inputs have predictable outputs. The market is chaotic. It is irrational.
Optimizing code is a procrastination technique disguised as work. It feels productive because your brain is solving puzzles. But you aren't moving the needle.
How to Break the Cycle
You need to set constraints. Artificial, painful constraints.
1. The "One Weekend" Rule Scope your version 1.0 down to something that can be built in a weekend. If it takes longer, you are building too much. Cut features. You don't need password reset functionality for V1. You can manually reset it in the database if someone asks.
2. Use Boring Tech I love learning new things, but I learn them in isolation. When I'm building a product to ship, I use what I know. For me, that's Next.js and Supabase. I don't try to learn Rust while building a SaaS. That is suicide.
3. The "Shame" Filter
If you aren't slightly embarrassed by your code or your design, you waited too long to launch. Reid Hoffman said that, and it’s true. Push the code that has the // TODO: Fix this mess comment in it.
The Cloud Engineer's Perspective on "Good Enough"
In cloud architecture, we have the CAP theorem. You can only have two of the three: Consistency, Availability, and Partition Tolerance. You have to make trade-offs.
Shipping a product has a similar theorem. You can have:
- Perfect Code
- Rapid Speed
- Complete Features
Pick two. Actually, pick one: Speed.
Because without speed, you don't get feedback. Without feedback, you are building in a vacuum. And building in a vacuum is the most expensive way to write code.
I am pivoting my career. I am diving into Azure and AWS. I am learning the enterprise way of doing things. But I am keeping my Indie Hacker soul. I know that at the end of the day, a Docker container that is running on a server and serving customers is infinitely more valuable than a perfect Kubernetes manifest file sitting on my laptop.
Stop polishing. Stop optimizing. Stop planning for scale you don't have.
Just ship the damn thing.
Read Next
How I Built Pass My Essay to Solve the AI Detection Problem
I built a SaaS to bypass AI detection using Next.js, Supabase, and Stripe. A technical deep dive into algorithms, serverless architecture, and shipping fast.
ReadBuilding iBuildElementor: Productizing a Service Business
How I pivoted from hourly freelancing to a productized service. Deep dive into the Next.js/Supabase portal I built to manage it and the lessons on scaling.
Read