Image and Font Optimization: AVIF/WebP, srcset, NgOptimizedImage Loaders and Font Subsets with Turkish Characters
Resizing images to their size on screen, serving them in the best format the browser accepts (AVIF, WebP, JPEG) and loading fonts with the right subset and fallback metrics are the two jobs that affect LCP and CLS most. We encode Bookstore's covers by Accept in ASP.NET Core with Magick.NET, generate srcset with an NgOptimizedImage loader and cut LCP from 13.7 s to 1.34 s; we measure ğ, ş and İ falling back with the latin subset and how size-adjust reduces the shift.
Version note: The examples in this post were run with Angular 22.2 (with TypeScript 6.0, the version Angular 22.2 supports), .NET 10 (ASP.NET Core 10), Magick.NET 15.1 (Magick.NET-Q8-AnyCPU), fontTools 4.60, Inter 4.1 (the inter-ui package) and Playwright 1.65. All numbers are lab measurements taken on a single machine against a local build with headless Chromium 158. Browser support data comes from MDN browser-compat-data 8.1.9 (2026-11-12).
Predict First
At the end of the previous post, the home page of Bookstore, the online bookstore used as the shared example across this site's series, looked like this on a phone:
$ node measure.mjs http://127.0.0.1:5300/ --runs 5 --profile mobile
run TTFB FCP LCP CLS
med 151 308 13692 0
LCP subparts (median): ttfb=151 loadDelay=27 loadDuration=13510 renderDelay=17
LCP element: html>body>app-root>main>app-home-page>section.hero>img.hero-cover
LCP is 13.7 s, and 13.5 s of it is load duration, the download of the cover image. Dune's cover is a 1200×1800 pixel JPEG of 440 KB; on the phone it is shown 380 CSS pixels wide, and the page has eight more covers. Before reading on, guess: if the same cover is resized to 480 pixels wide and served as AVIF, how many KB is it? 100 KB, 30 KB or 5 KB?
Choosing a Format: AVIF, WebP, JPEG and JPEG XL
Three formats are common for photo-like images today: JPEG (works everywhere), WebP (usually clearly smaller than JPEG) and AVIF (the image format of the AV1 video codec; often the smallest but the most expensive to encode). According to browser-compat-data, AVIF has been supported since Chrome 85, Edge 121, Firefox 93 and Safari 16.4; WebP is in every current browser. The status of JPEG XL changed in 2026: it is supported in Chrome 155 and Firefox 158 and has been in Safari since 17, but it is still missing in Edge. So you can only serve JPEG XL by looking at the Accept header and giving browsers without support another format; in this post we stay with AVIF, WebP and JPEG.
The quality setting does not mean the same thing across formats: 75 for JPEG and WebP and 50 for AVIF give roughly similar visual quality, and those are the values in this post. Pick the quality for your own images by comparing them by eye; flat-colored images with text such as covers behave differently from photos.
Resizing in ASP.NET Core and Picking the Format by Accept
The original covers live in the API's Covers/ folder. A new endpoint picks the closest allowed width for the requested width, chooses the format from the browser's Accept header and caches the result in memory. For image processing we used Magick.NET (the .NET package of ImageMagick, Apache 2.0 licensed), which can write AVIF and WebP:
dotnet add package Magick.NET-Q8-AnyCPU --version 15.1.0
using ImageMagick;
using Microsoft.Extensions.Caching.Memory;
namespace Bookstore.Api.Images;
public sealed record EncodedImage(byte[] Bytes, string ContentType);
// Resizes the original cover (Covers/*.jpg) and encodes it as AVIF, WebP or JPEG.
// Results are kept in a size-limited memory cache; in production a CDN or a disk cache sits in front.
public sealed class CoverImageService(IWebHostEnvironment env, IMemoryCache cache)
{
// Only these widths are produced: an arbitrary ?w= would let anyone fill the cache and burn CPU
public static readonly int[] Widths = [160, 320, 480, 640, 960, 1200];
public async Task<EncodedImage?> GetAsync(string name, int width, string format, CancellationToken ct)
{
string path = Path.Combine(env.ContentRootPath, "Covers", name + ".jpg");
if (!File.Exists(path))
{
return null;
}
string key = $"cover:{name}:{width}:{format}";
return await cache.GetOrCreateAsync(key, async entry =>
{
EncodedImage image = await EncodeAsync(path, width, format, ct);
entry.Size = image.Bytes.Length;
return image;
});
}
private static async Task<EncodedImage> EncodeAsync(string path, int width, string format, CancellationToken ct)
{
using MagickImage image = new(await File.ReadAllBytesAsync(path, ct));
image.Resize((uint)width, 0);
image.Strip();
(MagickFormat magickFormat, uint quality, string contentType) = format switch
{
"avif" => (MagickFormat.Avif, 50u, "image/avif"),
"webp" => (MagickFormat.WebP, 75u, "image/webp"),
_ => (MagickFormat.Jpeg, 75u, "image/jpeg"),
};
image.Quality = quality;
return new EncodedImage(image.ToByteArray(magickFormat), contentType);
}
}
using System.Text.RegularExpressions;
namespace Bookstore.Api.Images;
public static partial class CoverImageEndpoints
{
public static IEndpointRouteBuilder MapCoverImageEndpoints(this IEndpointRouteBuilder app)
{
// GET /img/covers/dune?w=480 -> the smallest allowed width >= w, in the best format the browser accepts
app.MapGet("/img/covers/{name}", async Task<IResult> (string name, int? w, HttpContext http, CoverImageService images, CancellationToken ct) =>
{
if (!SafeName().IsMatch(name))
{
return TypedResults.NotFound();
}
int width = CoverImageService.Widths.FirstOrDefault(x => x >= (w ?? 1200), CoverImageService.Widths[^1]);
string accept = http.Request.Headers.Accept.ToString();
string format = accept.Contains("image/avif") ? "avif" : accept.Contains("image/webp") ? "webp" : "jpeg";
EncodedImage? image = await images.GetAsync(name, width, format, ct);
if (image is null)
{
return TypedResults.NotFound();
}
// Same URL, different bytes per Accept header: caches must key on it
http.Response.Headers.Vary = "Accept";
http.Response.Headers.CacheControl = "public, max-age=86400";
return TypedResults.Bytes(image.Bytes, image.ContentType);
});
return app;
}
[GeneratedRegex("^[a-z0-9-]{1,60}$")]
private static partial Regex SafeName();
}
builder.Services.AddMemoryCache(options => options.SizeLimit = 64 * 1024 * 1024);
builder.Services.AddSingleton<CoverImageService>();
Two decisions matter. The first is the list of allowed widths: an endpoint that accepts any number in ?w= lets anyone request thousands of different sizes and fill both the CPU and the cache. The second is Vary: Accept: the same URL returns AVIF to Chrome and JPEG to an old browser; a cache in between (CDN, reverse proxy, browser) that does not know this would hand one browser's AVIF to the other. The sizes the endpoint produces:
original /covers/dune.jpg (1200x1800): 440.0 KB
width jpeg webp avif
320 8.1 KB 3.9 KB 3.6 KB
480 13.6 KB 6.2 KB 5.5 KB
960 58.1 KB 26.0 KB 16.4 KB
1200 132.0 KB 106.2 KB 44.4 KB
Content-Type: image/avif, Vary: Accept, Cache-Control: public, max-age=86400
If you guessed 5 KB you were right: the 480-pixel AVIF is 5.5 KB, a little over one percent of the original. These covers are smooth gradients with little detail, so the ratio is very high; with real photos the difference is smaller. The price is CPU: the image is encoded on the first request. In a separate test, encoding a 480-pixel cover took 47 ms as JPEG and 124 ms as AVIF; later requests come from the cache.
An NgOptimizedImage Loader, srcset and sizes
For the browser to pick the right width for the screen size and pixel density, it needs srcset and sizes. Instead of writing them by hand we give NgOptimizedImage a loader, which turns the source address and the width into the real URL of the image:
import { ImageLoaderConfig } from '@angular/common';
// The widths Bookstore.Api produces; NgOptimizedImage builds the srcset from the same list
export const COVER_WIDTHS = [160, 320, 480, 640, 960, 1200];
// '/covers/dune.jpg' at 480px -> '/img/covers/dune?w=480' (resized and encoded by Bookstore.Api)
export function coverLoader(config: ImageLoaderConfig): string {
const name = config.src.replace(/^\/covers\//, '').replace(/\.jpg$/, '');
return `/img/covers/${name}?w=${config.width ?? 1200}`;
}
provideClientHydration(),
{ provide: IMAGE_LOADER, useValue: coverLoader },
{ provide: IMAGE_CONFIG, useValue: { breakpoints: COVER_WIDTHS } },
The breakpoint list matches the allowed widths in the API, so every URL the browser might request exists on the server. In the template we only add sizes: it tells how many CSS pixels the image takes at which screen width.
<!-- A real <img>: discoverable, prioritizable; priority = fetchpriority="high" + no lazy loading -->
<img class="hero-cover" [ngSrc]="book.coverUrl!" width="1200" height="1800" priority
sizes="(max-width: 640px) 100vw, 360px" [alt]="book.title" />
<img [ngSrc]="book.coverUrl!" width="1200" height="1800"
sizes="(max-width: 640px) 50vw, 180px" [alt]="book.title" />
The cover in the prerendered HTML now looks like this:
<img width="1200" height="1800" priority="" sizes="(max-width: 640px) 100vw, 360px" class="hero-cover" alt="Dune" loading="eager" fetchpriority="high" decoding="sync" ng-img="true" src="/img/covers/dune?w=1200" srcset="/img/covers/dune?w=160 160w, /img/covers/dune?w=320 320w, /img/covers/dune?w=480 480w, /img/covers/dune?w=640 640w, /img/covers/dune?w=960 960w, /img/covers/dune?w=1200 1200w">
On the Pixel 7 profile the screen is 412 CSS pixels wide with a pixel density of 2.625; for 412 × 2.625 ≈ 1,082 pixels the browser picks w=1200 (44.4 KB as AVIF). The new arrivals covers are 182 CSS pixels wide, and w=480 downloads for them. NgOptimizedImage prepends auto to sizes on lazy images; browsers that support it (Chrome 126, Firefox 150, Safari 27 according to browser-compat-data) use the real layout width.
Measurement: From 13.7 s to 1.34 s
http://127.0.0.1:5300/ profile=mobile runs=5
run TTFB FCP LCP CLS
1 151 320 1352 0
2 151 300 1336 0
3 151 300 1352 0
4 151 300 1336 0
5 151 300 1336 0
med 151 300 1336 0
LCP subparts (median): ttfb=151 loadDelay=22 loadDuration=1146 renderDelay=18
LCP element: html>body>app-root>main>app-home-page>section.hero>img.hero-cover
resource start end render-blocking
/ 0 179 non-blocking
/fonts/inter-latin-400-normal.woff2 174 893 non-blocking
/fonts/inter-latin-700-normal.woff2 175 939 non-blocking
/img/covers/dune?w=1200 175 1320 non-blocking
/styles-EHOBXKHN.css 176 360 non-blocking
/main-VDO5CNCM.js 181 1762 non-blocking
/img/covers/emma?w=480 299 606 non-blocking
/img/covers/the-hobbit?w=480 299 682 non-blocking
/img/covers/middlemarch?w=480 299 946 non-blocking
/img/covers/frankenstein?w=480 299 998 non-blocking
/img/covers/pride-and-prejudice?w=480 300 1185 non-blocking
/img/covers/moby-dick?w=480 300 1275 non-blocking
/img/covers/jane-eyre?w=480 300 1290 non-blocking
/img/covers/war-and-peace?w=480 300 1329 non-blocking
analytics.thirdparty.test:5302/analytics.js 1826 2460 non-blocking
/img/covers/emma?w=640 1836 1849 non-blocking
/img/covers/the-hobbit?w=640 1836 1849 non-blocking
/img/covers/middlemarch?w=640 1837 1849 non-blocking
/img/covers/frankenstein?w=640 1837 1849 non-blocking
/img/covers/pride-and-prejudice?w=640 1838 1849 non-blocking
/img/covers/moby-dick?w=640 1838 1849 non-blocking
/img/covers/jane-eyre?w=640 1839 1850 non-blocking
/img/covers/war-and-peace?w=640 1839 1850 non-blocking
analytics.thirdparty.test:5302/collect 2524 2781 non-blocking
FCP 304 ms, LCP 1336 ms
LCP went from 13,692 ms to 1,336 ms; load duration from 13.5 s to 1.15 s. The nine covers together went from about 4.3 MB to around 90 KB, and the LCP image now has the line almost to itself. The waterfall has an interesting detail: during hydration, w=640 requests start for the eight new arrivals and finish within 10-15 ms. When we followed the same load through the DevTools protocol, these requests were cancelled with net::ERR_ABORTED and the images' currentSrc stayed at w=480; they cost no bytes, but if you see them in RUM or the server logs, this is why. Lighthouse's image delivery insight confirms the change: at the end of the previous post it said "Est savings of 3,960 KiB", now it finds nothing to suggest.
The same change also closed a problem in the first post's RUM table. The cover on the book page was an unsized <img>, and in the lab simulation the phone CLS p75 was 0.32. NgOptimizedImage requires width and height, so the browser reserves the space before the image arrives; in three runs of the same page CLS is 0. The detailed diagnosis of shifts belongs to the CLS post.
Fonts: WOFF2, Origin and font-display
In the second post we started serving Inter from our own origin; WOFF2 is the only format you need today, and with font-display: swap the text shows in the fallback font until the font arrives. Two jobs are left on the font side: loading the right character subset and reducing the shift while the font changes. We measured both on a small lab page separate from the home page: Playwright serves the page itself, the font files arrive 800 ms after the text has been painted, and we ask through CDP which font drew the paragraph. Since the topic is Turkish characters, the text on this page is Turkish:
await tab.goto('http://fontlab.test/');
await tab.waitForTimeout(2500);
const cdp = await context.newCDPSession(tab);
await cdp.send('DOM.enable');
await cdp.send('CSS.enable');
const { root } = await cdp.send('DOM.getDocument');
const { nodeId } = await cdp.send('DOM.querySelector', { nodeId: root.nodeId, selector: '#text' });
const { fonts } = await cdp.send('CSS.getPlatformFontsForNode', { nodeId });
const drawn = fonts.map((f) => `${f.familyName} ${f.glyphCount}`).join(', ');
Turkish Characters and unicode-range
scenario font bytes glyphs drawn for <p> CLS
latin only 46.9 KB Arial 8, Inter 180 0.0092
latin + latin-ext 116.5 KB Inter 188 0.0092
tr subset 31.7 KB Inter 188 0.0092
tr subset + fallback metrics 31.7 KB Inter 188 0.0040
The first row shows the most common mistake. Google Fonts and Fontsource split a font into script groups: the latin subset contains ASCII, Latin-1 (ç, ö, ü) and the dotless ı on its own, but ğ, ş, Ş and İ are in latin-ext. If you load only latin, the browser draws these four letters from the fallback font: 8 glyphs in the paragraph come from Arial, the rest from Inter. Two different fonts show up inside the same word ("Kitaplı" in Inter, "ğ" in Arial). It can be hard to notice, but it stands out at once in bold headings and large type.
The second row is the correct setup: both subsets are declared with unicode-range and the browser downloads the ones the characters on the page need. The price is bytes: Turkish text asks for both subsets, so four files for two weights, 116.5 KB. The third row is our own subset: from Inter's full font we extracted only ASCII, Latin-1, the Turkish letters and a few punctuation marks with fontTools' pyftsubset:
$ pip install fonttools brotli
$ pyftsubset node_modules/inter-ui/web/Inter-Regular.woff2 --flavor=woff2 --output-file=fonts/inter-tr-400.woff2 \
--unicodes="U+0020-007E,U+00A0-00FF,U+011E-011F,U+0130-0131,U+015E-015F,U+2013-2014,U+2018-201D,U+2026,U+20AC,U+20BA"
About 16 KB per weight; 31.7 KB for two weights. There is a trade-off: a character missing from the subset (a Polish or German letter, an emoji) still falls back. On a page that shows user content, keeping latin-ext as a backup through unicode-range is a safe middle ground.
Reducing Font-Induced Shift with Fallback Metrics
The last column of the table is CLS. font-display: swap shows the text at once in the fallback font; when Inter arrives, letter widths and line heights change, lines break in different places and everything below moves. The fix is to scale the fallback font so it takes the same space as Inter: size-adjust matches the width, ascent-override, descent-override and line-gap-override match the vertical metrics. We wrote a small script that computes the values from the two fonts' tables:
# Text the widths are averaged over: the letters of real Bookstore copy, Turkish included
SAMPLE = "Bookstore haftanın kitabı: Dune. Kitaplığımızda Şubat ayında İstanbul'dan gelen yeni çıkanlar var."
def average_width(font: TTFont) -> float:
cmap, hmtx, upem = font.getBestCmap(), font["hmtx"], font["head"].unitsPerEm
widths = [hmtx[cmap[ord(c)]][0] / upem for c in SAMPLE if ord(c) in cmap]
return sum(widths) / len(widths)
web, fallback = TTFont(sys.argv[1]), TTFont(sys.argv[2])
size_adjust = average_width(web) / average_width(fallback)
hhea, upem = web["hhea"], web["head"].unitsPerEm
print(f"size-adjust: {size_adjust * 100:.2f}%;")
print(f"ascent-override: {hhea.ascent / upem / size_adjust * 100:.2f}%;")
print(f"descent-override: {-hhea.descent / upem / size_adjust * 100:.2f}%;")
print(f"line-gap-override: {hhea.lineGap / upem / size_adjust * 100:.2f}%;")
$ python fallback-metrics.py Inter-Regular.woff2 arial.ttf
size-adjust: 105.53%;
ascent-override: 91.80%;
descent-override: 22.86%;
line-gap-override: 0.00%;
Here arial.ttf is a copy of Arial in the working folder; on Windows it is C:\Windows\Fonts\arial.ttf. The result goes into styles.css as a fallback @font-face:
/* Arial scaled to take the same space as Inter: the swap moves less (values from lab/fallback-metrics.py) */
@font-face {
font-family: 'Inter Fallback';
src: local('Arial');
size-adjust: 105.53%;
ascent-override: 91.8%;
descent-override: 22.86%;
line-gap-override: 0%;
}
body { margin: 0; font-family: Inter, 'Inter Fallback', system-ui, sans-serif; color: #0f172a; background: #f8fafc; line-height: 1.5; }
On the lab page the shift went from 0.0092 to 0.0040. On this page both values are far below the "good" threshold (0.1), because the font only moves a few lines; on pages with long text, on narrow screens and added to other shifts the difference grows. Together with the @font-face rules from the second post, the app's font chain is: Inter first, scaled Arial while Inter is missing, the system font if Arial is missing too. In the final measurement of the home page CLS is 0 and LCP 1.35 s.
In Production
Instead of encoding images on request, leaving them to an image CDN (such as Cloudflare Images, imgix or Cloudinary) is also a common choice. NgOptimizedImage ships ready loaders for most of them; they replace the loader we wrote with one line. If you serve your own endpoint, put a cache in front of it and make sure that cache supports Vary: Accept; CDNs handle Vary differently (some only honor Accept-Encoding), so check your CDN's documentation. Our IMemoryCache example can encode the same image twice when two requests arrive at once; under heavy traffic, move encoding to build or upload time, or add a one-at-a-time lock.
Fallback metrics depend on the fallback font. local('Arial') does nothing on devices where Arial is not installed; Android's default sans-serif font is Roboto, and there the text is most likely drawn with the unscaled system font (we did not measure this in the lab). If mobile traffic matters to you, run the same script for Roboto and add a second fallback @font-face. Finally, watch the preloads: beasties itself adds preload links for the fonts used on a prerendered page (we saw it in the previous post); adding another font preload by hand means one more competitor on the same line as the LCP image.
Common Pitfalls
- Loading only the
latinsubset on a Turkish site;ğ,ş,Ş,İare drawn from the fallback font. - Forgetting the
İ/ıpair or the capitals (Ğ,Ş) when building your own subset. - Serving hundreds of icons in one icon font; the page downloads hundreds of KB for three icons. Putting the icons you use in as SVG is cheaper.
- Leaving the
?w=parameter unbounded; anyone can fill your CPU and cache as they like. - Choosing the format by
Acceptand forgettingVary: Accept. - Writing
sizeswrong; the browser picks an image that is too large or too small. Measure the image's real CSS width and write that. - Using images without
width/height; every image that arrives pushes the content below it.
A Small Experiment
★☆☆ Run cover-bytes.mjs for frankenstein and compare the AVIF and WebP difference with Dune. Then lower the AVIF quality in CoverImageService to 35 and open the image in the browser; is the difference visible?
★★☆ Add a row to font-check.mjs that also measures the <h1>, and write the heading "Şubat İndirimi" in bold with only the latin subset. Which letters come from Arial?
★★★ Run fallback-metrics.py for Roboto (you can download Roboto from Fontsource), add a second fallback @font-face and repeat the fourth scenario in font-check.mjs with local('Roboto').
LCP 1.35 s, CLS 0: on the loading side the home page is now good. The last red row in the RUM table is the INP of the catalog: typing into the search box of the 2,000-book list took 232 ms on phones. In the next post we look at the three phases of an interaction (input delay, processing, presentation delay), break up long tasks with scheduler.yield() and make the list smooth in Angular with computed, the right track and virtual scroll.