Full Width [alt+shift+f] Shortcuts [alt+shift+k]
Sign Up [alt+shift+s] Log In [alt+shift+l]
108

My current prototyping environment

from Daniel Marino [alt+shift+b] in programming

The other day I shared why I prefer coding prototypes rather than using design apps to create them. My prototyping environment has evolved over the years. I love to hear how others build prototypes, so I thought I’d share where I’m at now. Maybe you’ll find it useful. A single repository I currently have a single GitHub repo housing all of my prototypes. I do this primarily so I don’t have to remember where any given prototype lives. They all live in the same place! Another benefit is if I pull in a library or some CSS component, I can reuse it in other prototypes without having to go out and grab it from the source again. My old setup In the past I used Sinatra hosted on Heroku. Having Ruby and a basic server (Such as Webrick) in the backend was pretty nice: I could code up some fairly complex prototypes out with realistic url schemes. Gems! If I needed fake data, I could use the Faker gem. If I wanted a table with a 100 rows, I could easily generate that with a super simple loop. But it got clunky. Spinning up a new prototype wasn’t very efficient. Setting up the urls took time. Deploying to Heroku wasn’t always straight forward. Heroku also got rid of their free plan, and I didn’t want to go looking for a new service. Maybe I’m making excuses. A dumb server Now I just use HTML, CSS (vanilla), and Javascript with no special backend. I don’t have Node.js running, and I don’t use a package manager like NPM or Yarn. To start a server I navigate to the prototype directory in iTerm, and run Statikk. Easy peasy. This setup requires no upkeep, so I can focus on actually prototyping! I have a basic file structure for keeping prototypes separate. I typically use Preact for scaffolding. To import Preact or other NPM packages I use esm.sh. When I push changes to the GitHub repo it’s deployed to GitHub pages. I can then share a public URL to folks that need to review the prototype. One glaring problem There’s only one issue I’ve run into using this setup, and its not even...
18th Apr 2024

Stay updated

Get a weekly newsletter with the top 5 articles worth reading every week.

More from Daniel Marino

Letting Claude Teach Me

Now that I use AI regularly to help write code and complete tasks, I find myself in a position where I’m not learning as much as I used to. That feels a bit crummy. Fairly often, Claude writes code I don’t fully understand. For example: I know enough TypeScript to be dangerous, but I’m definitely not an expert. Recently I had Claude take some typing I thought was overcomplicated and drastically simplify it. The new version was clearly better — I just couldn’t tell you why. So I asked Claude to explain it in simple terms. Sure enough, it did. I guess that’s a silver lining to using AI: it can at least explain why it did what it did. A coworker mentioned he has Claude write one-off explanations with examples whenever this happens, and I liked that idea. So I built a skill around it. The skill looks at my uncommitted code, breaks it down, and writes a short blog article for me to read. I’m a fan of Josh Comeau’s blog and the way he uses interactive examples to teach a concept, so I made that part of the skill too — Claude generates interactive examples alongside the explanation, then packages everything up in a small Vite environment. The first iteration was expensive to run. At least 3 minutes, and roughly 10K tokens. I’m fine burning tokens, but I’m also thrifty 😬. The second iteration kept the Vite environment and styles already wired up, so Claude only had to write the article and the examples. That brought it down to about a minute and 7K tokens. Better — though I’d still like to get the token usage lower. The blog format is great, but it’s a lot of work (for Claude) to generate all of this just to delete it when I’m done reading. It’d be nice to have these articles persist somewhere I could revisit later, or share with other people. That brought me to my current iteration. It’s an Astro project that lives on GitHub. The skill works roughly the same way — review uncommitted code, generate a post complete with interactive examples — but now it publishes to a TIL subdomain where I can read the articles at my leisure or pass them along. It’s all vibe coded, and that feels appropriate. I’m up front about it on the site itself: Things Claude changed that I didn’t understand — explained… by Claude. I’ll probably keep tinkering to bring the token usage down further, but it was a fun project to put together, and I’m happy to let Claude teach me what it’s doing.

4th May 2026 • 1 votes
What I’m Using in 2026

This is an updated list of what I’m using in 2026. I’m only sharing what’s changed since last year’s list. Applications Chrome — Chrome was on my list last year, but I’m mentioning it again because I actually used Brave for most of 2025. I like Brave, and honestly I can’t even remember why I switched back to Chrome 🤷🏻. ChatGPT — I hate that I’m listing AI tools, but if I’m going to be transparent, it has to be here. I use it a bit for code, but mostly for rewording messages or rubber-ducking ideas. Claude Code — I wrote about my thoughts and reluctance to embrace AI for coding. My company pays for it, so I use it. Endel — I’m neurodiverse and have been trying to be more intentional about using tools that help me stay focused. Endel is super cool and has a lot of genuinely helpful features. Hyper — I was previously using iTerm2, but switched to Hyper mostly because it feels prettier. That’s probably a dumb reason since I could have customized iTerm2, but I installed Hyper on my new machine and never felt the need to switch back. Retcon — My team is pretty opinionated about Git history, and rewriting history via the command line or VS Code started to feel tedious. Several coworkers use LazyGit, but I didn’t want another terminal-based tool. Retcon does exactly what it promises and lets me handle more advanced Git workflows without having to be a Git super-nerd. Notion — I was using Bear for notes, but didn’t feel like I was getting enough value out of paying for it. That’s not a knock against Bear—it’s a great product. Notion’s free plan works just fine for me. I also tried Obsidian this year and liked it, but parts of it felt quirky. It also seemed like a bigger time investment to make it feel as polished as Notion or Bear. VS Code — I tried Zed for a bit, and there are things I really like about it. That said, I ran into a few weird and buggy behaviors that I just couldn’t get past. Equipment I got a 2025 M4 MacBook Pro when I started my new job this year. It’s more than powerful enough for anything I need to do.

4th Jan 2026 • 1 votes
The Money Filter

One of the biggest challenges I’ve faced as a web designer is how often people expect a website for next to nothing. Somewhere along the way, the perception formed that a website is a commodity—quick, cheap, and interchangeable. So when someone asks what I charge (for a very basic marketing site), I usually reply, “That’ll cost around $5,000.” That number isn’t a gimmick—it’s a filter. It immediately reveals who understands the craft behind the screen, who values quality, and who’s serious about investing in their own business. It separates those looking for something fast and inexpensive from those seeking something purposeful and crafted. It used to frustrate me when people balked at the cost. Now? I see it as a helpful sorting mechanism. It shows me who I actually want to work with. The Cost of “Cheap” There’s no shortage of DIY website builders promising professional results at a low price. And honestly—sometimes that’s fine. If you just need something quick and functional, a template might be all you need. But a boutique website is something entirely different. When you hire me, you’re not buying convenience—you’re investing in craft. A boutique site is designed and engineered specifically for you. It’s not a template dressed up with new colors. It’s built intentionally, shaped around your goals, your audience, your brand’s personality, and the experience you want people to have. And cheap solutions often come with hidden costs: lost conversions poor usability and performance accessibility issues a generic, forgettable brand presence The value of a bespoke website isn’t just in what it does. It’s in how it’s made—and what it communicates about you. Why Bespoke Work Matters When I build a custom website, it’s not just about aesthetics. It’s about alignment—making sure every part of the experience supports your story, your content, and your clients’ needs. Every choice—typography, layout, semantics, performance—is intentional. Anyone can make a website that looks good. I handcraft websites that feel right and function beautifully. As a design engineer, I don’t hand off a static mockup and hope the final result captures the original intent. I design with the intent to build, carrying the project from concept to code so the final experience is cohesive, thoughtful, and true to the vision. That’s what boutique design is: every pixel and every line of code has purpose. The Bottom Line If you only need a website, you don’t need me. But if you want a boutique digital experience—something that reflects your values, your vision, and your commitment to quality—then I’m the right fit. You’re not paying for a website. You’re paying for craftsmanship, collaboration, and care. And while not everyone values that, the right clients do. My pricing isn’t just a number—it’s a filter that helps me focus on the people who appreciate the work, the craft, and the impact it can create.

30th Nov 2025 • 1 votes
How I Handletter

When I first got into hand-lettering, I had a hard time finding people who shared their full process—especially the digitizing phase. So here’s a look at how I typically work. Everyone’s process is different, and a lot depends on your style and how you plan to present the piece. For me, since I don’t have the steadiest hand, I tend to embrace rough, scrappy, distressed looks. Most of my work ends up digital anyway, so mistakes can be fixed later. Here’s a short process video I made a while back. Thumbnails & Sketching Start with quick thumbnail ideas on scrap paper. Refine the chosen direction with a pencil on Bristol Paper. If possible, I use a drawing pencil (softer, lighter lead makes cleanup easier). After inking, I clean up with an art gum eraser. Inking My go-tos are Microns in different thicknesses. In the video I sharedI used a .08 for outlines and Graphic 1 for filling letters. I used a Gelly Roll 10 to add cut-outs, create depth, and fix small mistakes 😬. Digitizing I scan the inked artwork using Scanner Pro on iOS. Clean up in Pixelmator Pro. Add textures for distress and wear (I’ve collected a bunch of texture packs over the years). Where I’ve Used This This is basically the same process I followed for my State Motto series, and most of my other lettering projects. Don’t be afraid to lean into your natural tendencies (like a scrappier style if your hand isn’t steady) and use the digital phase to enhance or correct. Trial and error is a huge part of it.

19th Sep 2025 • 1 votes
Vibe Coding and the Existential Dread of Progress

Does anyone else hate the term vibe coding? I’ve been pretty resistant to incorporating AI directly into my development workflow. I use it all the time to clean up emails, Slack messages, and blog posts (including this one). It’s helped me plan vacations, write my cover letter for Planning Center, and even gain insights from journaling. But coding? That felt like crossing a line. At first, I confined AI to repetitive tasks—formatting long word lists into arrays, deciphering cryptic console errors, or exploring how to integrate Vite into a Rails codebase still using Sprockets. All helpful, but I steered clear of anything that touched “real” engineering. Still, AI isn’t going anywhere. And as much as I hate admitting how much I rely on it, it genuinely makes my life easier. So I gave vibe coding a shot. Spoiler: I’m sold—with a few caveats. Vibe Coding an Alfred Workflow I’d played with basic Alfred workflows before, using the built-in UI or tools like Alfy. But I had an idea for something more complex—and neither the time nor the motivation to learn how to build it from scratch. The Problem At Planning Center, our design system has 300+ tokens (and counting). To grab one, I’d open our Storybook instance, scroll to the search bar, type in a few characters, click to copy, then return to my editor to paste it. Repeat that a few dozen times a day, and the friction starts to add up. The Solution An Alfred workflow that lets me search tokens via fuzzy matching, press Enter to copy, and paste directly—eliminating multiple steps and speeding up my flow. Using Claude Code I’ve tried ChatGPT and GitHub Copilot. They both work well (though I find Copilot’s integration with VS Code a bit invasive). I’m not ready to pay out of pocket, so I started using Claude Code through Planning Center’s access. Setup took less than 10 minutes. Anthropic’s documentation is clear, and their tips on effective usage are actually helpful. Building the Workflow with Claude Code I kicked things off with a prompt: I want an Alfred extension where I can enter “tt” followed by characters, and tokens from URL redacted will be shown using fuzzy search. Claude generated a Python script… that didn’t work. It produced a workflow Alfred couldn’t import. So I responded with: The Alfred extension fails to import. Claude replied something like, “You’re right, let me fix that,” and, impressively, it did. After about three hours of iterative prompts and debugging, I had a fully functional Alfred workflow that: Stores design tokens as a JSON database Caches data locally Refreshes if unused for over an hour Fuzzy-finds tokens on input Displays color swatches for color-based tokens I also used Claude to: Generate 100+ color swatches (since Alfred can’t use data URIs) Write a Bash script that builds the workflow with semantic versioning I don’t know much Python, but I know enough to skim the output and feel confident in the structure. And because this was a small, siloed project, I wasn’t concerned about maintainability or codebase conventions. The Cost Aside from the monthly AI access fee, the entire three-hour build process cost around $10 in usage tokens. That’s ~$3.33/hour—far below the value of my time. And now, every use of the workflow saves me 10+ seconds. That adds up quickly. If I’d tried to build this from scratch, it easily could’ve taken 10+ hours and been half as effective. That’s not even accounting for generating the color swatch images. I did spend about an hour trying to get Claude to scrape the tokens directly from the design system site, but that turned out to be unreliable. Still, the final solution works and required zero extra effort from the rest of my team. The Pros and Cons of AI in the Workflow Pros Efficiency Boost: Great at handling menial but necessary tasks—JSON formatting, token lookups, error decoding, etc. Rapid Prototyping: I built a working Alfred workflow in a single evening. Even if it were only a prototype, it would’ve been valuable. Creative Leverage: Designers and PMs can use AI to sketch out ideas and flows, helping engineers jump into development faster. Cons Skill Stagnation: Over-reliance on AI could erode problem-solving ability or deeper understanding of frameworks and languages. Job Displacement: AI is replacing some roles. At Planning Center, leadership has been clear AI won’t replace people—only support them. But the broader industry picture is less certain. Environmental Cost: Running large AI models consumes a lot of energy. Price Tag: Even if it saves time, AI access and usage can get expensive—especially if you’re footing the bill. So Why Do I Still Feel Icky? It took me a while to name the feeling, but I got there: I’m grieving the loss of what it means to be an engineer. I’ve spent over 20 years solving problems, building UIs, and turning ideas into code. I’ve always taken pride in being able to take a design and bring it to life. But now, what it means to be an engineer is changing—and that’s uncomfortable. I don’t want to become just a prompt wrangler. But I also don’t want to be the person who gets left behind because they refused to adapt. The logic is clear: why pay one engineer for 10 hours of work when another can produce the same result in two with AI? Finding the Sweet Spot I think the answer lies somewhere in the middle. Engineers should still understand their craft—be able to reason through problems, write maintainable code, and build features from scratch when needed. But AI should be a tool we use intentionally to reduce toil and accelerate progress. We don’t call it cheating when someone uses code completion. Why should this be any different?

19th Jul 2025 • 1 votes

More in programming

Must we always use LLMs this way?

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? →

14 hours ago • 1 votes
TokyoDev Welcomes Its Head of Operations

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.

yesterday • 1 votes
Speeding up Kidz Fun Art & making Google love it

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!

yesterday • 1 votes
Why we're moving our warehouse off DuckDB

Five months ago, we rebuilt our warehouse on DuckDB instead of ClickHouse. We did it because a shared ClickHouse cluster couldn't handle hours-long…

yesterday • 1 votes
📚 BoredReading

You seem to be enjoying this.

Join free to unlock everything.

Create free account

Already have an account? Sign in