More from Posts on Nikita Lapkov
I manage my legally obtained content using a media server. Over the years, both hardware and software on the server went through a bunch of transformations which I find interesting. In order to not look at ads on my Samsung TV, I view all of my content through NVIDIA Shield. It is connected to the TV through HDMI and has a working implementation of HDMI eARC that my TV responds to. Because of that, whether NVIDIA Shield turns on or turns off, TV follows suit, which is very convenient.
Whether we like it or not, email is widely used to identify a person. Code sent to email is used as authentication and sometimes as authorisation for certain actions. I’m not comfortable with Google having such power over me, especially given the fact that they practically don’t have any support you can appeal to. If your Google account is blocked, that’s it. Maybe you know someone from Google and they can help you, but for most of us mortals that’s not an option.
Holy shit, what a year that was. It was absolutely bonkers overwhelming. A lot of interesting stuff happened, but at the same time I took on so much more than I could handle. Conferences Speaking at RustLab 2024 During late 2023 and early 2024 I applied to a bunch of conferences. Previously, I didn’t have any luck of becoming a speaker at non-Russian conferences - all of my talk proposals were rejected (with a notable exception of Handmade Seattle, which happened late 2023).
A month ago I received an email from the organisers of Rust Nation UK 2024 inviting me to speak at the conference. One of the speakers got COVID and I was chosen to be their replacement. I had less than 48 hours to prepare the slides, which was a fun challenge, but very stressful! The final result was a talk about my work on elfo with the following abstract: Type-safe and fault-tolerant mesh services with Rust
More in programming
As with my last post on AI, let me open with a statement: Just because most people hold AI a certain way, doesn’t mean that we always must, either now or in the future. As I noted in that piece, and my shades of gray post, I am well aware — and agree with — … Continue reading Must we always use LLMs this way? →
Introduction Hamming Distance Intuitive Estimation of Error Correction Bits Digging Deeper Comparing against BCH Codes What about Reed-Solomon Codes? BCH vs Reed-Solomon Codes References Footnotes Introduction I’ve been interested in error correcting codes (ECC) for a while now, and though I haven’t gotten very far, I keep on reading and, occasionally, writing about it. Except for really simple schemes, ECC theory gets complicated quickly. The two codes that I’ve looked at most are Reed-Solomon (RS) and binary Bose–Chaudhuri–Hocquenghem (BCH) codes. They are closely related: both use polynomial math that is normally performed over a Galois Field, though, as shown in my earlier Reed-Solomon blog post, that’s not strictly required. The encoding algorithms are relatively straightforward but the decoding algorithms are significantly more complicated. RS and binary BCH codes differ in the size of their symbols, their alphabet: a symbol of the former consists of multiple bits, often 8 bits, while a symbol of the latter is a bit. Errors are corrected with a granularity of a symbol. One key ECC question is “How many additional bits do I need to correct a given number of errors?” In this blog post, I check if an intuitive approach that I’ve been using matches reality. I’m trying hard to stay away from theory here, which makes the subject hopefully more accessible, but has the flip side that it’s far from being rigorous and exact. Hamming Distance When you look at the theory of error detecting and correcting codes, the discussion immediately turns to Hamming distance: the number of bit flips required to convert one vector of bits to another. The goal of an error correcting code is to transform the input vector, the message, to an output vector, the codeword, so that the minimum Hamming distance between valid codewords is guaranteed to be at least a certain number. The higher the Hamming distance, the higher number of bits that can be flipped in a codeword without destroying the ability to recover the original message. If \(t\)1 is the number of errors that we want to correct, error coding theory gives us the following formula: In words: to correct a codeword with a maximum number of \(t\) bit errors, the Hamming distance between any 2 codewords must be larger than two times \(t\). The theory also tells us the minimum Hamming distance to detect an error without correcting it: In the rest of this blog post, though, we’ll only care about correction, not detection. Here are 2 quick examples where the message is one bit. Adding 1 bit to the message For a one bit message, there are only 2 possible messages, 0 and 1. Let’s use a code that repeats this bit once: 0 becomes 00 and 1 becomes 11. The Hamming distance between these 2 valid codewords is 2 because you need to flip 2 bits to convert 00 into 11. The equation above tells us that a distance of 2 is not sufficient to fix a one-bit error. Indeed, when we receive 01 as a codeword, it’s impossible to tell if the transmitted codeword was 00 or 11: both have a Hamming distance of 1 to the received word of 01. Adding 2 bits to the message When we expand the 1 bit message to 3 bits and repeat the message bit twice, things are different: 0 transforms into 000 and 1 into 111. The Hamming distance between all possible codewords is 3. The theory says that we should be able to correct a codeword for which only 1 bit was flipped. And indeed, if we receive 001 and there is only 1 bit corrupted, we know for sure that the original message was 0. Intuitive Estimation of Error Correction Bits The concept of a minimum required Hamming distance to correct a number of errors is not particularly complicated, but it’s a different matter to use it to determine the number of additional ECC bits for, say, a BCH code. In fact, I still don’t know how to do it. As an alternative, I found myself using a simple model that works like this: Participant A transmits 1000 bits to participant B over a noisy channel B transmits the same 1000 bits back to A over a perfect channel A transmits a 10 bit error index to signal to B which bit it received wrong Or more general: If a message of \(k\) bits has 1 incorrect bit, you need a number of \(r = \lceil \log_2(k) \rceil\) 2 bits to point out the incorrect bit. Let’s call that number the error index3. To correct \(t\) bits, you need up to \(r = t \cdot \lceil \log_2(k) \rceil\) additional bits. That’s it! The model is simple, it’s not exactly practical, and the number of parity bits has to be only a first order estimation, but I felt like it had to be pretty close to reality. But is it? Digging Deeper Let’s consider some of the aspects of this model: There is no way to signal that there was no corruption That can be solved by allocating one error index value extra to signal a perfect transmission and calculate the number of bits as \(r = \lceil \log_2(k+1) \rceil\) instead of \(r = \lceil \log_2(k) \rceil\). E.g. for a 1000-bit message, an error index from 0 to 999 indicates an error location, and 1000 signals perfect transmission. For messages that are not a power of 2, the number of error indicator bits remains the same. The transmitted error indicator bits can be corrupt as well One way to solve this is by going through the same procedure to verify the error index. For a first iteration, it would cost an additional \(\lceil \log_2(\lceil \log_2(k+1) \rceil ) \rceil\) bits, 4 bits for a 10 bit error index. Of course, those 4 bits might be corrupt too! A better approach is to work backwards: start out with the length of the codeword and the number of errors you want to correct and determine the number of input message bits from there. The number of error indicator bits is then calculated over the length of the codeword and you get \(r = \lceil \log_2(n+1) \rceil\). The length of the message is \(k = n - r\). This breaks the simple model where you transmit message and error indicator separately, but that was not a realistic scenario anyway. It doesn’t consider that the same error index can appear only once If the code can correct 2 errors, you’d need \(\lceil \log_2(k+1) \rceil\) bits for the first error index to indicate zero errors or the location of the first error. For the second error, you need one less value because one location has already been corrected, so the number of bits needed is \(\lceil \log_2(k) \rceil\). This suggests that the number of error indicator bits might be an upper bound. It only applies to error correction the way BCH does it Any random bit can be corrected, but it doesn’t work for RS coding because there a whole symbol can be incorrect. That’s fixable though, I get to that later. You don’t need to signal the correct value Since the symbol is 1 bit in size, the location of the error is sufficient. To fix it, the receiver just needs to invert the bit. This also doesn’t work for RS. Comparing against BCH Codes Forget about using the Wikipedia article to compare the intuition against theory: you won’t find anything explicit. I found this presentation from the National Taipei University useful. The key formulas are on the first slide: Block length: \(n = 2^m -1\) Number of parity-check bits: \(n - k \le m t\) Minimum distance: \(d_{min} \ge 2t + 1\)4 In the equations above, \(k\) is still the length of the original message, \(t\) is the number of bits that can be corrected, \(n\) is the length of the codeword. If we rearrange the first equation, we get: Substitute \(m\) in the second equation: \(r\) is the number of error correction bits, so \(r = n - k\) and: Compare this with the intuitive formula that I started with: The only differences are: I used \(k\) instead of \(n+1\). That accounts for the fact that parity bits themselves need error correction too and that a perfect transmission must be signaled. the BCH equation assumes that the total codeword length is always one less than a power of two. instead of an approximation, it defines an upper bound. Theoretically, all BCH and RS codewords have a length \(n = 2^m -1\) symbols. In practice, the transmitted codewords are often shortened by setting a bunch of the symbols in the input message to zero and not transmitting them.5 When using shortened codes, you still have to use the \(m\) value of the parent code for which \(n = 2^m -1\). The intuitive formula works for long codewords and a limited number of errors to correct: \(n \approx k\). What about Reed-Solomon Codes? RS codes correct symbols not bits but you can still use the intuitive formula to identify the corrupted symbol: in a message with, say, 223 bytes you’d need 8 bits to pinpoint an incorrect byte. However, you also need an additional byte to fix the corruption and that is the case for every error byte that needs to be corrected. To repair \(t\) symbols that are \(s\) bits in size in a message of \(k\) symbols, you’d need this number of bits for the error location: And a bunch of bits with the correct values: Note that \(r_{location}\) points to a symbol, not a bit. Just like BCH codes, \(n = 2^s - 1\) for RS codes. Change \(r_{location}\) to use \(n\) instead of \(k\) and you get: The final result is: It turns out, that is exactly what you’ll find in all texts about RS coding: the number of parity symbols must be at least 2 times the number of corrupt symbols you want to correct. BCH vs Reed-Solomon Codes I’m getting a bit off-topic here but with the intuitive formula validated, it’s interesting to see when RS codes are better than BCH and vice versa. Let’s create an example where codewords are 255 bytes or 2040 bits and where we reserve 32 bytes or 256 bits for error correction. For RS, this allows us to correct 16 corrupt bytes that can contain up to 128 corrupt bits, as long as they’re contained within those 16 bytes. For BCH, we need 11 bits per error or \(11 \cdot 23 = 253\) parity bits to correct 23 corrupt bits which fits within the 256 bits that were allocated for RS. RS is the winner if errors happen in bursts: it doesn’t matter if a burst is 1 or 8 bits long, it counts as a single corruption when they fall within the same byte. BCH wins when errors are spread randomly: instead of fixing 16 bit flips, it can fix 23. In practice, communication protocols often used 2 codes in the following scheme: Transmitter: RS Encoder -> Interleaving -> Convolutional Encoder Receiver: Viterbi Decoder -> De-interleaving -> RS Decoder In this setup, RS is called an outer code and convolutional coding the inner code, because they wrap around the transmission in that order. In many transmission mediums, bit error are uncorrelated and burst errors unlikely. One would think that RS coding is the wrong choice then. However, the Viterbi decoder can introduce bursts of errors when it makes a mistake. The RS decoder is used to clean those up. The burst error length can be so long that a single RS codeword can’t deal with them. That’s why there’s an interleaver to increase the length of burst errors that can be corrected, at the expense of latency and memory storage, but that’s a topic for a different blog post. Modern communication protocols, such as DVB-S2 that’s used by satellite TV, use a low density parity check (LDPC) code instead of a convoluational code as inner code. A LDPC decoder has a lower tendency to introduce long error bursts in case of a mistake and instead introduces single bit errors that are spread randomly within a large codeword. This is where BCH codes shine which is why it’s used instead of RS. All words in this blog posts were written by a human. References Reed-Solomon Error Correcting Codes from the Bottom Up National Taipei University - BCH code CMU Introduction to Coding Theory - Notes 4: Elementary bounds on codes NASA - Bandwidth efficient CCSDS coding standard proposals Discusses outer coding/interleaving/inner coding for space communication. There’s an abundance of information in there, which is what you’d expect from a 70 page document! Footnotes In literature, you’ll often see \(k\) being used for this formula, but further down, \(k\) is used as the length of the original message, which is also a literature convention. So I decided to be consistent within this blog post. ↩ \(\lceil x \rceil\) means: round up to nearest integer. ↩ I came up with “error index” myself, but it turns out to be a term that is commonly used in error coding theory and its meaning is exactly what I had in mind! ↩ This is the minimum Hamming distance equation for error correction. ↩ This assumes that a systematic coding scheme is used, where the original message appears unmodified in the codeword. This is the case for most BCH and RS coding schemes that are used in the real world. ↩
TokyoDev has grown substantially over the past few years. We’ve expanded our community, dramatically increased the scope and frequency of our events, and have curated a library of articles for international developers. Despite all that, the way we run the business hasn’t changed much. I’ve continued to coordinate most of these activities myself, making the final decisions and keeping everything connected. In short, TokyoDev cannot run without my daily input. That approach worked well when TokyoDev was smaller, but it’s increasingly limiting how much we can accomplish. To help the business grow further, I wanted to find someone to take over some of my responsibilities and bring a fresh perspective. This is a significant step for me personally, as while I have worked with many contractors over the years, I have never actually hired a regular employee. Most importantly, I wanted to ensure that this person was someone I could trust to understand what TokyoDev stands for, and to make decisions in line with our philosophy. So I’m happy to announce that Scott Rothrock is joining TokyoDev full time as our Head of Operations. About Scott Scott has lived in Japan for nearly 20 years and has worked as a software engineer in multiple industries and environments, including small Japanese-owned businesses, overseas employers, and international companies based in Japan. Until the end of October 2026, he was the head of the Japan Markets team at Kraken Technologies, where he led an international team of software engineers in delivering complicated, regulated products on strict timelines for Japanese electricity and gas retailers, while also ensuring compliance with evolving organizational and global standards. He’s made a name for himself both inside and outside Japan as a kind, effective mentor who can cultivate great engineers and leaders. Scott’s previous role in TokyoDev Some of you may already know Scott in another capacity, as he has been involved with TokyoDev since 2022. He began as a community volunteer and later became our community manager, which also included responsibilities such as managing our article pipeline from submission to publishing. When I created TokyoDev’s Discord, I had no previous experience running an online community. Scott joined soon after I started and began helping other members, stepping in when people behaved inappropriately, and configuring our roles and permissions and safety features. I was impressed by his initiative and asked him to become an official moderator in January 2023. Outside of his role as a moderator, Scott has always pushed TokyoDev to grow; for example, he arranged internet events such as livestreamed AMAs and interviews. He organized TokyoDev’s first meetup as a volunteer in February 2023, which taught me that there was interest in TokyoDev events outside of the internet. By the end of 2023, I had brought him on as our part-time community manager. What I saw throughout his tenure was that Scott continued to take initiative in expanding TokyoDev. Scott was one of our first external contributors, writing about his experience living with a disability in Japan. He encouraged me to expand and refine both our editing and submission processes so that we could reach out to even more contributors and platform their perspectives. The editorial team now consists of him and Rebecca Callahan, and they’ve had a hand in every article that TokyoDev has published. In other words, since 2022, Scott has shown that he cares about TokyoDev as much as I do. He is invested in what we do and why we do it, and shares my vision for what TokyoDev can become. What changes now Scott’s experiences as an engineer, a mentor, and a leader make me confident that, as our Head of Operations, he will continue to help TokyoDev mature. A big part of what has made TokyoDev successful is that I see the software developers who use our site as my peers and genuinely want to help them. Scott shares that perspective, and will join me in ensuring that TokyoDev remains true to its principles while we grow. So what changes in the short term? Nothing dramatic. I will teach Scott about managing TokyoDev as a business, and he will document our processes and products as a part of that learning. He will also look for areas of improvement while ensuring that we remain compliant with regulations and labor law. Over time, I will hand over some other responsibilities. I will continue to run TokyoDev, but TokyoDev will become less dependent on me for its day-to-day operations. Long term, I’m excited about what this will enable us to do. TokyoDev has always been about helping international software developers build successful careers in Japan, and bringing Scott on full time puts us in a much better position to continue doing that. We look forward to writing the next chapter of TokyoDev together.
Kidz Fun Art is a drawing, colouring and animation app for kids that I’ve been building since 2021. It runs in the browser also as a simple wrapper app on iOS, iPadOS and Windows. It wasn’t getting a great click through rate on Google, even for people who directly searched for it. So, for two days I took a hard look at how fast it loads and how search engines see it. It wasn’t flattering. This post covers what I found, what I changed and what the graphs say now. Short version: on a simulated slow phone, the homepage’s Largest Contentful Paint got about 2.6× faster (15.9 s to 6.1 s) and its first load is about 82% smaller (4.2 MB to 0.75 MB). I also fixed a set of SEO mistakes that are, frankly, embarrassing to write down. I’ll write them down anyway. Part 1: Google had no idea what the site was for I started by reading 16 months of Google Search Console data. 9,380 clicks in total. About 9,200 came from people who had typed “kidz fun art” or a close spelling. People searching for what the app actually does (“coloring app for kids”, “ai art for kids”, “make a comic book”) sent the site 164 clicks. In 16 months. Total. When there was a relevant page, Google ranked it well. The site was #1 for things like “drawing app for kids ipad” and a German query that means “paint by numbers app without ads”. There just weren’t many relevant pages. Then I looked at the code, and the cause was obvious. The homepage <title> was “Kidz Fun Art” in all 12 languages. That’s a fine name, and a terrible title. It tells a search engine nothing. The homepage’s first <h1> was the pricing table’s “Choose your experience”. I was, in effect, telling Google that the most important thing on my homepage was the checkout. Every blog post had the same meta description: a generic line about “news, updates, tips”. Each post’s front matter already had a perfectly good excerpt field that nobody read. Blog posts had two <h1>s: the site header and the post title. There was no structured data at all. The sitemap that redirected every single URL This one stings. The sitemap is the one file whose whole job is to say “here are my URLs, please index them”. Mine listed 17 URLs, and every one of them redirected. The sitemap build used an environment variable that resolved to kidzfun.art. The site lives on www.kidzfun.art, so every URL got a 308. Only the English blog posts were listed. The other 11 languages appeared only as hreflang alternates, which pointed at redirecting URLs too. There were no <link rel="canonical"> tags on any page and no hreflang in the page <head>. Google was left to guess which URL was the real one, and it guessed wrong a lot. What I changed: The sitemap is now built on https://www.kidzfun.art, with 204 URLs: every home page, blog index, blog post, About page and gallery page, in all 12 languages. Each one lists all of its language alternates plus an x-default. A shared SeoLinks component adds an absolute canonical (www, no query string) and the full hreflang set to every public page. The ?v= cache-busting URLs that had been showing up as duplicates now canonicalise to the clean URL. /xx/blog/yy/... URLs, which returned a 500 in production (oops), now 308 to /blog/yy/.... Login, account, debug, export and embed pages send X-Robots-Tag: noindex, follow from next.config.js, so they stop using up crawl budget. robots.txt now mentions the sitemap. Novel idea. Why Google skipped half the site Search Console’s Pages report had 93 of 174 known pages not indexed. Every reason on that list traces back to the mistakes above: “Page with redirect” (22): that sitemap, plus internal links to the non-www host. “Duplicate without user-selected canonical” (18): I hadn’t selected any canonicals. Fair cop. “Crawled, currently not indexed” (39): mostly translated posts that Google reached through redirecting URLs, plus thin pages like /login. “Discovered, currently not indexed” (12): Google found these and decided they weren’t worth a visit. With no sitemap entry and no hreflang, I hadn’t given it a reason to. On-page fixes Titles and descriptions: Each blog post got its own seoTitle and description, in all 12 languages. The meta description now comes from the post’s excerpt, which is what that field was for all along. The About pages take their title and description from their own localized Markdown. They had been sharing an English title and a generic description. One <h1> per page: A # heading inside a post’s Markdown now renders as an <h2>, so the post title is the only <h1>. The About page opts back in with a keepH1 prop. Its Markdown heading is the page title. This fix arrived in two parts. The first part left the About page with zero <h1>s. I noticed, eventually. Structured data and social tags: JSON-LD for the home page (the app), blog posts (articles) and the embedded videos. Open Graph and X/Twitter tags on every public page, so shared links get a proper card. A real 404 for deleted gallery images: The AI gallery shows a friendly “AI Artwork Not Found” page when an image has been deleted. It used to return 200 OK, which Google calls a “soft 404” and dislikes. It now returns a 404 with noindex, and a 503 if the Firestore lookup itself fails, so a database hiccup tells crawlers “come back later” rather than “this is gone”. Part 2: Making the homepage fast The homepage is the app. It works offline, so every dialog (save, stickers, colouring pages, “get an adult”, “no wifi”) is server-rendered into the HTML up front. That’s a deliberate trade-off, and I kept it because I want new users to be able to immediately be able to start drawing as soon as they launch the app. What I hadn’t thought about was what it meant for the network. What the homepage downloaded before the app could start I measured the live site with a Lighthouse-style mobile profile: slow 4G (1.6 Mbps, 150 ms latency), 4× CPU slowdown, empty cache. First load: 4.5 MB over 198 requests. The homepage HTML alone was 358 KB, with about 77 <img> tags in hidden dialogs. The browser dutifully fetched every one of those images. The script that starts the app (main.min.js, 191 KB) sat at the very end of the document, so the browser found it last and gave it whatever bandwidth was left. The app script finished arriving at about 14 seconds. The CPU was not the problem. Every long task was under 300 ms. This was purely about bandwidth and request order. I had built a very polite queue and put my own app at the back of it. Watch the queue The waterfall is a simplified illustration. The milestone times (first paint, LCP, load) are measured: slow 4G + 4× CPU, local production build, median of 3 runs. My Largest Contentful Paint was the “Contact support” link The canvas doesn’t count toward LCP, and the toolbar buttons are small. So on a slow load the largest thing painted was… the loading dialog. The loading dialog fades in its “Taking a while? Contact support” text after 5 to 7.5 seconds. On a slow connection, that text became the page’s LCP element, at 15.9 s. I was measuring my page speed by how long it took to suggest the user complain about my page speed. There’s a lesson in there somewhere. The fix wasn’t to change the dialog. It was to make the app start sooner, so it paints before that text ever appears. What I changed Preload the app script. I added <link rel="preload" as="script"> in <head> for main.min.js and errorHandler.min.js. The <script> tags stay where they were, so execution order doesn’t change. The browser just starts fetching them before any image. This one line did more than anything else on this list. It’s a little humbling. Turn the three dialog GIFs into MP4s. Three animated GIFs: an arrow pushing pictures into a tablet (927 KB), a dancing rainbow pencil (650 KB) and a paper plane (305 KB). That’s 1.9 MB of decoration in dialogs most visitors never open on their first visit. I re-encoded them with ffmpeg: H.264 High profile, -preset veryslow -crf 20, yuv420p, +faststart, constant frame rate taken from each GIF’s frame delay. The commands are committed as scripts/gif-to-mp4.sh. The result is 130 KB in total, with SSIM between 0.988 and 0.993 against the originals. The pencil needed tpad=stop_mode=clone:stop_duration=0.2, because ffmpeg’s GIF reader drops the final 250 ms hold and the dance lost its big finish. The new <video autoplay loop muted playsinline> tags render at exactly the same size and class as the old <img> tags. Alt text moved to aria-label. Locked colouring thumbnails were full-size PNGs. In the colouring page picker, locked pages used the full-size colouring PNG as their background image, inlined into the HTML. That came to about 1.3 MB on every first visit, to draw thumbnails in a dialog that was closed. They now use the small image, set when the picker opens, like the unlocked ones always did. Sticker thumbnails wait their turn. I tried loading="lazy" first. It didn’t help: Chrome fetched the images before the stylesheet that hides the dialogs had arrived, so as far as it knew they were visible. Sticker thumbnails and the media library’s YouTube thumbnail now use data-deferred-src. They load 3 seconds after the load event, or as soon as their dialog opens, whichever comes first. The service worker preload starts after load. The offline preload queue (about 2 MB of colouring pages and stickers) used to start while the page was still loading and compete with it. Now it starts after the load event. Same files, same offline result, no fighting for bandwidth. Analytics is async. Nothing waited for it, so nothing needed to block on it. Cache headers. /images/*: max-age=86400, stale-while-revalidate=604800. It had been max-age=0, so every image was re-checked on every visit. Versioned /js, /css and /addToHomescreen files are immutable for a year in production. Offline still has to work Offline support is a core feature, so “just don’t download it” was never an option. Some of the changes above put it at risk, and a couple of things turned up along the way: Safari plays video from byte ranges. Safari requests video with Range: bytes=.... Answer that with a full cached response and the video won’t play. The service worker now answers Range requests from its cache with a proper 206 Partial Content slice (lib/util/rangeResponse.ts, with unit tests). First-visit gap: on a first visit the service worker isn’t controlling the page yet, so dialog images only reached the offline cache on the second online visit. The page’s own dialog images and videos now go to the front of the offline preload queue on the first visit. I nearly downloaded my whole site, 12 times over. The service worker cached every <link href> it found in the page. The new SEO work had just added canonical and hreflang <link>s pointing at all 12 localized homepages. So every new visitor would have downloaded about 12 extra homepages, in languages they don’t speak, “for offline use”. I caught it before production. The service worker now skips rel=canonical and rel=alternate. Safari stopped the service worker mid-download. The old preload queue ran on timers after the request had been answered. Safari considers an idle service worker fair game, so it stopped it after the first few images. The page now sends images in batches of 10, and the service worker caches each batch before it replies. A page lock-up I’d never noticed. resizeDialog added a new “image loaded” listener every time the window resized. With lots of images loading at once, the listeners multiplied until the page locked up. It now attaches one listener per image, which, in hindsight, is how many it needed. Part 3: The blog Blog index cards used the full 1920 px screenshots as covers, up to 500 KB each, in a box about 200 px wide. Each card was a little billboard, shrunk down by the browser on every visit. Covers now go through next/image with sizes that match the actual card widths, so the browser gets a resized WebP. The first few cards get priority. The rest load lazily. The first sizes hint was too small at some widths and made covers blurry. It now matches the real card widths, up to about 630 px between 769 px and 1028 px wide. Post pages: The cover image gets fetchpriority="high", and images further down the post load lazily. Five images were recompressed in place, keeping the same filenames and dimensions. The PNGs came out about 4× smaller and the JPEGs about 40% smaller. I deleted a 1.1 MB cover.png that nothing referenced. All 12 translations of that post used cover.jpg. The PNG had just been sitting in public/ taking up space. Results on the slow 4G profile: /blog LCP went from 13.3 s to 1.8 s. A typical post went from 3.9 s to 1.7 s. The gallery didn’t get faster, on purpose. It got a 5-minute edge cache and a cached Firestore image count, which fixed its slow server responses. I decided not to change its 20 full-size PNG thumbnails, so its lab LCP barely moved (8.4 s to 8.6 s). That’s a choice, and the chart owns it. Part 4: Lighthouse homework After the speed work went live I ran Lighthouse on the production homepage (mobile) and cleaned up what it flagged. Performance stayed in the low 70s, so I have some more work to do, but it’s moved nicely in the right direction. Part 5: Telling the search engines Fixing the site is half the job. The other half is getting crawlers to come back and notice. Google Search Console: Resubmitted the www sitemap. Clicked “Validate fix” on “Duplicate without user-selected canonical” and on the redirect errors. Requested indexing for the main language homepages and the top blog posts. The quota is about 10 a day, so it’ll take a few days. Wrote down a baseline from the Performance report, so in 4 to 8 weeks I can tell whether any of this worked. Bing Webmaster Tools: Submitted all 204 sitemap URLs through URL Submission. The daily quota is 10,000, so no trickle-feeding needed. Bing’s index also feeds DuckDuckGo and several AI search tools, so it’s cheap extra reach. Most of Bing’s “SEO recommendations” pointed at old ?v= URLs and old titles. They’ll clear as Bing recrawls. What I’m not watching: the Lighthouse score. Google ranks on real-user Chrome data (the Chrome UX Report): a rolling 28-day window, judged at the 75th percentile. So the honest answer to “did it work?” is “ask me in a month”. How I kept from breaking things The app has hundreds of unit and integration tests, all of which still pass (even though they do take over 30 minutes to run). I also manually regression tested all the riskiest changes. Hopefully I don’t make things worse before they get better… Lessons, mostly at my own expense Read your own sitemap. Actually open it and click a URL. Mine had been quietly redirecting for who knows how long. <link rel="preload"> for the script that boots your app is the cheapest big win there is. Browsers fetch resources in the order they find them. If your app script is at the bottom of a 358 KB document full of images, it really is last in line. Check what your LCP element actually is before you try to make it faster. Mine was a “Contact support” link. GIF is an image format from 1987 that I was using as a video format. A real video codec beats it by an order of magnitude at the same quality. try/catch doesn’t catch promise rejections. I knew that. My code didn’t. Every <link> you add to <head> is something your service worker might decide to download. Speed scores are for diagnosis. Rankings come from real users. Fix what the lab finds, then wait for the field data. Epilogue You’re still here? Wow. Cool. After all this, I’m hoping that users notice nothing other than quicker app start times. I’m looking forward to keeping an eye on traffic and retention in the coming weeks. Wish me luck!