(I make websites.)
“Your new favorite AI engineering partner”
I am a serious professional.I am a serious professional.| seconds | |
|---|---|
| < 0.1 | Instant |
| 0.1 - 0.3 | Machine |
| 0.3 - 1 | Flow |
| 1 - 3 | Anxiety |
| 3 - 5 | Abandonment |
| 10+ | Ghost Town |
In June 2021, Google launched the Page Experience update.
Core Web Vitals — drawn from CrUX — became an official search ranking signal.
Your CrUX score is your position in search results.
The user requested your webpage and is waiting for a response from the server.
This is measured in milliseconds (ms).
| < 300ms | Excellent | This feels instant to users. |
| < 800ms | Good | Google's passing grade for a "healthy" user experience. |
| 800ms - 1800ms | Needs Improvement | Users will notice a blank white screen. Your server is struggling. |
| 1800ms+ | Failure | We can assume the page is dead. |
Because you want your site to have users. Higher TTFB means users will bounce.
But first, we need a baseline.
Which means we need another acronym.
The time it takes for the client to send a signal to the server and receive an acknowledgment back.
This is also measured in milliseconds (ms).
aka...
pingping| Step | What happens? | Cost |
|---|---|---|
| TCP |
|
1 RTT |
| TLS 1.3 |
|
1 RTT |
| HTTP/2 |
|
0.5 RTT |
| TOTAL | 2.5 RTTs | |
| Connection Type | Typical RTT | Handshake (×2.5) |
|---|---|---|
| Fiber | 10ms | 25ms |
| Cable | 25ms | 63ms |
| DSL | 50ms | 125ms |
| 5G | 45ms | 113ms |
| 4G / LTE | 70ms | 175ms |
| 3G | 200ms | 450ms |
| Excellent | < 300ms | |
| Good | < 800ms | |
| Needs Improvement | < 1800ms | |
getAvatar.php
<?php
if (isset($_COOKIE['sid'])) {
if ($email = json_decode(file_get_contents("api/email?sid=".$_COOKIE['sid']), true)['email']) {
if ($status = json_decode(file_get_contents("api/status?email=".$email), true)['active']) {
if ($pref = json_decode(file_get_contents("api/pref?email=".$email), true)) {
if ($theme = $pref['settings']['theme']) {
if ($avatar = json_decode(file_get_contents("api/avatar?id=".$pref['id']), true)['url']) {
define('READY', true);
echo "<img src='$avatar' class='$theme'>";
}
}
}
}
}
} else {
header("Location: /login");
}
GetUserTheme.sql
SELECT
* FROM users
GetUserTheme.sql
SELECT
* FROM users
LEFT JOIN
orders ON users.id = orders.user_id
GetUserTheme.sql
SELECT
* FROM users
LEFT JOIN
orders ON users.id = orders.user_id
LEFT JOIN
activity_logs ON users.id = activity_logs.user_id
GetUserTheme.sql
SELECT
* FROM users
LEFT JOIN
orders ON users.id = orders.user_id
LEFT JOIN
activity_logs ON users.id = activity_logs.user_id
LEFT JOIN
site_themes ON users.theme_id = site_themes.id
GetUserTheme.sql
SELECT
* FROM users
LEFT JOIN
orders ON users.id = orders.user_id
LEFT JOIN
activity_logs ON users.id = activity_logs.user_id
LEFT JOIN
site_themes ON users.theme_id = site_themes.id
WHERE
users.id = 42
LIMIT 1



.htaccess
# Cache static assets for 1 year
Header set Cache-Control "public, max-age=31536000, immutable"
# Cache HTML for 24 hours (CDN) / 1 hour (browser)
Header set Cache-Control "public, max-age=3600, s-maxage=86400"
# Don't cache dynamic content
Header set Cache-Control "no-cache, must-revalidate"
| Handshake Cost | 0-RTT Resume? | |
|---|---|---|
| HTTP/1.1 + TLS 1.2 | 3+ RTTs | No |
| HTTP/2 + TLS 1.3 | 2.5 RTTs | No |
| HTTP/3 + TLS 1.3 | 1 RTT | Yes |
The handshake is done.
10 segments × 1,460 bytes = ~14.6KB
Oh no! That means we have to discuss
And now, a brief tangent about...
"Above the fold" is where everything important should go.
This was true of newspapers in the 1950's.
And everything on our website is important.
Therefore everything must go above the fold.
And therefore...
The
is where content goes to die.
but
The first pixels of your website's content appear on the user's screen.
| < 1.8s | Good | Users feel the page responding immediately. |
| 1.8s - 3s | Needs Improvement | Noticeable blank screen. Users are starting to question the page. |
| 3s+ | Failure | Most users have already mentally abandoned this page. |
index.html
<head>
<link rel="stylesheet" href="/styles/global.css">
<link rel="stylesheet" href="/styles/unused-bootstrap-v2.css">
<link rel="stylesheet" href="/styles/that-one-widget-we-deleted-in-2019.css">
<script src="/js/jquery-ui-full-1.12.1.min.js"></script>
<script src="https://dead-tracking-service.io/tracker.js"></script>
</head>
page-with-icon.html
<head>
<link rel="stylesheet" href="/css/fontawesome-all-pro-v6.css">
</head>
<body>
<p>Check out my blog!</p>
<i class="fa-solid fa-face-smile-wink"></i>
</body>
app.html
<head>
<title>Enterprise SaaS Turbo Ultra</title>
</head>
<body>
<div id="app"></div>
<script src="/static/js/main.9f2a83b1.chunk.js"></script>
<script src="/static/js/vendors~heavy-enterprise-modules.882c12.bundle.js"></script>
</body>
typography.css
@font-face {
font-family: 'ExtremelyFancySerif';
src: url('/fonts/ultra-bold-italic-extra-wide.woff2');
}
h1 {
font-family: 'ExtremelyFancySerif';
}
Well, let's see a demo...
index.html
<head>
<script src="/analytics.js" async></script>
<script src="/app-interactivity.js" defer></script>
</head>
<body>
<header class="site-header">...</header>
<div class="skeleton-nav"></div>
<img src="/footer-ad.jpg" loading="lazy" width="300" height="250">
</body>
typography.css
@font-face {
font-family: 'BrandFont';
src: url('/fonts/brand-font.woff2') format('woff2');
/* The browser paints instantly with a system font */
font-display: swap;
}
body {
/* Falls back nearest available fonts until 'BrandFont' is ready */
font-family: 'BrandFont', 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
}
app.html
<head>
<title>Enterprise SaaS Turbo Ultra</title>
</head>
<body>
<div id="app">
<header>...</header>
<main>
<h1>Welcome to the App</h1>
<p>This content was rendered on the server!</p>
</main>
<footer>...</footer>
</div>
<script src="/static/js/main-bundle.js" defer></script>
</body>
layout.html
<head>
<style>
/* Critical CSS: above-fold styles inlined — no network request */
.site-header { height: 60px; background: #1a1a1a; }
.nav-link { color: #fff; padding: 10px; }
</style>
</head>
<body>
<header class="site-header">
<nav class="nav-link">Home</nav>
</header>
<link rel="stylesheet" href="/styles.css">
<main>...</main>
<link rel="stylesheet" href="/print.css" media="print">
</body>
The largest visible content element on your website has been rendered.
| < 2.5s | Good | The main content feels like it arrived quickly. |
| 2.5s - 4s | Needs Improvement | Users notice the wait. The page feels unfinished. |
| 4s+ | Failure | "Is anything ever going to show up?" |
hero-hidden.css
/* The browser doesn't know this exists until the CSS is parsed */
.header-wrapper .hero-inner {
background-image: url('/assets/huge-hero.jpg');
}
index.html
<body>
<img src="/hero.jpg" loading="lazy" alt="Hero">
</body>
app.html
<body>
<img src="huge-hero.jpg">
</body>
index.html
<head>
<link rel="preload" as="image" href="/hero.avif">
</head>
<body>
<img src="/hero.avif" fetchpriority="high" alt="Hero">
</body>
layout.css
.hero-image {
/* Reserves a 16:9 box so the page doesn't jump */
aspect-ratio: 16 / 9;
width: 100%;
height: auto;
}
responsive.html
<picture>
<source srcset="hero-400.webp" media="(max-width: 400px)" type="image/webp">
<source srcset="hero-600.webp" media="(max-width: 600px)" type="image/webp">
<source srcset="hero-900.webp" media="(max-width: 900px)" type="image/webp">
<source srcset="hero-1200.webp" media="(max-width: 1200px)" type="image/webp">
<source srcset="hero-1800.webp" type="image/webp">
<img src="hero-1800.webp" alt="Hero">
</picture>
Measures how long it takes for the page to visually respond to user input.
| < 200ms | Good | Feels instant. The page is listening. |
| 200ms - 500ms | Needs Improvement | User notices the lag. Trust starts to erode. |
| 500ms+ | Failure | Clicking into the void. |
checkout.js
button.addEventListener('click', () => {
const data = JSON.parse(massivePayload); // ~300ms
rows.forEach(row => {
const h = row.offsetHeight; // forces layout
row.style.height = (h * 1.5) + 'px'; // invalidates layout
}); // × 500 rows
localStorage.setItem('cart', JSON.stringify(data)); // ~80ms
// Total: ~600ms of main thread. Page looks frozen.
});
checkout.js
button.addEventListener('click', async () => {
const data = JSON.parse(massivePayload);
await scheduler.yield(); // hand control back to the browser
rows.forEach(row => {
row.style.height = (row.offsetHeight * 1.5) + 'px';
});
await scheduler.yield(); // and again
localStorage.setItem('cart', JSON.stringify(data));
});
search.js
// ✗ Runs on every keystroke
input.addEventListener('input', searchEverything);
// ✓ Waits until the user stops typing
input.addEventListener('input', debounce(searchEverything, 300));
Measures the visual stability of your page by tracking unexpected layout shifts.
| < 0.1 | Good | Stable. Content stays where users expect it. |
| 0.1 - 0.25 | Needs Improvement | Noticeable jumping. Users are losing their place. |
| 0.25+ | Failure | The page is actively fighting the user. |
Each layout shift is scored as:
impact fraction × distance fraction
A 400px element in an 800px viewport shifts down by 200px:
| Impact fraction | Before (rows 0–50%) + After (rows 25–75%) = 75% of viewport = 0.75 |
| Distance fraction | Moved 200px ÷ 800px viewport = 0.25 |
| CLS Score | 0.75 × 0.25 = 0.1875 — Needs Improvement |
Your users are clicking the wrong things.
index.html
<!-- Browser has no idea how tall this will be -->
<img src="/hero.jpg" alt="Hero">
<p>User is already reading this...</p>
<!-- Image arrives. Everything below shifts down. -->
page.html
<body>
<main>
<p>...to continue reading,</p>
<a href="/next">Next page →</a> <!-- 👆 user is here -->
</main>
</body>
page.html
<body>
<div class="ad-network-banner">
<!-- SPONSORED --> <!-- 👆 user clicks THIS -->
</div>
<main>
<p>...to continue reading,</p>
<a href="/next">Next page →</a> <!-- ↓ now 90px lower -->
</main>
</body>
typography.css
@font-face {
font-family: 'TallFont';
src: url('/fonts/tall.woff2');
/* font-display: swap = invisible text, then sudden reflow */
}
body { font-family: 'TallFont', sans-serif; }
/* Fallback font: line-height 1.4, 14 lines visible */
/* TallFont arrives: line-height 1.8, now 11 lines */
/* Everything below the text block just shifted up. */
index.html
<!-- Tell the browser exactly what size to expect -->
<img src="/hero.jpg"
width="1200"
height="630"
alt="Hero">
<!-- Or in CSS -->
<style>
.hero { aspect-ratio: 16 / 9; width: 100%; }
</style>
layout.css
/* Give ad slots and widgets a guaranteed home */
.ad-banner { min-height: 90px; }
.cookie-bar { min-height: 64px; }
.chat-widget { min-height: 56px; }
/* Content loads into the reserved space — nothing shifts */
typography.css
@font-face {
font-family: 'BrandFont';
src: url('/fonts/brand-font.woff2') format('woff2');
font-display: optional;
}
/* optional: use the web font only if it's already cached.
Otherwise, stick with the fallback. No swap. No shift. */
index.html

layout.css
/* Anything injected after the initial render */
.ad-banner { min-height: 90px; }
.cookie-bar { min-height: 64px; }
.chat-widget { min-height: 56px; }
/* Content loads into space that was already there — nothing shifts */
typography.css
@font-face {
font-family: 'BrandFont';
src: url('/fonts/brand-font.woff2') format('woff2');
font-display: optional; /* no swap, no shift */
}
handlers.js
// ✗ Everything fires before the browser can paint
input.addEventListener('input', searchEverything);
button.addEventListener('click', runTheWholeThing);
// ✓ Wait until the user pauses (INP win for inputs)
input.addEventListener('input', debounce(searchEverything, 300));
// ✓ Yield between chunks (INP win for heavy handlers)
button.addEventListener('click', async () => {
doFirstThing();
await scheduler.yield();
doSecondThing();
});
| lemon@ahoylemon.xyz | |
| @ahoylemon.xyz | |
| @ahoylemon@mastodon.social | |
| sessionize.com/lemon | |
| github.com/AhoyLemon |