// It's Not "Just A Button"

by Andrew Lee | Jun 7, 2025

It’s just a button! How hard could it be to build?

As a software engineer, I sometimes get asked questions like this. People in my life wonder what I actually do for my job when I’m typing away on the computer all day. These are great questions, but can be tricky to answer. Today, I answer them together once-and-for-all. I’m talking about the software development lifecycle. 🔥

In my view, a primary goal of a software developer at a company is to ship valuable features and fix bugs as correctly and as quickly as possible. Of course, the goal of the business is to provide value to customers and make money, but we play a particular role in achieving that goal. Software developers have lots of competing priorities when building. They want to build things that:

  • Solve problems and meet user needs,
  • Work as intended,
  • Can handle ample amounts of data and traffic,
  • Are quickly deployed to customers,
  • Are easy to understand and modify.

Testing that everything works as expected is important, but it also slows down the development process. It’s a trade-off! We need to balance the need for quality with the need for speed. There’s a lot of work that goes into making that happen, so let’s talk about what software engineers really do, and why building a single button can be hard. We’ll start at the beginning: code.

Writing Code

Engineers write code to build software. Code is written in a programming language, like Python, JavaScript, or Java. Languages have different strengths and weaknesses - some are easier to write quickly but run more slowly, for example. Some languages have better ecosystems than others for solving particular domain problems, like Python for machine learning and TypeScript for web development. In practice, engineers often use a combination of languages and frameworks, which are collections of pre-written code that help with common tasks like authentication, data storage, building UIs, etc. At the end of the day, these are all tools we use to solve problems.

Generally, code is organized into modules, classes, and/or functions that perform specific tasks. Code can be tested by writing other testing code to ensure that things work as expected. Like plain English, there are syntax rules that must be followed to write valid code. Unlike English, if they aren’t followed perfectly, the code most likely won’t be able to run. Thankfully, there’s lots of tools that can help you write code that follows these rules, like linters and formatters.

Software can be complex, written by many people over many years with evolving standards. Big software projects can have millions of lines of code! Imagine trying to read a book with 50,000 pages. Thankfully, because of the way code can be organized in levels of abstraction, it’s possible to understand and work with large codebases.

To demonstrate, think about what it takes for a car to start up.

  1. You turn the key, drawing power from the battery.
  2. The starter motor engages and begins to spin the engine.
  3. The fuel pump sends gasoline to the engine’s cylinders.
  4. Spark plugs ignite the mixture of air and fuel.
  5. Controlled explosions push the pistons.
  6. The engine’s crankshaft rotates, and the engine runs on its own.

But you don’t have to know all that when you drive, do you? Thank goodness not. This is an abstraction boundary, just like how functions hide the details of how they work.

from car_module import Car, start, drive, stop
# These functions might be implemented with thousands of lines of code,
# calling other functions, doing complicated things,
# but they are easy to call!
car = Car()
car.start()
car.drive()
car.stop()

Developers also have a large ecosystem of tools available to help navigate, understand, and edit codebases, like integrated development environments (IDEs), debuggers to inspect the state of the program as it runs, language servers for code completion and refactoring, and so on. In the end, all of this makes it possible for developers ship well-tested, validated, and maintainable code.

Version Control

Code is stored in what’s called a repository, where a version control system (VCS) like Git tracks the history of changes to the code. This lets you roll back to previous versions if necessary, look up the author for a specific line of code, and work in parallel to other developers by branching off of the remote trunk (the latest draft of code everyone works on).

   graph TD;
    subgraph Git Repository
        C1("Commit 1") --> C2("Trunk: Commit 2");
        C2 --> C4("Trunk: Commit 4 (Merged)");

        subgraph "Feature Branch"
            B1("Branch created") --> B2("Commit 3");
        end

        C2 --> B1;
        B2 --> C4;
    end 

Building a Feature

Here’s where the rubber meets the road. Let’s say you want to add a button to a web page that adds an item to a user’s shopping cart. Sounds simple enough, right? Let’s get to work.

Planning

First, you need to plan what we need for this feature.

I’ll skip the details of how this feature work was prioritized, but suffice it to say: software development is a team sport. In this post, I focus on the engineering perspective but there’s more to the story. It’s not just about the technical aspects, but also about collaboration among team members in different roles, including designers, product managers, QA engineers, sales, marketing, and more.

Let’s start by determining the scope of the feature:

  • Define the user story and acceptance criteria.
  • Determine the technical design to implement this feature.

These tasks vary based on the type of feature being developed. In this case, we’ll need to:

  1. Make a database schema change to be able to store the cart items,
  2. Make an API endpoint, which is basically a function a user’s browser can call to do a task on our backend server-side code,
  3. Add the button to our frontend client-side code to call the endpoint on click.

For more complex features, you may need to consider regional data residency, encryption of sensitive customer data, backwards compatibility, release sequencing, provisioning infrastructure, adopting new dependencies, etc. Don’t worry about the details of all those terms, but know that they make adding new features more challenging. Security, privacy, and compliance are critical aspects of software development that must be considered throughout the entire lifecycle.

Additionally, planning often isn’t done all upfront “waterfall”-style, but rather iteratively and incrementally. For our relatively simple case, this will suffice.

Implementation

Now it’s time to BUILD 🗣️ THAT 🗣️ BUTTON.

Phase 1: Preparation

  1. First, you pull the latest trunk from the remote VCS to your workstation. Other folks working on the codebase might’ve merged things into the trunk since you last synced your local copy, so you want to make sure you’re up to date.

     graph LR;
      subgraph "Remote Server"
        A((Trunk))
      end
    
      subgraph "Workstation"
        B((Local Trunk))
      end
    
      A -.-> B 
  2. You create a branch where you’ll build your feature intending to eventually be merged into the trunk.

Phase 2: Database & Backend Work

  1. When you reload the page or log into your account from another device, you want your cart to be the same. That means we need to persistently store that change on a server database. Can your database schema represent that kind of data? Think of a bunch of excel document tables with different columns. If you don’t have a table that has columns like cart_id and item_id to store all the items in a cart, you add new table(s) to the database.

  2. You make a backend API endpoint that takes an item and stores it in the authenticated user’s cart.

     sequenceDiagram
      participant Client as 📱 Client (Browser/Device)
      participant Backend as ☁️ Backend API
      participant DB as 🗄️ Server Database
    
      Note over Client: User adds an item to the cart
      Client->>+Backend: API Request: POST /cart (item_id, quantity)
    
      Note over Backend: Verifies authenticated user
      Backend->>+DB: Store item in user's cart record
      DB-->>-Backend: Confirm item stored successfully
    
      Backend-->>-Client: Success response (updated cart) 
  3. You write some tests to validate that your new code does what you’d expect. If there were 0 items in the shopping cart then you call that endpoint with an item, you should now have 1 item. If that item already existed, maybe you store that item once with a quantity of 2 instead of 1.

  4. You commit your changes, which locally snapshots the new version of your code.

Phase 3: Frontend Work

  1. You make some changes to the frontend user interface, the visual part that users interact with in their browser, by adding some HTML + CSS + JS to create your shiny new button. Clicking the button makes a call to your new endpoint.
  2. You snapshot your code changes with another commit.
  3. You may create UI tests or end-to-end (E2E) tests to check the entire process of loading the site, logging in, clicking the button, and viewing the cart. If you choose to add these, you commit those.

Phase 4: Review

  1. You push your branch to the remote repo. Your chain of commits are now visible to other developers, but your code isn’t in the trunk yet (thus won’t roll out to users).
  2. You file a pull request (PR) that contains all of your code changes, a description of what you’re changing and why, and any other useful info. It’s targeting to merge into the trunk.
  3. You request reviews from relevant feature owners, experts, teammates, etc. They’ll read through your code as a second pair of eyes to challenge your assumptions, provide suggestions for improvements, spot mistakes, etc. They’ll either request further changes or mark it as approved.

Phase 5: Validation & QA

  1. If they’ve been set up, some checks run in your CI/CD build system when you push code to ensure your changes successfully pass all the tests. If they don’t pass, your PR is usually blocked from merging into the trunk.
  2. You hand off your code to be manually validated by quality assurance (QA). QAs generally run your code in some kind of staging or pre-production environment, a sandbox for development that’s completely separate from the real production environment that your users access. You might just be your own QA and have to do that testing yourself.

Phase 6: Integration & Deployment

  1. Finally, after your feature is built, all the tests pass on your PR, QA has validated your work, and relevant reviewers have approved, it’s time to merge your branch! The PR gets closed, your changes get merged into the trunk branch. This process is also called integration.
  2. Your continuous integration/continuous deployment (CI/CD) system will then deploy the latest trunk version (including your code) to pre-production then finally production, where users will be able to use the new button!

That was a step-by-step “happy path” for building this feature. Just know this is a simplified example! In reality, the process isn’t so linear - you build something, talk to stakeholders, get reviews, test, iterate, re-evaluate, and repeat. Sometimes, you omit certain steps due to urgent time constraints or lack of resources. Occasionally, you need to go back to the drawing board (sometimes literally, shameless plug for LucidSpark) and change your plan.

This all might sound like a lot of work for a “simple” button, and you’re right! It takes a lot of effort to maintain high standards for code quality and correctness when building software. However, without these processes, you’re much more likely to

  • Introduce bugs to the production environment negatively affecting real users.
    • In the worst case, a bug in your feature can break other features. One bad button can break an entire website if built poorly.
  • Accumulate tech debt that makes shipping new features and bug fixes slower and more difficult long-term. We’ll talk more about this shortly.

There is a time and place for rapid prototyping, but when users care about reliability, a robust software development process is critical. And we’re not done just yet!

Post-Deployment

Job Not Finished

Job’s not finished.

Once the feature lands in production, it’s important to monitor its behavior. Software teams put infrastructure in place to ensure that features perform as expected. Log indexing, trace indexing, alerting, and analytics are all important for monitoring a deployment. There’s a rich ecosystem of tools and services that help with each of these aspects.

When things do go wrong (as they inevitably will with enough time), developers need to investigate the issue, identify the root cause, ideally reproduce the issue themselves, write a fix for it, and release it. This process can be time-consuming and requires a lot of effort, but it’s important to fix issues quickly to minimize the impact on users. If your software offers service license agreements (SLAs), this may require 24/7 support, paging, and escalation policies to mitigate impact and minimize downtime. Investigating issues is a critical part of the software development lifecycle. It’s also common to hold a post-mortem meeting to discuss what went wrong and how to prevent it from happening again.

Process Diagram

The whole process as I’ve defined looks something like this:

 graph TD
    subgraph "Branch Development"
        A["Investigation & Design"] --> B["Development"];
        B --> C{"Automated Testing"};
        B --> D{"Code Review"};
        C -- "Tests Fail" --> B;
        D -- "Changes Requested" --> B;

        E["Staging & QA Testing"];
        C -- "Tests Pass" --> E;
        D -- "Approved" --> E;
        E -- "Bugs Found" --> B;
    end

    subgraph "Release Cycle"
        F{"Merge Code"};
        E -- "Passes" --> F;
        F --> G["Deployment & Release"];
        G --> H["Monitoring & Maintenance"];
        H -- "New Features / Bugs" --> A;
    end 

Intuitions on Software Timelines

It’s consistently surprising to non-developers that features that seem so simple take so much time and effort to build. It’s even surprising to developers to some degree too.

It’s just a button!

Unfortunately, it’s often unclear how complicated something will be to build until actually doing it. It’s one reason why engineers often double the time when estimating how long it will take to build a feature. In one codebase, it might be vastly easier to implement the same button than another due to tech debt, choice of tech stack, architecture or scaling limitations, etc. regardless of the skill level of the engineer.

For freelancers, it’s also a rule of thumb to demonstrate the backend implementation of a feature before showing the styled frontend to stakeholders. Stakeholders may have a hard time intuiting how much work goes on behind the scenes on the backend.

The button’s there on the page, but it’s not working! How much work could it be to make it work?

Without a background in software engineering, users might not have an accurate mental model for how a button on a website works. It’s better to save demonstrating the most user-facing work for last, so when the user sees something that looks like it should work, it actually does.

Technical Debt

One of the biggest reasons causing software to take longer to develop than we expect is technical debt. Technical debt is the cost of not doing something right the first time, and it can accumulate over time. It’s important to be mindful of technical debt and to prioritize paying it down as part of the development process.

It can be difficult for non-developers to understand the concept of technical debt, and it can be especially frustrating to hear about for managers - paying down tech debt can sound like a worthless “feature freeze” if it’s not communicated properly. Let’s take a look at a visceral example.

The “Messy Kitchen” Analogy

A commonly used analogy for tech debt is that of a messy kitchen. Let’s say you’re hired to work as a chef in a restaurant. You get there for your first day of work and find the kitchen is in absolute shambles - dishes everywhere, pots and pans stacked haphazardly, and no clear organization.

Messy kitchen

Your boss comes over and tells you that they want you to come up with a brand new dish to wow the customers. You grumble that they might want to start by wowing the health inspector, but you have your marching orders. Anyway, you’ve got some ideas for a killer french onion soup you’ve been wanting to try. You go look in the pantry, and of course, it’s a total mess. You dig through piles of disorganized veggies for several minutes until you finally find some onions. You walk to the spice cupboard looking for thyme and bay leaves, and they’re not there. You ask around until you finally find another chef who had used them recently… Turns out, they just kept the thyme in their toque Ratatouille-style for easy access. How could you have possibly known that?

You waste a lot of time searching for the other ingredients, tools, and pots you need… and you realize that you don’t have enough time to finish the dish before the customers arrive. You decide to make a simpler version of the dish, using only the ingredients you have on hand. You’re not happy with the situation, but you plate it anyway. The show must go on.

As you feared, the customers are unimpressed; they thought the dish was bland. And to make matters worse, after your frenzied search, you somehow managed to leave the kitchen messier than you found it.

Bad code is not so visceral as a messy kitchen, but sometimes we wish it was, if only just to demonstrate the importance of paying down technical debt to keep a codebase maintainable. If left unchecked, it can cause wasted time and resources, missed deadlines, system unreliability, developer burnout, customer dissatisfaction, and ultimately, loss of business.

Debt Means Debt

The term “debt” in technical debt is no coincidence, it functions a lot like financial debt. Taking technical shortcuts can allow you to deliver faster short-term, in the same way that taking out a loan can allow you to buy a house sooner than saving up the full purchase price. This can be a good strategic decision, but note the consequences.

Like financial debt, technical shortcuts have “interest rates”. It varies on the complexity of the code, the team’s experience, the urgency of the project. Low interest rate tech debt might be an ugly hack for a one-off script that runs once a year - it may slow you down if you need to work on that again, but you don’t anticipate it changing much.

If you throw together some duct tape and glue in a critical system that gets a lot of traffic and is frequently changed, that’s probably high interest rate debt. In a system like that, debt can beget more debt. Bad architectural decisions can be made that force you to make more bad decisions unless fixed. You either clean up the kitchen so you can install modern appliances, or you can keep using the old ones and live with the consequences.

Trade-offs Everywhere

Not all tech debt comes from messy work. Sometimes, it appears when a successful product simply outgrows its original design. For anything you want to build, there can be any number of different ways to build it, each with their own trade-offs. Some are clearly better than others, but sometimes it’s more nuanced.

Imagine a small rambler house was built to sleep 6 people, then suddenly some event required it to support 60 people. Then 600 people? What about 6,000 people? Forget about it. At some point, reasonable architectural decisions made yesterday can become unreasonable for today. It’s intuitive that you can’t linearly scale a rambler by stacking it upward for 1,000 floors. It takes fundamentally different architectural decisions to build a skyscraper - different foundations, different materials, different structure, different maintenance schedule, etc. You don’t always need a skyscraper, of course. Engineering is very much about balancing those trade-offs within a certain timeframe to deliver, and re-balancing them as the project evolves.

Conclusion

So, what do software engineers really do all day? They do all of this. They act as architects, city planners, and construction workers for the digital world. They don’t just build features; they design systems, collaborate with peers, lay scalable foundations, and perform constant maintenance to ensure everything runs smoothly (mostly).

It’s a process of managing trade-offs: balancing the goal of building a stable, bug-free skyscraper with the immediate need to give users a reliable place to live. The next time you see a seemingly “simple” button on an app, you’ll have a better sense of the hidden complexity and effort required to bring it to you. It’s rarely “just a button” - it’s only the visible tip of a larger iceberg. I hope you’ve gained a deeper appreciation for these icebergs and for those of us who work on them. 🧊

Thanks for reading!

~ Andrew

Bonus meme:

Software Iceberg

Additional Note: AI

In my opinion, a lot of this process is going to be augmented by the use of AI in the next few years. In many places, it’s already happening. If you’re sick of hearing about it, I feel you… but it’s not going anywhere. This post actually came from writing about some predictions on AI. While writing that, I realized I wanted to define software development to non-developers, so that we can share some grounded reality in what the job looks like right now — before we get into what AI might do to it.

Anyway, look out for that post on AI coming soon.

 
::

2025 C. Andrew Lee.