On your computer, the Kladde is fast. It comes from localhost, with no network in between, and it consists of four small files. Anyone who opens it on an older phone on the train experiences something else; in lesson 2.1.5, throttling to “3G” already turned a few milliseconds into several seconds. And a real home page almost always has an image, often one straight from the camera, many megabytes in size.

In this lesson, you learn what makes a page slow, how to measure that without being fooled by a single number, and how to fix the most common causes. The Kladde’s home page gets an image along the way, first the way you often see it, then done right.

What makes a page slow

A page appears in steps. The browser requests the HTML and waits for the response. In it, it finds the stylesheet and the images and requests them. Only when the stylesheet has arrived does it draw the page for the first time, because without CSS it would look wrong. A stylesheet in the head is therefore blocking: Until it’s there, the page stays white. The causes that slow things down most often follow from that:

  • Waiting time per request. Every request needs at least one trip to the server and back, the round trip time. On a mobile network, that’s quickly 100 milliseconds or more. Requests that can only begin after another one add up their waiting times.
  • Bytes. A slow network transfers about 1.6 megabits per second, or roughly 200 kilobytes. A photo of 2.7 megabytes then needs about 14 seconds for the transfer alone.
  • Images are the largest part of the bytes on almost every page, and they’re often much larger than they’re displayed.
  • Fonts that have to be loaded before the text appears.
  • Scripts that block drawing or keep the processor busy for a long time. The Kladde doesn’t have one yet; in module 2.2, you’ll see how a script doesn’t hold the page up.
  • The server’s response time, until the first byte of the HTML arrives. That’s what it’s about from module 2.3 on.

The Core Web Vitals

How fast a page feels is described by three metrics, the Core Web Vitals:

Metric measures good poor
LCP, Largest Contentful Paint when the largest image or text block in the visible area has been drawn at most 2.5 s over 4 s
CLS, Cumulative Layout Shift how much content shifts unexpectedly without anyone having done anything at most 0.1 over 0.25
INP, Interaction to Next Paint how long the page takes after a click or key press until it visibly changes at most 200 ms over 500 ms

For a page that many people visit, the thresholds apply to 75 percent of visits: Three out of four should be “good.” Such values from real visits are called field data. What you measure on your computer is lab data: a single load under fixed conditions. Lab data is good for comparing a change before and after. How real people fare, it shows only roughly.

INP exists only when someone does something with the page. A measurement that only loads measures the Total Blocking Time (TBT) instead: how long the processor was so busy during loading that the page couldn’t have responded to an input. A page without a script almost always has 0 here.

Measuring with Lighthouse

Lighthouse is built into the DevTools of Edge and Chrome. This is how you measure load time:

  1. Open an InPrivate window (Ctrl+Shift+N) with the page. Extensions are usually off there, and nothing from the cache distorts the first visit.
  2. Open the DevTools and in them Lighthouse (through the Command Menu if the tab is missing).
  3. Choose Mode: Navigation, Device: Mobile and only the category Performance, then Analyze page load.

After a few seconds, a score from 0 to 100 appears at the top: red up to 49, orange up to 89, green from 90. Below it come the metrics and a list of hints on what can be improved. The score is a weighted average of five metrics: LCP, CLS and TBT count the most, plus First Contentful Paint (when anything appears at all) and Speed Index (how quickly the visible area fills up).

With “Mobile,” Lighthouse calculates as if a mid-range phone were loading over a slow 4G network: about 150 milliseconds of round trip time, 1.6 megabits per second and a processor four times slower than yours. Lighthouse doesn’t actually throttle in the process; it loads fast and calculates how long it would have taken under these conditions. The Performance tool next to it, by contrast, shows real, throttled loads: Set “Network throttling: Slow 4G” and “CPU throttling: 4x slowdown” there, reload, and the cards at the top show LCP, CLS and INP live. The numbers from the two tools differ; compare only measurements made with the same tool and the same settings.

One measurement is not yet a series

Measure the same page five times and you get five different results. The computer is sometimes more busy, sometimes less, an extension or a virus scanner injects scripts into every page, an update is downloading in the background. Four rules follow:

  1. Measure several times, at least three, better five.
  2. Take the median, the middle value, not the average and certainly not the best run. The median is insensitive to a single outlier.
  3. Record the conditions: tool, device, throttling, window, what else was running. Only then can someone else repeat the measurement.
  4. Change one thing at a time. If you change two things at once, you don’t know which one worked.

A difference of a few points between two runs is noise. A difference like the one in this lesson’s exercise, from 18 seconds to 1.5, is not.

Images

In the size they’re displayed

A photo from the camera is 4,000 pixels wide. On the Kladde’s home page, it’s 380 CSS pixels wide on a phone and at most 928 on the screen, as wide as the content of main. If the screen has a higher pixel density (device pixel ratio, DPR for short), it needs more real pixels: A phone with a DPR of 2 shows 380 CSS pixels with 760 real pixels. More than that doesn’t help; the browser scales the image down anyway, and the remaining bytes were transferred for nothing.

In a fitting format

Format for
JPEG photos, when an older program has to process the image further
PNG graphics with sharp edges or transparency, screenshots
SVG logos and icons; a vector graphic stays sharp at any size, like the Kladde’s logo
WebP, AVIF photos and graphics for the web; much smaller than JPEG and PNG at the same quality, and all current browsers display them

srcset and sizes: the browser chooses

With srcset, you offer several sizes of the same image; with sizes, you say how wide it will be on the page. From that, the browser works out with its pixel density which file it needs:

HTML
<img class="office-picture" src="images/office-960.webp"
     srcset="images/office-960.webp 960w, images/office-1920.webp 1920w"
     sizes="(min-width: 60rem) 58rem, calc(100vw - 2rem)"
     width="960" height="640" alt="">
  • 960w means: this file is 960 pixels wide.
  • sizes means: from a window of 60rem on, the image is 58rem wide; below that, as wide as the window minus the two paddings of main. That’s the same calculation as in the stylesheet, only written down for the browser, which has to choose the file before the stylesheet has arrived.
  • src applies to browsers that don’t know srcset.

Which file the browser took is revealed by the console: Select the image in Elements and type $0.currentSrc.

width and height against shifting

If an image loads without dimensions, the browser doesn’t know how tall it will be. It draws the page first without the image, and when the image arrives, it pushes everything below it down. Anyone who was just about to read or tap a link loses their place or misses. That’s what CLS measures.

With width and height in the HTML, the browser knows the aspect ratio from the start and keeps the space free. Together with the rule img { max-width: 100%; height: auto; } from lesson 2.1.4, the image is still as wide as the space; the attributes don’t set the displayed size, only the ratio. With several files in srcset, use the dimensions of one of them, because they all have the same ratio.

loading="lazy" only below the fold

loading="lazy" loads an image only when it comes near the visible area. For images far down a long page, that saves many bytes. For an image you see immediately, it does harm: The browser requests it only after it has laid out the page, instead of right when reading the HTML. If this image is the largest element, LCP gets later, and if the dimensions are missing, the page also shifts. The rule is simple: What you see without scrolling doesn’t get lazy.

Shrinking images with image.cs

For the exercises, you need an image in photo size and a tool for shrinking. The following program can do both. It draws a desk with a laptop, a Kladde, a cup and a plant and gives it the grain of a photo so that the file gets as heavy as one. It can shrink any image. It uses SkiaSharp, a graphics library from the .NET Foundation under the MIT license. Create image.cs in k2-practice:

C#
// image.cs: draws a photo-sized image and shrinks images for the web
#:package SkiaSharp@4.155.0
#:package SkiaSharp.NativeAssets.Linux.NoDependencies@4.155.0

using SkiaSharp;

if (args.Length == 2 && args[0] == "photo")
{
    using SKBitmap photo = Draw(4000, 2667);
    Save(photo, args[1], SKEncodedImageFormat.Jpeg, 92);
    return 0;
}

if (args.Length >= 3 && args[0] == "resize")
{
    using SKBitmap original = SKBitmap.Decode(args[1]) ?? throw new InvalidOperationException($"Not an image: {args[1]}");
    foreach (string width in args[2..])
    {
        int w = int.Parse(width);
        int h = (int)Math.Round(original.Height * (double)w / original.Width);
        using SKBitmap smaller = original.Resize(new SKImageInfo(w, h), new SKSamplingOptions(SKCubicResampler.Mitchell))
            ?? throw new InvalidOperationException($"Resizing to {w} pixels failed.");
        Save(smaller, $"{Path.GetFileNameWithoutExtension(args[1])}-{w}.webp", SKEncodedImageFormat.Webp, 75);
    }

    return 0;
}

Console.WriteLine("Usage: dotnet run image.cs -- photo <file.jpg>");
Console.WriteLine("       dotnet run image.cs -- resize <file> <width> [<width> …]");
return 2;

static void Save(SKBitmap bitmap, string path, SKEncodedImageFormat format, int quality)
{
    using SKImage image = SKImage.FromBitmap(bitmap);
    using SKData data = image.Encode(format, quality);
    File.WriteAllBytes(path, data.ToArray());
    Console.WriteLine($"{path}: {bitmap.Width} × {bitmap.Height} pixels, {data.Size / 1024} KB");
}

// A drawn desk with a laptop, a Kladde, a cup and a plant, plus grain like a photo's.
static SKBitmap Draw(int width, int height)
{
    var bitmap = new SKBitmap(width, height);
    using var canvas = new SKCanvas(bitmap);
    float u = width / 100f;

    using var paint = new SKPaint { IsAntialias = true };
    paint.Shader = SKShader.CreateLinearGradient(new SKPoint(0, 0), new SKPoint(0, height),
        [new SKColor(0xf4, 0xef, 0xe6), new SKColor(0xd9, 0xd2, 0xc4)], SKShaderTileMode.Clamp);
    canvas.DrawRect(0, 0, width, height, paint);
    paint.Shader = null;

    paint.Color = new SKColor(0x8a, 0x5a, 0x3b);
    canvas.DrawRect(0, 57 * u, width, height, paint);

    paint.Color = new SKColor(0x2b, 0x31, 0x30);
    canvas.DrawRoundRect(18 * u, 30 * u, 34 * u, 24 * u, u, u, paint);
    paint.Color = new SKColor(0x8c, 0xc4, 0xf0);
    canvas.DrawRect(20 * u, 32 * u, 30 * u, 20 * u, paint);
    paint.Color = new SKColor(0x3d, 0x4a, 0x47);
    canvas.DrawRoundRect(14 * u, 54 * u, 42 * u, 3 * u, u, u, paint);

    paint.Color = new SKColor(0x1f, 0x5f, 0x5b);
    canvas.DrawRoundRect(60 * u, 45 * u, 18 * u, 12 * u, u / 2, u / 2, paint);
    paint.Color = new SKColor(0xf4, 0xef, 0xe6);
    canvas.DrawRect(62 * u, 45 * u, u, 12 * u, paint);
    for (int line = 0; line < 3; line++)
    {
        canvas.DrawRect(65 * u, (48 + 3 * line) * u, (line == 2 ? 6 : 10) * u, 0.8f * u, paint);
    }

    paint.Color = new SKColor(0xa4, 0x26, 0x2c);
    canvas.DrawRoundRect(81 * u, 49 * u, 6 * u, 8 * u, u, u, paint);

    paint.Color = new SKColor(0x2e, 0x7d, 0x4f);
    canvas.DrawCircle(93 * u, 33 * u, 5 * u, paint);
    canvas.DrawCircle(90 * u, 39 * u, 4 * u, paint);
    canvas.DrawCircle(96 * u, 40 * u, 4 * u, paint);
    paint.Color = new SKColor(0x6b, 0x3f, 0x2a);
    canvas.DrawRect(90 * u, 47 * u, 6 * u, 10 * u, paint);

    // Grain: every color value of every pixel is randomly made a little lighter or darker; the fourth byte is the opacity.
    canvas.Flush();
    using SKPixmap pixmap = bitmap.PeekPixels();
    Span<byte> bytes = pixmap.GetPixelSpan<byte>();
    var random = new Random(7);
    for (int i = 0; i < bytes.Length; i++)
    {
        if (i % 4 != 3)
        {
            bytes[i] = (byte)Math.Clamp(bytes[i] + random.Next(-12, 13), 0, 255);
        }
    }

    return bitmap;
}

The second #:package line brings the parts of SkiaSharp for Linux, for the codespace, for example; on Windows and macOS, they’re already included in the first package. random.Next(-12, 13) returns a whole number from −12 to 12; the upper bound isn’t included. Because Random starts with the fixed number 7, you get the same image on every run.

Bash
dotnet run image.cs -- photo office.jpg
dotnet run image.cs -- resize office.jpg 960 1920
office.jpg: 4000 × 2667 pixels, 2749 KB
office-960.webp: 960 × 640 pixels, 14 KB
office-1920.webp: 1920 × 1280 pixels, 43 KB

Your sizes may differ by a few kilobytes. The 960-pixel version has less than a hundredth of the original’s bytes: about a seventeenth of the pixels, and WebP packs them more densely than JPEG. In addition, shrinking smooths out the grain that makes the original so heavy.

Fonts

The Kladde writes with the system’s fonts (system-ui, “Segoe UI,” Roboto). That costs not a single request. A web font of your own is one file per style: regular, bold, italic, each separately. If you need one:

  • only the styles the page really uses, in the WOFF2 format;
  • font-display: swap in the @font-face rule, so the text appears immediately in a fallback font instead of waiting invisibly;
  • serve the files yourself, from wwwroot. Then no request goes to a third-party server when the page loads, and with it goes no visitor’s address.

A switch from the fallback font to the web font can make text shift that runs wider in the new font. That counts in CLS, too.

Caching

What the browser already has, it doesn’t have to fetch again. How long it may keep something is stated by the server with the Cache-Control header:

Value means
max-age=3600 fresh for one hour; for that long, the browser doesn’t ask at all
no-cache keep it, yes, but before every use, ask the server whether it’s still correct
no-store don’t keep it at all; for responses with personal data
immutable never changes; don’t ask even on a reload
public, private whether an intermediate cache along the way may also keep the response, or only the browser

You know the asking from lesson 2.1.5: The browser sends the ETag of its version in If-None-Match, and if the file is unchanged, the server answers with 304 and no content. That saves the bytes, but not the request’s waiting time.

The usual division follows from that:

  • HTML gets no-cache. If a page changes, everyone should see the new one immediately.
  • Stylesheets, scripts and images with a fingerprint in the name, such as kladde.6vlgbbyfj1.css, get a max-age of one year and immutable. If the content changes, the name changes, and the HTML points to the new file. The old one may sit in the cache forever; nobody asks for it anymore.

UseStaticFiles, which the Kladde has used since lesson 2.1.6, sends an ETag and Last-Modified, but no Cache-Control. Then the browser may estimate for itself how long a file is fresh, and that’s why kladde.css sometimes didn’t reach the server at all in lesson 2.1.5. MapStaticAssets is better. It serves the files from wwwroot as endpoints, with an ETag from the file’s content and with Cache-Control: no-cache for names without a fingerprint. The names with a fingerprint and immutable you get in module 2.5: Blazor puts them into the pages with @Assets["…"]. A static HTML file can’t do that.

Compression

Text packs well, often down to a quarter. The browser says in Accept-Encoding which methods it knows, usually gzip and br (Brotli, which packs a bit smaller still). The server answers with Content-Encoding and the packed content, plus Vary: Accept-Encoding, so that no intermediate cache hands the packed version to a browser that doesn’t understand it. Images in JPEG, WebP or PNG and fonts in WOFF2 are already packed; a second time brings nothing.

MapStaticAssets packs at build time, not on every request: with gzip in the ordinary build, and additionally with Brotli when publishing with dotnet publish. The packed files sit next to the originals, and the server picks the fitting one per request. One line is added to Program.cs:

C#
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.UseDefaultFiles();
app.UseStaticFiles();
app.MapStaticAssets();

app.Run();

Why do UseDefaultFiles and UseStaticFiles stay? MapStaticAssets knows only the files themselves, not default documents. The address / is therefore still rewritten by UseDefaultFiles to /index.html, and UseStaticFiles serves it. Everything else, including /index.html through the navigation’s links, comes from MapStaticAssets. That’s also how it stands in Microsoft’s documentation on Microsoft Learn; what exactly the order of the lines means, you’ll learn in module 2.3.

Measure compression on the published version. With dotnet run in the Development environment, Content-Encoding: gzip is in the header, but MapStaticAssets repacks changed files there at runtime and hardly smaller. Measured for this lesson: kladde.css came there at 4,760 bytes instead of 4,727, with 33 bytes more of packaging.

Exercise 1: Calculating before measuring

Calculate without a browser, with 1.6 megabits per second for the network and 1 kilobyte = 1,024 bytes:

  1. How long does the pure transfer of office.jpg with 2,749 KB take, and how long that of office-960.webp with 14 KB?
  2. How many real pixels of width does the image need on a phone 412 CSS pixels wide (380 of them for the image) at a pixel density of 1.75, and how many at 3? How many on a screen 1,280 pixels wide at DPR 1 and at DPR 2?
  3. Which of the two files from srcset does the browser choose in these four cases?

Check question 3 later in exercise 2 with the device emulation and $0.currentSrc. You set the pixel density in the emulation’s bar; if the field is missing, show it through the bar’s menu with “Add device pixel ratio.”

Solution 1

  1. 2,749 × 1,024 × 8 = 22,519,808 bits, divided by 1,600,000 bits per second: about 14 seconds. For 14 KB: 114,688 bits, so about 0.07 seconds. For both, the request’s waiting time comes on top.
  2. Phone: 380 × 1.75 = 665 pixels, at DPR 3 1,140 pixels. Screen: The content of main is at most 928 CSS pixels wide, so 928 pixels at DPR 1 and 1,856 at DPR 2.
  3. The browser generally takes the smallest file that’s enough: 665 and 928 pixels get office-960.webp, 1,140 and 1,856 pixels get office-1920.webp. It’s not entirely fixed; if the browser already has the larger file in its cache, it may take it.

The calculation shows before any measurement where the problem lies: 14 seconds for an image that would get by on the phone with a quarter of the width.

Exercise 2: The home page gets an image

Work on a new branch home-picture in your Kladde.

Step 1: the image the way you often see it. Create the folder src/Kladde.Web/wwwroot/images and copy office.jpg from your practice folder into it, the photo from the section “Shrinking images with image.cs.” Put the image into index.html between the first paragraph and the cards:

HTML
<img class="office-picture" src="images/office.jpg" alt="" loading="lazy">

The image is a drawing with no meaning of its own; the text next to it says everything. That’s why, as in lesson 2.1.2, it gets alt="". In _layout.scss, add:

SCSS
.office-picture {
  display: block;
  margin-block: var(--space-4);
  border-radius: var(--radius);
}

Step 2: measure. Start the Kladde, open the home page in an InPrivate window and measure three times with Lighthouse (Navigation, Mobile, Performance). For each run, write down the score, LCP, CLS and TBT, and what Lighthouse says about the image among the hints.

Step 3: fix two causes. Copy office-960.webp and office-1920.webp from your practice folder to wwwroot/images. Replace the image with the two WebP files with srcset, sizes, width and height and without loading="lazy", as in the section “srcset and sizes.” Delete office.jpg from wwwroot/images.

Step 4: measure again, three times again, under the same conditions.

Step 5: Record both series of measurements in docs/load-times.md: conditions, the values for each run, the median for each state, and what you changed.

The exercise is checked when LCP and CLS afterwards are in the green range (at most 2.5 seconds and at most 0.1), git status shows no JPEG, and docs/load-times.md contains both series of measurements with their conditions.

Solution 2

Before, the hints about the image say, at the core, two things: It’s much larger than necessary, and it’s loaded lazily instead of being requested right when the HTML is read. In addition come hints about shifts in the layout and about caching. The two causes are therefore:

  1. Size and format: 2.7 MB of JPEG at 4,000 pixels for an image that appears at most 928 CSS pixels wide.
  2. Missing dimensions and lazy: The browser requests the image only after the first layout and until then doesn’t know how tall it will be. When the size arrives, the cards jump down.

The image after the fix:

HTML
<img class="office-picture" src="images/office-960.webp"
     srcset="images/office-960.webp 960w, images/office-1920.webp 1920w"
     sizes="(min-width: 60rem) 58rem, calc(100vw - 2rem)"
     width="960" height="640" alt="">

A series of measurements for this lesson, recorded with the Performance tool in Chromium at “Slow 4G” and 4x CPU throttling, on an emulated phone 412 pixels wide:

State Run 1 Run 2 Run 3 Median
before: LCP 17.98 s 17.96 s 17.94 s 17.96 s
before: CLS 0.19 0.19 0.19 0.19
after: LCP 1.52 s 1.49 s 1.55 s 1.52 s
after: CLS 0 0 0 0

LCP drops from “poor” to “good,” and CLS falls to 0. The values after come from the published version from exercise 3 with a packed stylesheet; on this network, the packed stylesheet saves about 20 milliseconds, and the image makes the difference. Lighthouse calculates with simulated throttling and arrives at other numbers; the direction is the same. TBT stays at 0 in both states, because the Kladde has no script.

Two things are worth a second look:

  • Before, LCP was the paragraph first, after a good second and a half, and only after 18 seconds the image. If a measurement ends too early, the page looks fast although it isn’t. Lighthouse waits until the network is quiet; in the Performance tool, you let the recording run long enough.
  • Without lazy but still without dimensions, nothing shifted in a trial for this lesson: The image was requested right along with the stylesheet, and its size was in the head of the file before the page was drawn for the first time. On slower connections or with more images, that can turn out differently. Dimensions therefore always belong; they cost nothing.

This is what docs/load-times.md can look like, with your own numbers:

# Load times of the Kladde

The home page is measured in Edge, InPrivate window, Lighthouse:
Navigation, Mobile, Performance only, default settings. Three runs per state,
the median counts. Device: <computer>, otherwise running: <nothing / what>.

## Image on the home page
Before: office.jpg, 4000 × 2667 pixels, 2749 KB, without width and height, loading="lazy".
After: office-960.webp and office-1920.webp with srcset and sizes, width and height,
without lazy.

| State  | Score (3 runs)   | LCP (3 runs)     | CLS (3 runs)     | TBT | Median LCP | Median CLS |
| ------ | ---------------- | ---------------- | ---------------- | --- | ---------- | ---------- |
| before | …, …, …          | … s, … s, … s    | …, …, …          | …   | … s        | …          |
| after  | …, …, …          | … s, … s, … s    | …, …, …          | …   | … s        | …          |

Exercise 3: Caching and compression

  1. Add app.MapStaticAssets(); to Program.cs as above and build the Kladde.

  2. Publish it and start the published version in the publish folder. publish/ is already in the .gitignore from K1. Before that, delete the generated CSS: AspNetCore.SassCompiler recompiles only if an SCSS file is newer than the CSS, and a switch from Debug to Release alone doesn’t count. Without this step, you publish the readable CSS from the last Debug build, source map included, instead of the compact version from lesson 2.1.6. rm -r is understood by PowerShell, too.

    Bash
    rm -r src/Kladde.Web/wwwroot/css
    dotnet publish src/Kladde.Web -c Release -o publish
    cd publish
    dotnet Kladde.Web.dll --urls http://localhost:5090
    
  3. In a second terminal, measure how many bytes arrive, once without and once with compression, for css/kladde.css, index.html and images/office-960.webp. On Windows, write the device NUL instead of /dev/null:

    Bash
    curl -s -o /dev/null -w "%{size_download}\n" http://localhost:5090/css/kladde.css
    curl -s -o /dev/null -H "Accept-Encoding: gzip" -w "%{size_download}\n" http://localhost:5090/css/kladde.css
    curl -s -o /dev/null -H "Accept-Encoding: gzip, deflate, br, zstd" -w "%{size_download}\n" http://localhost:5090/css/kladde.css
    
  4. With curl -s -D - -o /dev/null -H "Accept-Encoding: gzip, deflate, br, zstd", read the headers of css/kladde.css. What do Content-Encoding, Cache-Control and Vary say? Then ask again with the ETag from the response in If-None-Match.

  5. What do you get for / instead of /index.html, and why?

  6. Stop the server with Ctrl+C and go back with cd ... Delete src/Kladde.Web/wwwroot/css once more, for the same reason in the other direction: Then the next dotnet build creates the readable CSS with a source map again. Commit Program.cs with the changes from exercise 2.

Solution 3

Measured for this lesson (your numbers depend on your stylesheet):

File without gzip Brotli
css/kladde.css 3,877 1,275 1,052
index.html 1,803 830 669
images/office-960.webp 15,122 15,122 15,122
/ 1,803 1,803 1,803

With Brotli, the stylesheet shrinks to a good quarter and the HTML to a good third. The image stays the same, because WebP is already packed, and MapStaticAssets doesn’t pack it in the first place. If you forgot the deleting in step 2, kladde.css comes at 4,727 bytes, and in the folder publish/wwwroot/css there’s kladde.css.map next to it: That’s the readable CSS of the last Debug build. The stylesheet’s headers:

HTTP/1.1 200 OK
Content-Length: 1052
Content-Type: text/css
Accept-Ranges: bytes
Cache-Control: no-cache
Content-Encoding: br
ETag: "9lG2FpBDT4u7bG8MDW5X2osGL0BCd3hB1y6YXImC34Q="
Last-Modified: <date and time of the build>
Vary: Accept-Encoding

As in the lessons before, Date and Server are left out. Content-Encoding: br means: Brotli, the smallest version, because the browser offered it. Cache-Control: no-cache means: keep it and ask before every use. Vary: Accept-Encoding means: The response depends on what the client sent in Accept-Encoding. The ETag is a fingerprint of the content, and each version has its own: the unpacked one, the one with gzip and the one with Brotli. If you ask with the Brotli version’s ETag and the same Accept-Encoding, you get 304 Not Modified without content.

/ comes unpacked, without Cache-Control and with an ETag of the old kind: This address is rewritten by UseDefaultFiles, and UseStaticFiles serves the file without the advantages of MapStaticAssets. The Kladde’s links point to index.html and get the packed version. In module 2.5, Blazor takes over the home page, and UseDefaultFiles goes away.

The Kladde: an image with dimensions, measurements in docs

On the branch home-picture, these have now been added: the two WebP files in wwwroot/images, the image in index.html, the rule .office-picture in _layout.scss, MapStaticAssets in Program.cs and docs/load-times.md. Check with git status that neither office.jpg nor publish/ is included, and go through the definition of done, including the keyboard check and FastPass from lesson 2.1.7: The new image must be silent for screen readers. Commit with a message like “Add the office picture in the right size and serve compressed assets” and open a pull request with both series of measurements in the description.

That completes the prototype: three pages and an error page, semantic HTML, SCSS in the build, for phone and screen, checked against WCAG 2.2 AA and measured to be fast. In module 2.2, the contact list gets its first script.

What you can do now

  • You explain why a page is slow: waiting time per request, bytes, blocking files, images and fonts.
  • You know LCP, CLS and INP with their thresholds and tell lab data from field data.
  • You measure with Lighthouse and the Performance tool several times under fixed conditions and summarize the runs with the median.
  • You deliver images in a fitting size and as WebP with srcset and sizes, with width and height and without lazy in the visible area.
  • You read Cache-Control, ETag, Content-Encoding and Vary, let MapStaticAssets pack when publishing and measure on the published version.

Further reading