Skip to content

The 14 KB rule: fitting a portfolio in one TCP roundtrip

TCP starts a connection by sending about 14 KB before it pauses to wait for an ACK. Fit your whole page in that window and it lands in a single roundtrip. Here is how the homepage of this site does it.

Performance APITCPAstroGitHub Actions

The Live Proof strip on this site’s homepage settles at 14.0 KB of total transferred bytes for HTML, CSS and JS combined. That is not a round number picked for aesthetics. It is the budget you have to stay inside if you want the page to arrive in a single TCP roundtrip.

Why 14 KB

When a TCP connection opens, the server does not send everything it has. It sends a small first burst, then waits for the client to acknowledge it before sending more. This protects the network from a sender that does not yet know how much bandwidth is available.

The size of that first burst is called the initial congestion window, or initcwnd. Since RFC 6928 (published in 2013), Linux and the major web servers use a default initcwnd of 10 segments. The Maximum Segment Size on most paths is around 1460 bytes, which gives:

10 segments × 1460 bytes = 14,600 bytes ≈ 14.3 KB

Header overhead eats a small bite, so a useful rule of thumb is “the first ~14 KB of HTML / CSS / JS arrive in one roundtrip; the next bytes wait for a second roundtrip”.

That second roundtrip costs whatever the round-trip time is between your visitor and your edge. For a Vercel deploy, that is anywhere from 20 ms in the same region to 300 ms across the world. Staying under 14 KB shaves off the round-trip every visitor would otherwise pay on first paint.

What the slow-start looks like

   Client                                Server
     │                                     │
     │  ─── SYN ───────────────────────▶   │
     │  ◀──────────────── SYN/ACK ──────   │   (1 RTT, connection setup)
     │  ─── ACK + GET / ──────────────▶    │
     │                                     │
     │  ◀── 10 segments (~14 KB) ───────   │   (1 RTT, first burst)
     │  ─── ACK ──────────────────────▶    │
     │                                     │
     │  ◀── more segments ──────────────   │   (2nd RTT and beyond)
     │                                     │

If your page fits inside that first burst, the browser can start parsing and rendering immediately. If not, the next bytes are gated on a roundtrip that you cannot make faster.

How this site stays under 14 KB

I redesigned this site in June 2026 and migrated it to Astro 7 + Tailwind 4 + TypeScript 6. Two passes mattered for the budget:

1. Drop dependencies whose value did not pay for their bytes. I removed nine packages. The approximate weight of each on a typical page where it was loaded:

lenis            → ~9 KB minified + brotli
motion           → ~22 KB
astro-icon       → ~5 KB (plus per-icon SVG payload)
@iconify-json/mdi → ~3 KB
astro-i18next    → ~6 KB
i18next          → ~14 KB
dark-mode toggle script + CSS → ~3 KB
glass-card CSS (legacy)        → ~2 KB
theme-detection inline script  → ~1 KB

About 65 KB of JS and CSS gone, none of it visible in the redesign.

2. Let Tailwind 4 prune itself. The CSS-first config in Tailwind 4 only emits utilities that are actually referenced in source files. The generated stylesheet on the homepage sits around 5 KB. There is no tailwind.config.ts and no JIT compilation overhead at runtime.

3. Inline what you can. Astro’s build.inlineStylesheets: 'auto' puts small stylesheets inside the HTML instead of paying for a separate request. Self-hosted icons inside SVG <svg> tags travel inside the HTML payload. Google Fonts are preconnected and the font files load in parallel; they do not count against the first roundtrip because they are subresources.

Measure it yourself in any page

Drop this script into the bottom of your page and watch your own budget:

<script is:inline>
  (function () {
    function fmtKb(b) { return b < 1024 ? Math.round(b) + ' B' : (b / 1024).toFixed(1) + ' KB'; }
    function fmtMs(ms) { return ms < 1000 ? Math.round(ms) + ' ms' : (ms / 1000).toFixed(2) + ' s'; }

    window.addEventListener('load', function () {
      setTimeout(function () {
        var nav = performance.getEntriesByType('navigation')[0];
        var total = nav && nav.transferSize ? nav.transferSize : 0;
        performance.getEntriesByType('resource').forEach(function (r) {
          if (r.transferSize) total += r.transferSize;
        });
        console.log('page weight:', fmtKb(total));
        console.log('domInteractive:', fmtMs(nav.domInteractive));
      }, 250);
    });
  })();
</script>

transferSize already accounts for compression. If you are over budget, the bar at the top of the network panel in DevTools tells you which resource is eating the most.

Enforce the budget in CI

Once you are inside 14 KB, the only way to stay there is to make the build fail when you blow past it. A minimal GitHub Action for an Astro static build:

name: Page weight budget
on: [push, pull_request]

jobs:
  budget:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci && npm run build
      - name: Check homepage weight
        run: |
          BUDGET_BYTES=$((14 * 1024))
          PAGE=$(stat -c %s dist/index.html)
          CSS=$(find dist/_astro -name '*.css' -exec stat -c %s {} + | awk '{s+=$1} END {print s+0}')
          JS=$(find dist/_astro -name '*.js' -exec stat -c %s {} + | awk '{s+=$1} END {print s+0}')
          TOTAL=$((PAGE + CSS + JS))
          echo "homepage payload: $TOTAL bytes (budget: $BUDGET_BYTES)"
          if [ $TOTAL -gt $BUDGET_BYTES ]; then
            echo "::error::page payload exceeds 14 KB budget"
            exit 1
          fi

This runs before the deploy. If a PR adds a dependency that pushes the homepage over the line, the merge is blocked.

Caveats worth knowing

  • The 14 KB number applies to the first TCP connection to your origin. Subresources fetched on already-warm connections do not pay the same penalty.
  • HTTP/2 and HTTP/3 multiplex requests on a single connection but they still respect TCP slow-start on the initial connection.
  • CDNs with persistent connections to clients (a fraction of repeat visitors) bypass the slow-start entirely. The rule matters most for first-time visitors and cold cache loads.
  • Compression matters. The 14 KB is “bytes on the wire”, which means after gzip/brotli. The same site uncompressed could be 50 KB and still fit.

So what

This is the kind of optimization that does not show up in a Lighthouse score until everything else is already done. If you have not picked your typography, your color system or your fonts yet, do those first. But once the design is set, the 14 KB rule is one of the cheapest wins in performance. You delete bytes, you do not add them.

The whole homepage of this site is under the budget right now: 14.0 KB transferred, 160 ms domInteractive on a desktop browser. The Live Proof strip measures it every page load. If it ever creeps over, the CI fails before you ever see it.


More perf-nerd notes and the occasional Shopify Plus rant on X: @jaimesolis. If you want a budget-aware build set up on your project, the contact form is two clicks away.