<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Kunj Hirapara]]></title><description><![CDATA[Notes from building and running full-stack web apps with Next.js, React, Node.js and PostgreSQL: production bugs, upgrades and the fixes that worked.]]></description><link>https://kunjhirapara.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6ae1c81246765dc4faa540c2/fac57237-a3c6-4184-8e83-d65cf6daba78.png</url><title>Kunj Hirapara</title><link>https://kunjhirapara.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Thu, 05 Nov 2026 13:18:56 GMT</lastBuildDate><atom:link href="https://kunjhirapara.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Your analytics tag is slowing your site down]]></title><description><![CDATA[Originally published on kunjdeveloper.com. One Google Analytics tag, pasted into the <head> the way the setup guide says, cost a plain article page 10…]]></description><link>https://kunjhirapara.hashnode.dev/your-analytics-tag-is-slowing-your-site-down</link><guid isPermaLink="true">https://kunjhirapara.hashnode.dev/your-analytics-tag-is-slowing-your-site-down</guid><category><![CDATA[performance]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[Google Analytics]]></category><category><![CDATA[Next.js]]></category><dc:creator><![CDATA[Kunj Hirapara]]></dc:creator><pubDate>Sun, 01 Nov 2026 09:02:39 GMT</pubDate><content:encoded><![CDATA[<p>Originally published on <a href="https://kunjdeveloper.com/blog/analytics-tag-slowing-your-site">kunjdeveloper.com</a>.</p>
<p>One Google Analytics tag, pasted into the <code>&lt;head&gt;</code> the way the setup guide says, cost a plain article page 10 Lighthouse points and 424 ms of blocking time on mobile. I measured it, five runs each. The numbers are below.</p>
<p>I went looking because of an evening spent running Lighthouse on sites I like. Plenty of them scored badly on mobile, and the reports kept pointing at the same thing. Not their code. Their tracking. On most of them the tag scripts alone came to roughly 600 KB, give or take: the Meta Pixel, GA4, Microsoft Clarity and a handful of bot-detection scripts, all of it loaded and run before anything else got a turn.</p>
<p>I won't name them. I don't own them, and I don't know what their teams are up against. But I made the same mistake on my own site, so that one's fair game.</p>
<h2>Nobody owns the cost</h2>
<p>Here's how it usually goes. An agency or the SEO team sends over a Google Tag Manager snippet. The developer pastes it into the <code>&lt;head&gt;</code>, because that's what the instructions say. Then, over a year or so, tags pile up inside the container: GA4, an ads pixel, a heatmap, a chat widget. Each one made sense to someone when it went in.</p>
<p>Two things kept showing up.</p>
<p>Universal Analytics, still loading in 2026. Google stopped processing its data in July 2023. Nobody told the developer it could go, so it didn't.</p>
<p>And GA4, twice. Once as a tag inside GTM, once as a snippet someone handed the developer to paste into the code. Two downloads of the same script, two hits for every visit.</p>
<p>Neither of those is a coding mistake. Marketing owns the tags, the developer owns the page, and what the tags cost the page falls in the gap between them.</p>
<p><img src="https://kunjdeveloper.com/uploads/posts/your-analytics-tag-is-slowing-your-site-down/e81e34f6-gtm-container-audit.png" alt="Diagram: the SEO team or agency adds tags to the GTM container while the developer pastes a hardcoded GA4 snippet. Inside the container, Universal Analytics is dead and should be deleted, GA4 duplicates the hardcoded one, a session recorder should be sampled, and a chat widget firing on all pages should be narrowed. Every page downloads and runs all of it." /></p>
<h2>My own site did it too</h2>
<p>When I measured my site on Lighthouse's mobile profile, <code>gtag.js</code> was about 176 KB and ran for over a second right after the page hydrated. Over a second where a tap on a button just sat there, on every visit, so I could count the visit.</p>
<p>I moved it to load later (code further down). That change shipped alongside a pile of other startup fixes, though, so I can't pull a clean GA-only before and after out of it. So I built a test page.</p>
<h2>The test</h2>
<p>A plain article page with no JavaScript of its own, in three versions: no analytics, GA4's standard snippet in the <code>&lt;head&gt;</code>, and GA4 deferred with the snippet I'll show you further down. Lighthouse 13.6, mobile profile, five runs of each, alternating between versions.</p>
<p>I used a placeholder measurement ID. A real one ships your property's configuration inside the file: for my real ID it's 545 KB unzipped, against 446 KB for the placeholder. So if anything, these numbers flatter GA.</p>
<p>With no analytics, the page scored 100 with 0 ms of blocking time, every run. The deferred version did exactly the same: 100 and 0 ms, every run.</p>
<p>The standard snippet scored between 88 and 91, median 90. Blocking time ran from 391 to 469 ms, median 424. <code>gtag.js</code> came over the wire at 157 KB (446 KB unzipped) and ran for about 540 ms on Lighthouse's simulated phone.</p>
<p>Ten points. From one tag, on a page that otherwise blocks for nothing at all.</p>
<p><img src="https://kunjdeveloper.com/uploads/posts/your-analytics-tag-is-slowing-your-site-down/c4a436f8-ga4-blocking-time-lighthouse.png" alt="Bar chart of Total Blocking Time on Lighthouse mobile: no analytics 0 ms with a score of 100, GA4 in the head 424 ms median (runs from 391 to 469 ms) with a score of 90, GA4 deferred 0 ms with a score of 100." /></p>
<p>That deferred 0 ms needs a footnote. The script didn't get faster. It still runs, four seconds after load or on the first scroll or tap, and by then Lighthouse has stopped measuring. Same work, moved out of the moment the page is loading and someone is trying to use it. I also checked nothing got lost: the page view arrived, and an event I fired before GA had even loaded went out with the next batch once it did.</p>
<p><img src="https://kunjdeveloper.com/uploads/posts/your-analytics-tag-is-slowing-your-site-down/648bcaec-ga4-deferred-timeline.png" alt="Timeline diagram: with GA4 in the head, gtag.js runs for about 540 ms right after first paint and a tap during it has to wait. With GA4 deferred, a tap after first paint is answered straight away, and gtag.js runs later, outside the window Lighthouse measures." /></p>
<h2>"But the snippet is async"</h2>
<p>It is. That fixes half the problem.</p>
<p><code>async</code> means the browser keeps reading your HTML while the script downloads. It says nothing about what happens once the script runs, and it runs on the main thread, the same thread that handles taps, clicks and typing. Then GTM loads every tag in the container, and those run on the main thread too.</p>
<p>Lighthouse calls this Total Blocking Time: every task over 50 ms between first paint and the page being reliably interactive counts against you. It carries 30% of the performance score, more than any other metric.</p>
<p>A small correction to how this usually gets told: TBT isn't a Core Web Vital. It's a lab number. The Core Web Vitals Google measures on real visitors are LCP, CLS and INP, and INP (how fast the page responds to input) is the one tags hurt, for the same reason. A tap that lands while the main thread is busy with tags waits. Bad TBT in the lab tends to show up later as bad INP in Search Console.</p>
<h2>Measure first</h2>
<p>Before you change anything, get two numbers: how slow the page is, and how much of that is tags.</p>
<ol>
<li>Run Lighthouse in Chrome DevTools on the mobile profile and open <strong>Reduce the impact of third-party code</strong>. It lists each third party with its transfer size and how long it held the main thread.</li>
<li>In the Network panel, right-click a <code>googletagmanager.com</code> request and choose <strong>Block request domain</strong>. Do the same for the other tag domains, then run Lighthouse again.</li>
<li>Run each version at least five times. Scores swing by several points between runs of the same page, so one run of each tells you nothing.</li>
</ol>
<p>The gap is what your tags cost. "The developer wants to remove tracking" goes nowhere in a meeting. "Our tags cost us X points on mobile" gets an answer.</p>
<h2>Clean out the container</h2>
<p>This is the cheapest fix there is, and usually the biggest. Sit down with whoever owns marketing and go through the container one tag at a time.</p>
<p>Start with Universal Analytics. It sends data nowhere now, so delete it, along with tags for campaigns that ended and pixels for tools nobody pays for anymore. Then look for GA4 twice: search the codebase for <code>gtag/js</code> and check the container for a GA4 tag with the same measurement ID. Keep one. I'd keep the GTM one, but either works, as long as it's one.</p>
<p>Next, triggers. A chat widget or booking tool that matters on two pages has no business firing on "All Pages". And go easy on session recorders and heatmaps. They watch everything on the page, which makes them some of the heaviest scripts you can add. Sample them, or run them for a few weeks while you're actually researching something, then switch them off.</p>
<p>Every tag that survives gets an owner and a reason. A tag with no owner never gets removed.</p>
<h2>Load what's left later</h2>
<p>Analytics needs to know the visit happened. It doesn't need to know in the first 500 ms.</p>
<p>On my site, GA loads on the visitor's first interaction (a tap, a key press, a scroll) or a few seconds after the page finishes loading, whichever comes first. The timer is for people who read without touching anything. They still count.</p>
<p>Here's the plain HTML version. It works on any site:</p>
<pre><code class="language-html">&lt;script&gt;
  window.dataLayer = window.dataLayer || [];
  function gtag() { dataLayer.push(arguments); }
  gtag('js', new Date());
  gtag('config', 'G-XXXXXXXXXX');

  let loaded = false;
  function loadAnalytics() {
    if (loaded) return;
    loaded = true;
    const s = document.createElement('script');
    s.async = true;
    s.src = 'https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX';
    document.head.appendChild(s);
  }

  ['pointerdown', 'keydown', 'scroll', 'touchstart'].forEach((type) =&gt;
    addEventListener(type, loadAnalytics, { once: true, passive: true })
  );
  addEventListener('load', () =&gt; setTimeout(loadAnalytics, 4000));
&lt;/script&gt;
</code></pre>
<p>The inline part is safe to run early. <code>gtag()</code> only pushes onto the <code>dataLayer</code> array, and the real script works through that queue when it arrives, so events sent before it loads wait instead of disappearing. For GTM, swap the <code>src</code> for your <code>gtm.js</code> URL and keep the <code>dataLayer</code> setup.</p>
<p>It isn't free. Someone who lands and leaves within those few seconds without touching the page won't be counted. On most sites that's a thin slice of bounces, but agree on it with marketing before you ship, not after they spot the dip. And when a tap is what loads GA, the script runs right after that tap, so the next tap can feel slower. Still far better than running it during page load, and it's why the container clean-out matters even when you defer.</p>
<p>On Next.js, <code>@next/third-parties</code> ships <code>GoogleAnalytics</code> and <code>GoogleTagManager</code> components that load after hydration, which already beats a raw tag in the head, and <code>next/script</code> with <code>strategy="lazyOnload"</code> waits for the browser to go idle. I went a step further and only mount the GA component after the first interaction or the timer, because even after hydration it was one long task on a phone.</p>
<h2>If it's still heavy</h2>
<p>There are two bigger options, both with strings attached.</p>
<p>Partytown moves third-party scripts into a web worker, off the main thread. Some tags are happy there. Others break, especially ones that need to touch the page synchronously, so test every tag. On Next.js, the <code>worker</code> script strategy that uses it is still experimental and doesn't work in the App Router.</p>
<p>Server-side GTM has the browser send one stream of events to a server you run, which forwards them to GA4, the ad platforms and the rest. One script in the visitor's browser instead of one per vendor. You pay to host it and someone has to look after it, so it earns its keep once you have several vendors, not for a single GA4 tag.</p>
<h2>Keeping it fixed</h2>
<p>All of this rots the first time someone adds a tag without checking. Agree on a budget with marketing, something like "tags get at most 50 ms of blocking time on mobile", and check it whenever the container changes. Watch the Core Web Vitals report in Search Console too: Lighthouse is one simulated phone, Search Console is your actual visitors. If INP slips from green to orange after a release, open the container's version history before you start blaming your own code.</p>
]]></content:encoded></item><item><title><![CDATA[Next.js 16, one year on: what broke when I upgraded a real site]]></title><description><![CDATA[Turbopack builds broke admin sign-in, the Docker image lost its database driver, and the dev server showed neither. What changed and what to check before…]]></description><link>https://kunjhirapara.hashnode.dev/next-js-16-one-year-on-what-broke-when-i-upgraded-a-real-site</link><guid isPermaLink="true">https://kunjhirapara.hashnode.dev/next-js-16-one-year-on-what-broke-when-i-upgraded-a-real-site</guid><category><![CDATA[Next.js]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[Docker]]></category><category><![CDATA[Web Development]]></category><dc:creator><![CDATA[Kunj Hirapara]]></dc:creator><pubDate>Wed, 28 Oct 2026 10:27:03 GMT</pubDate><content:encoded><![CDATA[<p>Next.js 16 came out in October 2025. A year later it is the version most new projects start on, and the version a lot of working sites still have not moved to. This site moved this month. It is a real app, not a starter: the App Router, a Postgres database through TypeORM, a credentials-based admin panel, and a Docker image that runs on a small VPS.</p>
<p>The official upgrade guide is good and the codemod does most of the typing. What the guide cannot tell you is which of its changes will hit your particular stack. Two problems broke this site's production build in ways a dev server never showed. This is what broke, why, and what I would check before you upgrade yours.</p>
<h2>Turbopack builds your app differently</h2>
<p>The headline change is that <code>next build</code> uses Turbopack by default. Development already did; now production does too. For most code that is invisible. For code that relies on two routes sharing the same instance of a module, it is not.</p>
<p>After the upgrade, the production build served every public page fine, and admin sign-in failed with this:</p>
<pre><code class="language-txt">No metadata for "Admin" was found
</code></pre>
<p>The cause is how the database connection and the bundles meet. The TypeORM <code>DataSource</code> is created once and shared through <code>globalThis</code>, so whichever route connects first registers its entity classes. Turbopack gives each route its own copy of those classes. In production the first request is usually a public page, so the connection held the public page's <code>Admin</code> class, and the auth route asked for a repository with its own, different <code>Admin</code> class. TypeORM compares classes by identity, so as far as it was concerned that entity had never been registered.</p>
<p>webpack, the builder Next.js 15 used for production, shared one module instance across routes, which is why the same code had worked for months.</p>
<p>The fix is one line per lookup: ask for the repository by name instead of by class.</p>
<pre><code class="language-ts">// Breaks when the route that connected first holds a different copy of the class
const admins = ds.getRepository(Admin);

// Works from any route: TypeORM matches the entity by its name
const admins = ds.getRepository&lt;Admin&gt;('Admin');
</code></pre>
<p>The public loaders had already been switched to name lookups months earlier, after the same error showed up in development. The auth route was the one place still passing a class, and nothing exercised it until the production build did.</p>
<p>The lesson is broader than TypeORM. If anything in your app keeps state in a module-level singleton and compares classes or object identity across routes, test it on the production build. <code>next dev</code> will not show you this.</p>
<h2>The Docker image lost its database driver</h2>
<p>This one was not strictly Next.js. It came from upgrading TypeORM to 1.x in the same round, and it only surfaced because of how Next.js packages a standalone build. The two together are worth knowing about.</p>
<p>The site ships as <code>output: 'standalone'</code>. Next.js traces which files the server actually imports and copies only those into <code>.next/standalone</code>, which keeps the Docker image small. TypeORM 1.x loads its database driver with a dynamic <code>require(name)</code>. Output tracing cannot follow a require whose argument is a variable, so the trace quietly left <code>pg</code> out. Every page that touched the database threw <code>DriverPackageNotInstalledError</code>.</p>
<p>The fix is to import the driver yourself and hand it over, which puts it back in the trace:</p>
<pre><code class="language-ts">import pg from 'pg';

new DataSource({
  type: 'postgres',
  driver: pg,
  // ...
});
</code></pre>
<p>It never reached visitors, because CI builds the Docker image and runs a smoke test against it before anything deploys. It had already earned its place once, after an earlier standalone surprise: images uploaded at runtime into <code>public/</code> were not served, and the test grew a check for that too. If your deploy is a standalone build and your CI does not boot it, I would add that before upgrading anything.</p>
<h2>Middleware is now called proxy</h2>
<p><code>middleware.ts</code> is deprecated in favour of <code>proxy.ts</code>, and the exported function is renamed to match. The codemod does the rename. One difference matters if you relied on the Edge runtime: <code>proxy</code> always runs on Node.js, and the guide says to keep <code>middleware</code> for now if you need Edge. The behaviour this site relies on, guarding <code>/admin</code> before a request reaches the page, works the same.</p>
<p>The name is more honest about what the file does: it sits in front of your routes and decides what to do with a request.</p>
<p>I still check the session again inside every admin page and server action. A guard in front of the routes is a convenience, not the only lock on the door.</p>
<h2>next lint is gone</h2>
<p><code>next lint</code> was removed. You run the ESLint CLI directly, with a flat config. The codemod writes one for you; this site's is core web vitals, TypeScript and Prettier in <code>eslint.config.mjs</code>, and <code>npm run lint</code> calls ESLint itself.</p>
<p>The practical difference is that ESLint now lints whatever its config says, not what Next.js decided to point it at. The first run after the switch walked into tool folders that had never been linted before, and they needed adding to the ignores.</p>
<h2>The small breaking changes</h2>
<p>Most of the remaining changes are mechanical, and the codemod handles them:</p>
<ul>
<li><code>params</code>, <code>searchParams</code>, <code>cookies()</code> and <code>headers()</code> are async. Await them.</li>
<li><code>revalidateTag</code> takes a second argument, a cache profile such as <code>'max'</code>. The single-argument form is deprecated and fails type checking.</li>
<li>The minimum Node.js is 20.9, and the supported browsers start at Safari 16.4 and Chrome 111.</li>
<li>If you have a custom <code>webpack</code> config, <code>next build</code> fails on purpose rather than silently ignoring it. You either migrate it or build with <code>--webpack</code>.</li>
</ul>
<p>None of these are hard. They are just noisy, which is a good reason to land the upgrade on its own and not inside a feature branch.</p>
<h2>The React Compiler is stable, and still a choice</h2>
<p>Next.js 16 made built-in support for the React Compiler stable, after the compiler's own 1.0 release. It memoizes components for you, so most hand-written <code>useMemo</code> and <code>useCallback</code> calls become unnecessary.</p>
<p>It is off on this site for now. If you turn it on, two things are worth knowing first. The upgrade guide warns that builds and dev compiles get slower, because the compiler runs through Babel. And eslint-plugin-react-hooks 7 ships rules that only make sense with the compiler on; this site has those three switched off, to be switched back on the day the compiler is.</p>
<h2>What I would check before you upgrade</h2>
<p>If you are still on Next.js 15, this is the short list I would work through:</p>
<ol>
<li><strong>Test the production build, not dev.</strong> Run <code>next build</code> and <code>next start</code>, then click through the parts of the app that only some users reach: sign-in, admin, webhooks, anything behind auth.</li>
<li><strong>Look for shared singletons.</strong> Database connections, caches and clients stored on <code>globalThis</code> are fine. Comparing classes across routes is not.</li>
<li><strong>Boot the standalone output in CI.</strong> If you deploy <code>.next/standalone</code>, start it and hit a few routes before you deploy. Dynamic requires are where tracing loses files.</li>
<li><strong>Upgrade one thing at a time.</strong> The driver problem came from TypeORM, not Next.js, and it was easy to pin down only because the dependency bumps were separate commits.</li>
<li><strong>Run the codemod, then read its diff.</strong> It renames and rewrites correctly. It cannot know which of your routes depend on what it changed.</li>
</ol>
<p>A year on, Next.js 16 is a better default than 15: faster builds, clearer names, and a cache API that says what it does. The upgrade itself is mostly typing. The work is in the few places where your app depended on how the old builder happened to behave, and the production build is the only place that will show you where those are.</p>
<hr />
<p><em>This post first appeared on my site,</em> <a href="https://kunjdeveloper.com/blog/nextjs-16-one-year-on"><em>kunjdeveloper.com</em></a><em>, where I write about building and running full-stack web apps.</em></p>
]]></content:encoded></item><item><title><![CDATA[My portfolio was blank on every iPhone and BrowserStack said it was fine]]></title><description><![CDATA[A friend opened it on an iPhone 13 Pro Max and got the header and nothing else. BrowserStack said the site was fine. Every real iPhone disagreed.]]></description><link>https://kunjhirapara.hashnode.dev/my-portfolio-was-blank-on-every-iphone-and-browserstack-said-it-was-fine</link><guid isPermaLink="true">https://kunjhirapara.hashnode.dev/my-portfolio-was-blank-on-every-iphone-and-browserstack-said-it-was-fine</guid><category><![CDATA[Next.js]]></category><category><![CDATA[CSS]]></category><category><![CDATA[iOS]]></category><category><![CDATA[Web Development]]></category><dc:creator><![CDATA[Kunj Hirapara]]></dc:creator><pubDate>Wed, 28 Oct 2026 10:24:35 GMT</pubDate><content:encoded><![CDATA[<p>I told my friends I'd finally pushed my portfolio live. One of them pulled it up on his phone right there on the bench.</p>
<p>The header loaded. The grid behind the hero loaded. Then nothing. A big empty screen where the rest of my site was supposed to be.</p>
<p>I got roasted. On a roadside bench, for a site I had announced moments earlier. It was funny. It was significantly funnier for them.</p>
<p>The phone was an iPhone 13 Pro Max on the latest iOS, and I'm putting that up front because it kills the excuse I would otherwise have reached for. This was not a five-year-old budget phone dying on 2G. It was a flagship, on mobile data, and my site did not render.</p>
<h2>Blaming Safari, then running out of things to blame</h2>
<p>First instinct: Safari. Blaming Safari is free and usually at least partly correct.</p>
<p>So he opened it in Chrome. Same shit.</p>
<p>That was the clue, and I completely missed it. Every browser on iOS is Safari wearing a different hat. Same WebKit engine underneath, whatever the icon says. "Try it in Chrome" isn't a second test on an iPhone, it's the first test again.</p>
<p>Then another friend opened it on his Samsung. Perfect. Smooth, exactly like my laptop.</p>
<p>Every iPhone broken. Every Android fine. My machine fine. Cool. Love that for me.</p>
<h2>A week of not being able to reproduce it</h2>
<p>This was the genuinely miserable part.</p>
<p>I threw it at BrowserStack. BrowserStack told me the site was fine. Every iOS version I tried, fine, fine, fine.</p>
<p>Then I borrowed my sister's phone. Broken. My cousins' phones. Broken. Every real iPhone I could physically get my hands on failed the same way, while the one service whose entire job is reproducing this stuff kept insisting I was imagining it.</p>
<p>I still don't have a clean explanation for that gap. My best guess is that a fresh remote device on a fast connection never sits in the broken state long enough for you to catch it. Whatever the reason, the lesson stuck: when every real phone you can find disagrees with your testing service, believe the phones.</p>
<p>So I did it the stupid way. Deploy, check on my sister's phone, change something, deploy again. For about a week.</p>
<h2>The thing that actually cracked it</h2>
<p>The next day I downloaded one of those apps that gives you a console on your phone. I genuinely don't remember which one, it was whatever came up first.</p>
<p>Then it took about ten seconds.</p>
<p>The content was there. All of it. Every paragraph sitting in the DOM at <code>opacity: 0</code>, waiting for JavaScript to turn up and give it permission to exist.</p>
<p>Nothing had failed to load. Nothing had crashed. My site had rendered perfectly and then hidden itself from everybody.</p>
<h2>Two things I had done to myself</h2>
<p>I wrote both of these on purpose, thinking I was being clever.</p>
<p>The first was the scroll reveal wrapped around every section:</p>
<pre><code class="language-tsx">&lt;motion.div
  initial="hidden"
  whileInView="show"
  variants={{ hidden: { opacity: 0, y: 24 }, show: { opacity: 1, y: 0 } }}
&gt;
  {children}
&lt;/motion.div&gt;
</code></pre>
<p><code>initial="hidden"</code> is the killer. It bakes <code>opacity: 0</code> straight into the server-rendered markup. The element doesn't come back until the bundle has downloaded, parsed, hydrated and hooked up an IntersectionObserver, and every single link in that chain is something that can be slow.</p>
<p>The second one was dumber. My hero entrance was a GSAP timeline, and to stop everything flashing in its final position before GSAP grabbed it, I'd written the obvious guard:</p>
<pre><code class="language-css">.hero-reveal { opacity: 0; }
</code></pre>
<p>GSAP cleared that on load. So if GSAP didn't run, nothing cleared it, and the hero stayed invisible. Permanently. There's no route back from that state. I had written a line of CSS whose only job was to hide my own content, and then trusted a third-party library to undo it.</p>
<p>Which explains exactly what my friend saw on that bench. The header and the hero grid were plain HTML and plain CSS, so they painted. Everything else in the page was sitting behind a script that hadn't arrived yet.</p>
<p>I'd built a site where the decorative background was more reliable than the writing.</p>
<h2>Why iOS specifically</h2>
<p>Honestly, none of this is iOS-specific, and that's the part worth taking away. Any browser anywhere will show you a blank page if the content is gated behind a script that hasn't run. The only variable is how long the gap lasts.</p>
<p>On my laptop that gap was short enough that I never once saw it while building the entire site. On that iPhone it was long enough to look like a broken website, and occasionally long enough that people gave up and started roasting me instead.</p>
<h2>The fix that wasn't</h2>
<p>My first attempt was <code>prefers-reduced-motion: reduce</code>, wired up through JavaScript. Reasonable idea: if someone doesn't want motion, skip the animation and show everything.</p>
<p>It didn't help at all. The JavaScript still had to load before anything could decide anything, so you got the same blank wait, and then the entire page appeared at once in a single ugly slab.</p>
<p>I hadn't fixed the problem. I'd moved it. Same gate, different key.</p>
<h2>Fixing the hero: no JavaScript at all</h2>
<p>The hero entrance is a staggered fade and rise. There was never a reason for that to be JavaScript. CSS has done this natively for years, so I deleted the timeline, deleted the guard, and wrote it properly:</p>
<pre><code class="language-css">@keyframes hero-fade-up {
  from { opacity: 0; transform: translateY(18px); }
  to   { opacity: 1; transform: translateY(0); }
}

@media (prefers-reduced-motion: no-preference) {
  .hero-badge,
  .hero-sub,
  .hero-cta,
  .hero-terminal {
    animation: hero-fade-up 0.6s cubic-bezier(0.22, 1, 0.36, 1) both;
  }

  .hero-badge    { animation-delay: 50ms; }
  .hero-terminal { animation-delay: 400ms; }
  .hero-sub      { animation-delay: 500ms; }
}
</code></pre>
<p>Two things in there matter more than they look.</p>
<p><code>both</code> as the fill mode. Without it you can't stagger with delays, because each element sits at its natural opacity until its delay runs out and then snaps into place. <code>both</code> tells the browser to apply the <code>from</code> state during the delay and hold the <code>to</code> state afterwards. The useful side effect is that the hidden state now lives inside the animation instead of in a rule of its own. Take the animation away and the element is just visible.</p>
<p>Then the <code>no-preference</code> wrapper, which is the direct fix for my failed attempt. Instead of animating by default and switching it off under <code>reduce</code>, the animation only exists inside <code>no-preference</code>. The plain stylesheet now describes a page that's already finished and static, and motion is the thing being added on top. Reduced motion stopped being something JavaScript had to decide and became the default I opt out of.</p>
<p>Visually it's the same entrance. Same easing, same delays. It just happens at first paint now, before the browser has even asked for the JavaScript.</p>
<h2>Scroll reveals, which do need JavaScript</h2>
<p>These actually depend on scroll position, so I couldn't just delete the JS. What I could change was which direction the logic ran. Instead of rendering hidden and letting JavaScript release it, render visible and let JavaScript arm it.</p>
<pre><code class="language-tsx">export function useInViewReveal(once = true) {
  const ref = useRef&lt;HTMLDivElement&gt;(null);
  const inView = useInView(ref, { once, margin: '-80px' });
  const [hydrated, setHydrated] = useState(false);

  useEffect(() =&gt; setHydrated(true), []);

  const animate: 'show' | 'hidden' = !hydrated || inView ? 'show' : 'hidden';
  return { ref, animate };
}
</code></pre>
<p>The <code>hydrated</code> flag is the whole trick. On the server it's <code>false</code>, so <code>animate</code> is <code>'show'</code>, so the markup that ships is visible. Pair it with <code>initial={false}</code> and Framer stops writing an initial state of its own:</p>
<pre><code class="language-tsx">&lt;motion.div ref={ref} initial={false} animate={animate} variants={variants}&gt;
  {children}
&lt;/motion.div&gt;
</code></pre>
<p>There's now no moment where JavaScript hasn't run and the content is hidden, because "JavaScript hasn't run" and "visible" are the same state.</p>
<p>Once it hydrates, anything already on screen stays put, so nothing flashes and nothing re-animates a paragraph you're halfway through reading. Anything off screen drops to hidden and reveals on scroll like before. On a decent connection you cannot tell that any of this changed.</p>
<p>One thing I got wrong on the first pass: I'd made the staggering container a <code>motion</code> element too, with its own hidden variant. Doesn't matter how careful the children are if the parent can still hide them. <code>RevealGroup</code> is a plain <code>div</code> now that hands each child an incremental <code>delay</code> and has no opinion about visibility.</p>
<h2>What else went, once I started looking</h2>
<ul>
<li>Lenis is lazy-loaded and skipped completely on touch devices. Native iOS scrolling beats anything I'd bolt on top, and not running a permanent <code>requestAnimationFrame</code> loop during hydration helps exactly the phones that need help.</li>
<li>The stacked project cards went back to <code>position: sticky</code> and became a server component. One less scroll library, and under reduced motion it collapses to a normal vertical stack for free.</li>
<li>The timeline progress rail is a small throttled scroll handler instead of a plugin.</li>
<li>The skills marquee waits for idle before mounting rather than shipping dozens of inline SVG paths in the initial HTML. Its height is reserved in CSS so nothing jumps when it arrives.</li>
<li><code>overflow-x: clip</code> on <code>body</code> went back to <code>hidden</code>. <code>clip</code> is the better property and I'd rather use it, but Safari didn't ship it until 16, and an <code>overflow</code> value an older iPhone doesn't understand is not a bug you find quickly.</li>
</ul>
<p>"Modern CSS" and "CSS that works on the phone your friend is holding" are not the same list.</p>
<h2>How to catch this without the bench</h2>
<p>None of it is hard to find once you know to look. It's just completely invisible from where I was standing.</p>
<p>The check that would have saved me a week: turn JavaScript off and reload. DevTools, command palette, Disable JavaScript. If the page still reads top to bottom you're fine. If it's blank, congratulations, you have my bug. Four seconds.</p>
<p>Then throttle properly, and watch the first two seconds rather than the finished state. The finished state always looks great. That's exactly why nobody catches this.</p>
<p>Read the SSR HTML instead of the DOM, too. The inspector shows you the page after hydration, which is precisely the version that's lying to you. <code>curl</code> the URL and grep it for <code>opacity:0</code>. Anything sitting on real content is a problem waiting for a worse connection.</p>
<p>And test on a real phone. Not a simulator, not a remote device farm that will cheerfully tell you everything is fine. A real one, in your hand, on cellular.</p>
<h2>Where it actually stands</h2>
<p>I'm not going to pretend this ended with a perfect Lighthouse score and a lesson learned. It's still not as fast as I want. The entrance is all hand-written CSS now with no motion library involved at all, and I'm still going back and forth on whether to do reduced motion through a proper hook instead of by hand.</p>
<p>But it renders. Immediately, on every phone I can get hold of, whether or not the JavaScript ever turns up. That's a low bar, and I had somehow built a site that couldn't clear it.</p>
<p>The thing had been live and broken for about a week before that bench. I have no idea who else opened it in that window. Probably nobody. I've decided to believe it was nobody.</p>
<hr />
<p><em>This post first appeared on my site,</em> <a href="https://kunjdeveloper.com/blog/blank-on-every-iphone"><em>kunjdeveloper.com</em></a><em>, where I write about building and running full-stack web apps.</em></p>
]]></content:encoded></item></channel></rss>