FULL-STACK · 03 OCT 2026

Is Full Stack development the only way?

DISCIPLINE
FULL-STACK
PUBLISHED
03 Oct 2026
READ TIME
7 min
AUTHOR
AlgoCore

You're a few years into a back-end or front-end job, and the advice keeps coming: go full-stack if you want to stay employable. I checked it against how developers in the 2025 Stack Overflow Developer Survey describe their jobs and their pay. Full-stack is the largest single role, but 65% of professional developers picked something else, and at the same experience in the same country, full-stack developers report about 17% less pay than back-end developers.

Full-stack is the biggest single role, not the majority

The survey has one single-answer question about your job: "Which of the following describes your current job or the job you had for the most time in the past year?" It ran from May 29 to June 23, 2025, recruiting mainly through Stack Overflow's own channels.

Of the 33,686 respondents who said they're developers by profession and answered it, 35.0% chose "Developer, full-stack." Back-end came second at 18.6%. Software or solutions architects (6.8%) outnumber front-end developers (5.3%). The most popular answer still leaves two in three working developers doing something else.

Split the same respondents by years of professional experience and the full-stack share shrinks:

ExperienceFull-stackBack-endFront-endArchitect
1–4 years38.9%18.2%7.0%1.4%
5–9 years38.2%20.8%6.8%3.1%
10–19 years33.7%20.5%5.3%8.1%
20+ years32.0%15.3%2.7%13.4%

Full-stack drops about seven points from the first row to the last, while architects grow almost tenfold. That needs a caveat. The 2024 survey had no architect option, and its full-stack share barely moved with experience, from 39.5% at 1–4 years to 38.1% at 20+. So the 2025 drop probably reflects senior developers picking the new label once it's offered [unverified: each year surveys different people, so this is an inference from two snapshots]. Front-end behaves differently: it thins out with experience in both years.

At the same experience, full-stack pays less than back-end

If full-stack made you more valuable to employers, pay is where it should show. To compare like with like, I kept employed individual contributors (no managers) who chose one of the three roles and reported their experience and a yearly pay between $1,000 and $1,000,000. I also dropped countries with fewer than 30 such people. That leaves 8,360 developers in 43 countries, with pay converted to USD at the June 25, 2025 exchange rate.

Here's the US, the largest group, as medians rounded to the nearest $1,000:

ExperienceBack-endFront-endFull-stack
1–4 years$120,000$90,000$88,000
5–9 years$160,000$145,000$130,000
10–19 years$188,000$162,000$155,000
20+ years$185,000$160,000$150,000

Full-stack is lowest in every band. Medians in one country hide a lot, so the main number comes from a regression of log pay on role, with country and experience band held fixed. Full-stack developers report 16.7% less than back-end developers, and front-end developers 14.0% less.

For uncertainty, I resampled the 8,360 respondents 1,000 times, refit the model each time, and repeated that with five random seeds. The 95% interval for full-stack runs from 14.2% to 19.1% less, and no seed moved either end by more than half a point. The gap also survives every cut I tried. It's widest in the US, and the direction holds everywhere, including a different survey year:

SampleFull-stack vs back-endFront-end vs back-end
2025, 43 countries (n=8,360)−16.7%−14.0%
2025, US only (n=2,109)−22.9%−15.7%
2025, outside the US (n=6,251)−14.6%−13.5%
2024, 39 countries (n=6,475)−21.4%−16.4%

About a third of the gap comes from where full-stack developers work

The full-stack share is highest at small companies. Here's the mix of the three roles by employer size in the 2025 pay sample:

Company sizeFull-stackBack-endFront-end
Fewer than 20 employees70%21%8%
20–9961%31%8%
100–49957%35%8%
500–99953%38%9%
1,000–4,99953%40%8%
5,000–9,99955%35%11%
10,000 or more51%41%9%

Add company size to the model and the full-stack gap shrinks from 16.7% to 13.6%. Add remote status and industry as well and it falls to 10.8%, with a 95% interval of 8.1% to 13.4%. Roughly a third of the raw gap tracks where full-stack developers work. In 2024 the same controls leave a 15.9% gap.

The controls I tried don't explain the rest. One reading is that employers pay for depth in one layer [unverified: the survey records job titles, not depth of skill]. Another is that full-stack is the default title at employers that pay less for reasons the survey doesn't capture [unverified: there's no field for company revenue or funding stage].

The same feature, with and without the front/back split

The survey can't explain why full-stack is most common at small companies, but building one feature both ways suggests a mechanism. Depending on your framework, the line between front end and back end is either a folder inside one codebase or a contract between two services.

The feature is a notes list: fetch notes, add a note. First, Next.js 16.3.6 (React 19.2.8, TypeScript 5.9.3, Node 22.22.2), scaffolded with create-next-app@16.3.6 using the App Router and TypeScript:

// lib/notes.ts
export type Note = { id: number; text: string };

// In-memory store: fine for a demo, gone on restart.
export const notes: Note[] = [];
// app/api/notes/route.ts
import { notes, type Note } from "@/lib/notes";

export async function GET() {
  return Response.json(notes);
}

export async function POST(request: Request) {
  const { text } = await request.json();
  if (typeof text !== "string" || text.length < 1 || text.length > 280) {
    return Response.json({ error: "text must be 1-280 characters" }, { status: 400 });
  }
  const note: Note = { id: notes.length + 1, text };
  notes.push(note);
  return Response.json(note, { status: 201 });
}
// app/page.tsx
"use client";

import { useEffect, useState } from "react";
import type { Note } from "@/lib/notes"; // type-only: the array never ships to the browser

const API = "/api/notes";

export default function Notes() {
  const [notes, setNotes] = useState<Note[]>([]);

  useEffect(() => {
    fetch(API).then((r) => r.json()).then(setNotes);
  }, []);

  async function add(formData: FormData) {
    const res = await fetch(API, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ text: formData.get("text") }),
    });
    if (!res.ok) return;
    const note: Note = await res.json();
    setNotes((prev) => [...prev, note]);
  }

  return (
    <main>
      <form action={add}>
        <input name="text" maxLength={280} required />
        <button>Add</button>
      </form>
      <ul>{notes.map((n) => <li key={n.id}>{n.text}</li>)}</ul>
    </main>
  );
}

The page and the route import the same Note type, and one npm run dev serves both from one origin. The split version uses FastAPI 0.141.1 with Pydantic 2.13.5 and Uvicorn 0.54.0 on Python 3.11.15, plus a React 19.3.0 app on Vite 8.3.1 with TypeScript 6.0.3.

# api/main.py
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field

app = FastAPI()

# The browser app runs on a different origin, so the API has to opt in.
app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:5173"],  # Vite's dev server
    allow_methods=["GET", "POST"],
    allow_headers=["Content-Type"],
)


class NoteIn(BaseModel):
    text: str = Field(min_length=1, max_length=280)


class Note(NoteIn):
    id: int


notes: list[Note] = []  # in-memory, gone on restart


@app.get("/notes")
def list_notes() -> list[Note]:
    return notes


@app.post("/notes", status_code=201)
def add_note(body: NoteIn) -> Note:
    note = Note(id=len(notes) + 1, text=body.text)
    notes.append(note)
    return note
# Terminal 1: the API
cd api
python -m venv .venv && source .venv/bin/activate
pip install "fastapi[standard]==0.141.1"
uvicorn main:app --port 8000

# Terminal 2: the frontend
npx create-vite@9.2.1 web --template react-ts --no-interactive
cd web && npm install
npx openapi-typescript@7.13.0 http://localhost:8000/openapi.json -o src/api.d.ts
echo "VITE_API_URL=http://localhost:8000" > .env
npm run dev

Copy the Next.js component into web/src/App.tsx, delete the "use client" line, and replace its type import and API constant with these:

import type { components } from "./api"; // generated from the API's OpenAPI schema

type Note = components["schemas"]["Note"];
const API = `${import.meta.env.VITE_API_URL}/notes`;

The UI code doesn't change. Everything new sits at the boundary: a second process, a CORS allow-list, an env var with the API's address (Vite exposes variables prefixed with VITE_ to client code), and a types file generated from the OpenAPI schema FastAPI serves at /openapi.json. Validation moved too. An empty note gets a 422 with Pydantic's error details from FastAPI, where the Next.js route returns the 400 I wrote by hand.

That boundary costs a solo developer time. It's also what lets two people work in parallel: one owns main.py and its schema, and the other regenerates types whenever the schema changes.

Both of my snags came from the boundary. npm i -D openapi-typescript failed with ERESOLVE, because openapi-typescript 7.13.0 declares a peer dependency on TypeScript ^5.x and the Vite template installs 6.0.3. Running it through npx, as above, works. Then npm run build && npx vite preview served the app on port 4173, which isn't in allow_origins. The browser logged has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present, the form action threw, and the page went blank. That's documented React behavior: an error thrown by a form action goes to the nearest error boundary, and without one, React removes its UI from the screen. The Next.js version can't hit either problem, because it has one origin and no generated types.

Choosing a direction from mid-level

At a company with fewer than 20 people, full-stack is how the job is staffed: 70% of the developers in these three roles there carry the title. If that's where you work or want to work, learning both sides is the practical call.

At 1,000 employees and up, back-end makes up 35% to 41% of these three roles. If you're heading there, pick the side of the API you want to own and go deep. In this data, back-end pays more in every US experience band and in every cut above, even after company size, remote status and industry. The survey can't tell you whether choosing it would raise your own pay.

If you're front-end today, two numbers matter. The front-end title thins out sharply with experience, from 7.0% of developers with 1–4 years to 2.7% of those with 20+ (8.7% to 3.0% in 2024). And going full-stack doesn't move you toward the higher-paid group in this data: relative to back-end, full-stack and front-end sit within three points of each other, and their intervals overlap.

Whichever side you pick, learn the boundary. Writing a schema that someone else generates code from, and understanding why the browser blocks a request that curl lets through, are the skills the split version needed and the Next.js version didn't. On a larger team, those are what the back-end and front-end developers work out between them.

What this data can't tell you

Job titles here are self-reported, respondents came mostly through Stack Overflow's own channels rather than a random sample, and each year is a single snapshot. The public files have no field that links one person's answers across years, so they can't show what happens to your pay if you move from a full-stack title to a back-end one. That's the test I'd run next, and it needs panel data: the same developers, asked every year.

Appendix: the analysis script

Every survey number above comes from this script. Download results.csv for 2025 (2024 sits in the folder next to it) and run python fullstack_pay.py results.csv. You need pandas and numpy. The survey data is released under the Open Database License. The 2025 file has 49,191 rows, while the methodology page reports 49,009 responses [unverified: I couldn't find what accounts for the 182-row difference].

"""
Do full-stack developers earn more or less than back-end and front-end
developers with the same experience, in the same country?

Data: Stack Overflow Developer Survey public results (results.csv), from
https://github.com/StackExchange/Survey/tree/main/packages/archive/2025
(2024 lives next to it; the script handles both years' wording.)

Usage:  python fullstack_pay.py results.csv
Checked with Python 3.11.15, pandas 3.0.2, numpy 2.4.4. The bootstrap
(3 models x 5 seeds x 1,000 resamples) takes about five minutes.
"""
import sys

import numpy as np
import pandas as pd

ROLES = {
    "Developer, full-stack": "full-stack",
    "Developer, back-end": "back-end",
    "Developer, front-end": "front-end",
}
EMPLOYED = {"Employed", "Employed, full-time"}  # 2025 wording, 2024 wording
BAND_EDGES, BAND_NAMES = [0, 4, 9, 19, 100], ["1-4", "5-9", "10-19", "20+"]
COLS = ["MainBranch", "DevType", "Employment", "ICorPM", "WorkExp", "Country",
        "OrgSize", "RemoteWork", "Industry", "ConvertedCompYearly"]


def role_share(df):
    pro = df[(df.MainBranch == "I am a developer by profession") & df.DevType.notna()]
    share = pro.DevType.value_counts(normalize=True).mul(100).round(1)
    by_band = pd.crosstab(pd.cut(pro.WorkExp, BAND_EDGES, labels=BAND_NAMES),
                          pro.DevType, normalize="index").mul(100).round(1)
    picks = [*ROLES, "Architect, software or solutions"]  # the 2024 list has no architect role
    return len(pro), share, by_band[[c for c in picks if c in by_band]]


def pay_sample(df):
    d = df[(df.MainBranch == "I am a developer by profession")
           & df.Employment.isin(EMPLOYED)
           & (df.ICorPM == "Individual contributor")  # keep managers out of it
           & df.DevType.isin(ROLES)
           & df.WorkExp.notna()
           & df.ConvertedCompYearly.between(1_000, 1_000_000)].copy()  # drop typos
    d = d[d.groupby("Country").Country.transform("size") >= 30]
    d["role"] = d.DevType.map(ROLES)
    d["band"] = pd.cut(d.WorkExp, BAND_EDGES, labels=BAND_NAMES)
    return d


def design(d, controls):
    """log(pay) ~ role + experience band + country (+ controls). Back-end is the baseline."""
    X = pd.DataFrame({"const": 1.0,
                      "full-stack": (d.role == "full-stack").astype(float),
                      "front-end": (d.role == "front-end").astype(float)},
                     index=d.index)
    fixed = pd.get_dummies(d[["band", "Country"] + controls].astype(str),
                           drop_first=True, dtype=float)
    return pd.concat([X, fixed], axis=1).to_numpy(), np.log(d.ConvertedCompYearly.to_numpy())


def gap(X, y):
    beta, *_ = np.linalg.lstsq(X, y, rcond=None)
    return np.expm1(beta[1:3]) * 100  # % pay difference vs back-end


def bootstrap(X, y, seed, n=1000):
    rng = np.random.default_rng(seed)
    draws = np.array([gap(X[i], y[i]) for i in
                      (rng.integers(0, len(y), len(y)) for _ in range(n))])
    return np.percentile(draws, [2.5, 97.5], axis=0)


if __name__ == "__main__":
    df = pd.read_csv(sys.argv[1], usecols=COLS, low_memory=False)

    n_pro, share, by_band = role_share(df)
    print(f"Professional developers who picked a role: {n_pro}")
    print(share.head(6).to_string(), "\n")
    print("Role share (%) by years of professional experience:")
    print(by_band.to_string(), "\n")

    d = pay_sample(df)
    print(f"Pay sample: {len(d)} ICs in {d.Country.nunique()} countries")
    print(d.role.value_counts().to_string(), "\n")
    print("Role mix (%) by company size:")
    print(pd.crosstab(d.OrgSize, d.role, normalize="index").mul(100).round(0).to_string(), "\n")

    us = d[d.Country == "United States of America"]
    print("US median pay (USD) by experience band:")
    print(us.pivot_table(index="band", columns="role", values="ConvertedCompYearly",
                         aggfunc="median", observed=True).round(-3).to_string(), "\n")

    for name, sub in {"US only": us,
                      "outside the US": d[d.Country != "United States of America"]}.items():
        fs, fe = gap(*design(sub, []))
        print(f"{name} (n={len(sub)}): full-stack {fs:+.1f}%, front-end {fe:+.1f}% vs back-end")
    print()

    models = {"country + experience": [],
              "+ company size": ["OrgSize"],
              "+ company size, remote, industry": ["OrgSize", "RemoteWork", "Industry"]}
    for name, controls in models.items():
        sub = d.dropna(subset=controls)
        X, y = design(sub, controls)
        fs, fe = gap(X, y)
        print(f"{name} (n={len(sub)}): full-stack {fs:+.1f}%, front-end {fe:+.1f}% vs back-end")
        for seed in range(5):
            (fs_lo, fe_lo), (fs_hi, fe_hi) = bootstrap(X, y, seed)
            print(f"  seed {seed}: 95% CI full-stack [{fs_lo:+.1f}, {fs_hi:+.1f}]"
                  f"  front-end [{fe_lo:+.1f}, {fe_hi:+.1f}]")

Want this applied to your stack?

Most of these findings came out of client delivery. We can run the same passes on your system.

Request a quote
Request a quote