<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Chetan Sanghani</title>
    <description>The latest articles on DEV Community by Chetan Sanghani (@chetansanghani).</description>
    <link>https://dev.to/chetansanghani</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4012473%2Fd0f9b093-7be7-4801-be6e-1e88a0198457.JPG</url>
      <title>DEV Community: Chetan Sanghani</title>
      <link>https://dev.to/chetansanghani</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/chetansanghani"/>
    <language>en</language>
    <item>
      <title>The Indian Income Tax notice cascade, as a state machine: CPC Supreme Court (walkthrough for salaried filers)</title>
      <dc:creator>Chetan Sanghani</dc:creator>
      <pubDate>Fri, 31 Jul 2026 15:18:07 +0000</pubDate>
      <link>https://dev.to/chetansanghani/the-indian-income-tax-notice-cascade-as-a-state-machine-cpc-supreme-court-walkthrough-for-2nf1</link>
      <guid>https://dev.to/chetansanghani/the-indian-income-tax-notice-cascade-as-a-state-machine-cpc-supreme-court-walkthrough-for-2nf1</guid>
      <description>&lt;p&gt;The Income Tax Department has been issuing lakhs of Section 80GGC (political-party donation) scrutiny notices to salaried filers for AYs 2019-20 through 2022-23. If you claimed 80GGC in any of those years, there's a nonzero chance the notice arrives — and the single worst thing you can do is ignore it.&lt;/p&gt;

&lt;p&gt;This post frames the Indian tax-scrutiny process the way a developer would find useful: &lt;strong&gt;as a state machine.&lt;/strong&gt; 8 states, strict deadlines at every transition, and missing one edge type forfeits your appeal at that level.&lt;/p&gt;

&lt;p&gt;The specific example is Section 80GGC because that's the current notice wave, but the cascade shape applies to any Chapter VI-A dispute.&lt;/p&gt;

&lt;h2&gt;
  
  
  The state machine
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌──────────────────────────┐
│  1. ITR Filed            │
└──────────┬───────────────┘
           ▼
┌──────────────────────────┐
│  2. CPC processing       │  Section 143(1) intimation
│     (9 months)           │  → arithmetic reconciliation
└──────────┬───────────────┘
           │
     ┌─────┴─────┐
     ▼           ▼
[No issues]  [Adjustment]  ─────┐
                                │  Rectify under Sec 154
                                │  within 4 years
┌──────────────────────────┐    │
│  3. Scrutiny selection   │◄───┘
│  Section 143(2) notice   │
│  (dept's 3-month deadline)
└──────────┬───────────────┘
           ▼
┌──────────────────────────┐
│  4. AO assessment        │  Section 142(1) requisitions
│  (12 months from AY end) │  + Section 143(3) order
└──────────┬───────────────┘
           │
    ┌──────┴──────┐
    ▼             ▼
[Accepted]    [Addition]  ────┐  30 days to appeal
                              │
┌──────────────────────────┐  │
│  5. CIT(A) — Form 35     │◄─┘
│  (12-24 months)          │
└──────────┬───────────────┘   60 days to next
           ▼
┌──────────────────────────┐
│  6. ITAT — Form 36       │  Quasi-judicial
│  (12-36 months)          │
└──────────┬───────────────┘   120 days
           ▼
┌──────────────────────────┐
│  7. High Court           │  Substantial law question only
│  (1-4 years)             │
└──────────┬───────────────┘   90 days
           ▼
┌──────────────────────────┐
│  8. Supreme Court        │  SLP under Article 136
│  (3-8 years, &amp;lt;5% admit)  │
└──────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  State 1 → 2: filing to CPC
&lt;/h2&gt;

&lt;p&gt;Automated arithmetic check by the Centralised Processing Centre in Bengaluru. Timeline: within 9 months from end of the FY in which the return was filed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Outcomes:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No adjustment → refund / no-demand intimation&lt;/li&gt;
&lt;li&gt;Adjustment → intimation under Section 143(1) with a demand or reduced refund&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;80GGC-specific triggers at this stage:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Claim on a New Regime ITR → auto-disallowed (New Regime disables Chapter VI-A except 80CCD(2)/80CCH/80JJAA)&lt;/li&gt;
&lt;li&gt;Claim above your reported gross income → auto-disallowed&lt;/li&gt;
&lt;li&gt;Missing Chapter VI-A schedule entries where the claim is present&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If CPC disallows and you disagree, file &lt;strong&gt;rectification under Section 154&lt;/strong&gt; within 4 years from end of the FY the order was passed. That's a soft transition — NOT scrutiny.&lt;/p&gt;

&lt;h2&gt;
  
  
  State 2 → 3: the department's deadline
&lt;/h2&gt;

&lt;p&gt;The critical fact: &lt;strong&gt;the Section 143(2) scrutiny notice must be served within 3 months from the end of the FY in which the return was filed.&lt;/strong&gt; If they miss it, they cannot open scrutiny for that AY — only reassess later under Section 148.&lt;/p&gt;

&lt;p&gt;Selection triggers specific to 80GGC:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Donation quantum inconsistent with reported income (₹1L donation on ₹6L salary)&lt;/li&gt;
&lt;li&gt;Party PAN linked to earlier bogus-registration flags&lt;/li&gt;
&lt;li&gt;Donation date bunched with hundreds of other salaried donors on the same day (agent-driven manufactured donations)&lt;/li&gt;
&lt;li&gt;No prior donation history + sudden ₹50,000+ donation&lt;/li&gt;
&lt;li&gt;Reversal patterns (donation debited then credited back after a lag)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  State 3 → 4: the AO back-and-forth
&lt;/h2&gt;

&lt;p&gt;The AO issues specific requisitions under Section 142(1) — numbered lists of documents. You upload responses via e-Proceedings. The AO may issue follow-ups. Personal / video hearing is available.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Timeline for the AO to complete assessment&lt;/strong&gt;: 12 months from the end of the AY. Extensions for TP / abroad / DRP references.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the AO looks for in 80GGC cases:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Genuineness of the political party (registered under Section 29A of RPA 1951? still-active registration?)&lt;/li&gt;
&lt;li&gt;Genuineness of the donation (bank trail matching receipt; no offsetting refund flow)&lt;/li&gt;
&lt;li&gt;Source of funds (donor's own income vs borrowed / gifted funds)&lt;/li&gt;
&lt;li&gt;Whether the donation aligns with donor's declared affiliation / activity pattern&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the order goes against you, the assessment order under Section 143(3) is issued. &lt;strong&gt;30 days from date of service&lt;/strong&gt; to file appeal to CIT(A).&lt;/p&gt;

&lt;h2&gt;
  
  
  The appellate deadlines — memorise these
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Transition&lt;/th&gt;
&lt;th&gt;Form&lt;/th&gt;
&lt;th&gt;Deadline&lt;/th&gt;
&lt;th&gt;Filing fee&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AO → CIT(A)&lt;/td&gt;
&lt;td&gt;Form 35&lt;/td&gt;
&lt;td&gt;30 days from AO order&lt;/td&gt;
&lt;td&gt;₹250-₹1,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CIT(A) → ITAT&lt;/td&gt;
&lt;td&gt;Form 36&lt;/td&gt;
&lt;td&gt;60 days from CIT(A) order&lt;/td&gt;
&lt;td&gt;₹500-₹10,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ITAT → High Court&lt;/td&gt;
&lt;td&gt;Reference/appeal&lt;/td&gt;
&lt;td&gt;120 days from ITAT order&lt;/td&gt;
&lt;td&gt;₹1,00,000+ (advocate cost)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HC → Supreme Court&lt;/td&gt;
&lt;td&gt;SLP under Article 136&lt;/td&gt;
&lt;td&gt;90 days from HC order&lt;/td&gt;
&lt;td&gt;₹5,00,000-₹25,00,000+&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Miss any deadline, forfeit right to appeal at that level.&lt;/strong&gt; The state machine has no "retry" edge from missed deadlines.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "20% rule" at CIT(A)
&lt;/h2&gt;

&lt;p&gt;At CIT(A) stage, to prevent the department from initiating recovery while your appeal is pending, you must:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pay 20% of disputed demand, OR&lt;/li&gt;
&lt;li&gt;Obtain a stay-of-recovery order&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is procedural. A CA can help with the stay application if you don't want to lock up 20% of the disputed amount for 12-24 months of pendency.&lt;/p&gt;

&lt;h2&gt;
  
  
  What NOT to do (the anti-pattern list)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;❌ &lt;strong&gt;Ignore the notice.&lt;/strong&gt; Non-response converts the state machine into best-judgment ex-parte assessment under Section 144 — full disallowance + interest + penalty. Ignoring is the single worst state transition.&lt;/li&gt;
&lt;li&gt;❌ &lt;strong&gt;Concede a genuine claim to close the file.&lt;/strong&gt; Weakens your position for future AYs with the same donation pattern.&lt;/li&gt;
&lt;li&gt;❌ &lt;strong&gt;Engage "tax notice resolvers"&lt;/strong&gt; who promise settlement without asking to see your documents. They file a bad response, then disappear.&lt;/li&gt;
&lt;li&gt;❌ &lt;strong&gt;Respond emotionally on e-Proceedings.&lt;/strong&gt; Every response is on record — used against you at appeal.&lt;/li&gt;
&lt;li&gt;❌ &lt;strong&gt;File frivolous appeals for delay.&lt;/strong&gt; ITAT + HC can impose cost orders under their inherent powers.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What TO do (the correct-transition list)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;✅ &lt;strong&gt;Respond within stated timelines.&lt;/strong&gt; Even a short "acknowledged, working on documents, please grant 14 more days" filing preserves your position.&lt;/li&gt;
&lt;li&gt;✅ &lt;strong&gt;Keep every donation-related document for at least 10 years.&lt;/strong&gt; Section 148 reassessment can reach that far for escaped income above ₹50 lakh in asset form.&lt;/li&gt;
&lt;li&gt;✅ &lt;strong&gt;Consult a qualified CA or tax advocate&lt;/strong&gt; if disputed tax exceeds ~₹2 lakh.&lt;/li&gt;
&lt;li&gt;✅ &lt;strong&gt;Request a personal or video hearing.&lt;/strong&gt; Written submissions alone are less persuasive.&lt;/li&gt;
&lt;li&gt;✅ &lt;strong&gt;If the claim was a genuine mistake&lt;/strong&gt; (e.g. 80GGC on New Regime), accept the demand + interest, move on. Fighting a losing case escalates costs.&lt;/li&gt;
&lt;li&gt;✅ &lt;strong&gt;Revised return under Section 139(5)&lt;/strong&gt; if the mistake is caught before the department flags it — reduces penalty exposure.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The bogus-party defence
&lt;/h2&gt;

&lt;p&gt;A specific 80GGC angle worth calling out: what if the party you donated to was later flagged as bogus by the Income Tax Department?&lt;/p&gt;

&lt;p&gt;Not automatic disallowance. Courts have repeatedly held that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The donor's obligation is to verify the party was registered under Section 29A of RPA 1951 &lt;strong&gt;at the time of donation&lt;/strong&gt;, AND&lt;/li&gt;
&lt;li&gt;Make the payment via non-cash mode with a valid receipt&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If both are documented, post-donation events at the party (deregistration, later fraud finding) do NOT automatically vitiate the donor's deduction. You will still need to demonstrate the reasonable due diligence you exercised at the time — bank trail, receipt with party's PAN + registration proof, ECI website snapshot from that date.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to just pay and move on
&lt;/h2&gt;

&lt;p&gt;For an individual salaried assessee, the practical materiality thresholds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Below ~₹50,000 in disputed tax&lt;/strong&gt;: pay + move on. Legal + advocate costs at ITAT alone exceed the amount.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;₹50K to ₹2L in disputed tax&lt;/strong&gt;: fight at CIT(A) with a CA. Don't escalate to ITAT unless CIT(A) affirms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Above ₹2L in disputed tax&lt;/strong&gt;: worth full CA + advocate representation through the cascade.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Above ₹10L in disputed tax&lt;/strong&gt;: usually worth appealing to ITAT even if CIT(A) affirms.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Over 99% of individual 80GGC cases settle at CIT(A) or ITAT. The Supreme Court is not the realistic exit route for an individual assessee.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I'm framing this as a state machine
&lt;/h2&gt;

&lt;p&gt;The Indian tax appellate structure looks intimidating in prose form. But it's a finite state machine with clear transitions, strict deadlines, and specific document requirements at each state. Once you internalise the shape, drafting the response becomes tractable engineering work — enumerate the documents, hit the deadline, request the hearing.&lt;/p&gt;

&lt;p&gt;Full statutory framing + timeline table + do/don't lists + FAQ + case-law framing: &lt;a href="https://smarttaxcalc.in/blog/80ggc-notice-process/" rel="noopener noreferrer"&gt;smarttaxcalc.in/blog/80ggc-notice-process/&lt;/a&gt; — CA-reviewed by ICAI 644575.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Disclaimer&lt;/strong&gt;: this is a general legal-process explainer, NOT legal advice for any specific case. If you have received an 80GGC notice or any tax scrutiny notice, consult a qualified CA or tax advocate before drafting a response.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The author builds &lt;a href="https://smarttaxcalc.in" rel="noopener noreferrer"&gt;SmartTaxCalc.in&lt;/a&gt; — free browser-based Indian tax calculator suite. Blazor WebAssembly, CA-reviewed, no signup, no backend. Previously wrote about &lt;a href="https://dev.to/chetansanghani/"&gt;the perquisites that survive the New Regime&lt;/a&gt; and &lt;a href="https://dev.to/chetansanghani/istrimmablefalse-is-not-enough-how-one-wasm-setting-silently-broke-my-blazor-app-for-4-days-37d4"&gt;Blazor WASM trim mode surprises&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>analysis</category>
      <category>software</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>The 4 Indian salary perquisites that survive the New Tax Regime (what your HR probably won't volunteer)</title>
      <dc:creator>Chetan Sanghani</dc:creator>
      <pubDate>Fri, 31 Jul 2026 15:18:01 +0000</pubDate>
      <link>https://dev.to/chetansanghani/the-4-indian-salary-perquisites-that-survive-the-new-tax-regime-what-your-hr-probably-wont-19bp</link>
      <guid>https://dev.to/chetansanghani/the-4-indian-salary-perquisites-that-survive-the-new-tax-regime-what-your-hr-probably-wont-19bp</guid>
      <description>&lt;p&gt;If you're a salaried developer in India, this is money left on the table because nobody frames it right.&lt;/p&gt;

&lt;p&gt;The FY 2026-27 conventional wisdom: on the New Regime you can only claim the ₹75,000 standard deduction + employer NPS. Everything else — HRA, LTA, 80C, 80D — is disabled. Which is why so many mid-senior developers who took the New Regime for the higher ₹12L Section 87A rebate think their tax bill is just: gross minus standard deduction, slab math, done.&lt;/p&gt;

&lt;p&gt;That framing misses &lt;strong&gt;four employer-provided benefits that stay exempt in BOTH regimes&lt;/strong&gt; because they aren't Section 10 exemptions — they're Rule 3 perquisite valuations. The New Regime dropped Section 10; it did NOT touch Rule 3.&lt;/p&gt;

&lt;p&gt;Combined tax savings at 30% + 4% cess bracket: typically &lt;strong&gt;₹36,000 to ₹1,20,000/year&lt;/strong&gt; depending on which your employer offers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule that survived: Rule 3
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Section 10&lt;/code&gt; of the Income Tax Act defines &lt;em&gt;which&lt;/em&gt; benefits are exempt from income. &lt;code&gt;Rule 3&lt;/code&gt; of the Income Tax Rules 1962 defines &lt;em&gt;how much of an employer-provided benefit is taxable&lt;/em&gt;. These are different things.&lt;/p&gt;

&lt;p&gt;The Budget 2020 New Regime (Section 115BAC) removed most Section 10 exemptions — HRA, LTA, most 10(14) allowances. It &lt;strong&gt;did not&lt;/strong&gt; modify Rule 3. So any perquisite whose taxability is defined by a Rule 3 sub-rule is valued identically in both regimes.&lt;/p&gt;

&lt;p&gt;Four Rule 3 sub-rules matter for the salaried developer:&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Rule 3(7)(iii) — meal coupons
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Cap&lt;/strong&gt;: ₹26,400/year (₹50/meal × 2 meals × ~22 working days × 12 months).&lt;/p&gt;

&lt;p&gt;Sodexo, Zeta, Zaggle. Your employer issues vouchers OR pays a food-court vendor directly. Fully exempt within cap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's NOT covered&lt;/strong&gt;: a cash "meal allowance" in your salary. Fully taxable regardless of what you spend it on. The exemption is conditional on the payment mechanism, not the intent.&lt;/p&gt;

&lt;p&gt;Tax saved at 30% + cess: ~₹8,240/year at full utilisation. Most tech companies already have this on their CTC menu — check your CTC statement for a "meal card" or "food coupon" line.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Rule 3(7)(iv) — gift voucher
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Cap&lt;/strong&gt;: ₹5,000/year cumulative across ALL employer gifts in that FY.&lt;/p&gt;

&lt;p&gt;The full-or-nothing trap: if aggregate gift-card issuance in the FY crosses ₹5,000 (₹3,000 Diwali + ₹3,000 work anniversary = ₹6,000), CBDT interpretation is the &lt;strong&gt;entire amount&lt;/strong&gt; becomes taxable, not just the ₹1,000 excess. Track this cumulatively.&lt;/p&gt;

&lt;p&gt;Tax saved: ~₹1,560/year at full utilisation.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Rule 3(7)(ix) — telephone + internet reimbursement
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Cap&lt;/strong&gt;: NO monetary cap. Actual reimbursement against bills is fully exempt.&lt;/p&gt;

&lt;p&gt;Conditions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Bills must be in the &lt;strong&gt;employee's name&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Employer actually &lt;strong&gt;pays or reimburses&lt;/strong&gt; (not a cash allowance)&lt;/li&gt;
&lt;li&gt;Mixed personal + official use is fine — no split proof required&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What's NOT covered&lt;/strong&gt;: a lump-sum "telephone allowance" paid as cash. Fully taxable regardless of your actual bills. The distinction between reimbursement-against-bills vs cash-allowance is the entire game.&lt;/p&gt;

&lt;p&gt;Practical example: mobile ₹1,500 + broadband ₹2,500 = ₹4,000/month = ₹48,000/year reimbursed = ~₹14,976/year tax saved at 30% bracket. High-earning devs with premium ISP plans routinely see ₹75,000+ reimbursement = ~₹23,400 in savings.&lt;/p&gt;

&lt;p&gt;This one is heavily under-used because tech companies default to "we pay you cash for phone" instead of "you submit bills, we reimburse." Ask HR to restructure — no cost to the employer, direct saving to you.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Rule 3(2) — company car (fuel + maintenance + driver)
&lt;/h2&gt;

&lt;p&gt;The biggest lever, most under-used.&lt;/p&gt;

&lt;p&gt;For a car provided by the employer used for &lt;strong&gt;both office and personal purposes&lt;/strong&gt;, the taxable perquisite is a &lt;strong&gt;fixed monthly amount&lt;/strong&gt; regardless of what the car actually costs the company.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Small engine (≤1,600cc)&lt;/th&gt;
&lt;th&gt;Large engine (&amp;gt;1,600cc)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Fuel + maintenance + insurance&lt;/td&gt;
&lt;td&gt;₹1,800/month = ₹21,600/year&lt;/td&gt;
&lt;td&gt;₹2,400/month = ₹28,800/year&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Driver (if provided)&lt;/td&gt;
&lt;td&gt;+₹900/month = +₹10,800/year&lt;/td&gt;
&lt;td&gt;+₹900/month = +₹10,800/year&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Numeric example: employer's actual outgoing on a 1,500cc car ≈ ₹60,000/year in fuel + insurance + maintenance. Employee's taxable perquisite = ₹21,600/year. Effective tax-free portion = ₹38,400/year. Tax saved at 30% + cess ≈ ₹11,981/year.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's NOT Rule 3(2)&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cash fuel/petrol allowance (fully taxable)&lt;/li&gt;
&lt;li&gt;Personal car with fuel reimbursement (fully taxable — different Section 10(14) framework, rare post-2018)&lt;/li&gt;
&lt;li&gt;Car used ONLY for personal purposes (full running cost taxable)&lt;/li&gt;
&lt;li&gt;Car used ONLY for office purposes (no perquisite; no benefit)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The sweet spot is "mixed use" — the reality for most senior devs commuting + weekend usage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Combined example — ₹25 lakh CTC dev
&lt;/h2&gt;

&lt;p&gt;Salary ₹25 lakh, marginal 30% + 4% cess, all four levers utilised:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Perquisite&lt;/th&gt;
&lt;th&gt;Amount exempt&lt;/th&gt;
&lt;th&gt;Tax saved&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Meal coupons&lt;/td&gt;
&lt;td&gt;₹26,400&lt;/td&gt;
&lt;td&gt;~₹8,240&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gift voucher (Diwali)&lt;/td&gt;
&lt;td&gt;₹5,000&lt;/td&gt;
&lt;td&gt;~₹1,560&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Telephone + internet (bills)&lt;/td&gt;
&lt;td&gt;₹48,000&lt;/td&gt;
&lt;td&gt;~₹14,976&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Company car 1,500cc&lt;/td&gt;
&lt;td&gt;employer cost ~₹60K → only ₹21,600 taxable&lt;/td&gt;
&lt;td&gt;~₹11,981&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~₹36,757/year&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On top of standard deduction, 80C, and everything else. And this stays the same in the New Regime.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why HR usually doesn't volunteer this
&lt;/h2&gt;

&lt;p&gt;Two reasons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Legacy CTC templates.&lt;/strong&gt; Older CTC structures encode a "cash phone allowance" as a taxable component because it was easier to run in payroll. Switching to reimbursement-against-bills is a payroll config change that requires HR's finance team to be aware — many aren't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The 5,000 gift trap is easy to trip.&lt;/strong&gt; HR issues a Diwali gift + a work-anniversary voucher without tracking cumulative → the employee ends up taxed on the full amount. Some companies just skip the exemption path and pay it as taxable.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  How to actually get these
&lt;/h2&gt;

&lt;p&gt;The framing that works with HR:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"I'd like to restructure a portion of my fixed CTC into (a) meal coupons up to ₹26,400/year, (b) telephone + internet reimbursement against actual bills, and (c) if the company offers a car benefit, I'd like to opt into that. These are all standard perquisite structures — no incremental cost to the company, but they lower my taxable perquisite value."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Most large employers have these on their CTC menu. Smaller firms may need walking through the Rule 3 framework — the CTC restructuring itself is standard HR practice, so it's not a novel ask.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common misconceptions
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;❌ "My cash fuel allowance is exempt because Rule 3(2) exists." No. Cash allowances are fully taxable. Rule 3(2) applies to actual company-provided car benefits.&lt;/li&gt;
&lt;li&gt;❌ "I'll claim ₹10,000/month telephone reimbursement even though my bill is ₹1,500." No. Only actual bills up to the amount reimbursed count.&lt;/li&gt;
&lt;li&gt;❌ "Gift card exemption is per gift, not per year." No. Cumulative ₹5,000 across all employer gifts in the FY.&lt;/li&gt;
&lt;li&gt;❌ "These deductions are Old-Regime only, like 80C." No. Rule 3(x) perquisite valuations survive the New Regime — they're computational rules, not Section 10 exemptions.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why I built a calculator for this
&lt;/h2&gt;

&lt;p&gt;I built &lt;a href="https://smarttaxcalc.in/" rel="noopener noreferrer"&gt;SmartTaxCalc&lt;/a&gt; — a browser-based Indian tax calculator — because tools that let you enter these perquisite values and see the exempt-vs-taxable split are rare. Most calculators just take gross salary and apply slab math. The homepage calculator now accepts all four of these perquisite categories individually and shows the New-Regime vs Old-Regime impact side by side.&lt;/p&gt;

&lt;p&gt;Full statutory framing + numeric examples + FAQ + HR-friendly script: &lt;a href="https://smarttaxcalc.in/blog/ctc-exempt-allowances-fy-2026-27/" rel="noopener noreferrer"&gt;smarttaxcalc.in/blog/ctc-exempt-allowances-fy-2026-27/&lt;/a&gt; — CA-reviewed.&lt;/p&gt;

&lt;p&gt;If you're on the New Regime and have never asked HR about restructuring these categories, that's the highest-ROI 15-minute conversation of your year.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The author builds &lt;a href="https://smarttaxcalc.in" rel="noopener noreferrer"&gt;SmartTaxCalc.in&lt;/a&gt; — free browser-based Indian tax calculator suite. Blazor WebAssembly, CA-reviewed by ICAI 644575, no signup, no backend. Previously wrote about &lt;a href="https://dev.to/chetansanghani/istrimmablefalse-is-not-enough-how-one-wasm-setting-silently-broke-my-blazor-app-for-4-days-37d4"&gt;Blazor WASM trim mode surprises&lt;/a&gt; and &lt;a href="https://dev.to/chetansanghani/"&gt;a PowerShell backlink rel verifier&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>The 4 static-mirror drift classes hiding in your Blazor WASM SEO (and the audit script that finds them)</title>
      <dc:creator>Chetan Sanghani</dc:creator>
      <pubDate>Sun, 26 Jul 2026 07:07:21 +0000</pubDate>
      <link>https://dev.to/chetansanghani/the-4-static-mirror-drift-classes-hiding-in-your-blazor-wasm-seo-and-the-audit-script-that-finds-54ob</link>
      <guid>https://dev.to/chetansanghani/the-4-static-mirror-drift-classes-hiding-in-your-blazor-wasm-seo-and-the-audit-script-that-finds-54ob</guid>
      <description>&lt;p&gt;I run a Blazor WebAssembly tax calculator site. To feed Google's crawler before the SPA hydrates, every &lt;code&gt;@page&lt;/code&gt; route also has a hand-maintained static HTML mirror at &lt;code&gt;wwwroot/&amp;lt;route&amp;gt;/index.html&lt;/code&gt;. That works — the crawler indexes real content, users get instant-paint before Blazor boots.&lt;/p&gt;

&lt;p&gt;But this weekend I ran a &lt;code&gt;grep&lt;/code&gt; audit across those 100 mirror files. It found 4 distinct drift classes, silently degrading SEO signals for months. If your Blazor WASM site uses the same pattern, you probably have some of these too.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup, if you don't already have it
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;.razor&lt;/code&gt; file is the source of truth. Blazor renders it after hydration. The static HTML mirror is a snapshot of what the page &lt;em&gt;should&lt;/em&gt; look like on first paint, for the crawler + for the ~1-2 seconds users wait for WASM to boot. In practice this means every content-page razor has a paired &lt;code&gt;wwwroot/&amp;lt;same-path&amp;gt;/index.html&lt;/code&gt;, and every razor edit is supposed to also update the mirror.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Supposed to.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The problem: razor edits happen in normal development flow, mirror edits require you to remember. Over 6 months and ~40 pages, drift accumulates. And it's invisible to &lt;code&gt;dotnet build&lt;/code&gt; because the mirror is just static HTML — the compiler doesn't check it.&lt;/p&gt;

&lt;p&gt;Here's what my Saturday audit found.&lt;/p&gt;

&lt;h2&gt;
  
  
  Drift class #1 — missing BreadcrumbList JSON-LD
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Discovery&lt;/strong&gt;: &lt;code&gt;grep -L '"BreadcrumbList"' wwwroot/blog/*/index.html&lt;/code&gt; returned 7 files.&lt;/p&gt;

&lt;p&gt;BreadcrumbList JSON-LD is what makes Google render "Home › Blog › Article Title" as a visible breadcrumb in the search snippet. Without it in the mirror, the crawler sees no breadcrumb at first index. My &lt;code&gt;&amp;lt;SeoSchema&amp;gt;&lt;/code&gt; component in each razor emits it correctly at hydration — but the crawler reads the mirror &lt;em&gt;before&lt;/em&gt; hydration. That schema was invisible to Google for 7 blog posts.&lt;/p&gt;

&lt;p&gt;Fix: 7 additive &lt;code&gt;&amp;lt;script type="application/ld+json"&amp;gt;&lt;/code&gt; blocks injected before &lt;code&gt;&amp;lt;/head&amp;gt;&lt;/code&gt;, matching the exact 3-level pattern (Home → Blog → Article Title) that my razors emit at runtime. Pattern was copy-paste from an already-working mirror.&lt;/p&gt;

&lt;h2&gt;
  
  
  Drift class #2 — missing FAQPage JSON-LD despite razor having FaqItems
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Discovery&lt;/strong&gt;: &lt;code&gt;grep -L '"FAQPage"' wwwroot/blog/*/index.html&lt;/code&gt; returned 16 files. But when I cross-checked which of those razors &lt;em&gt;had&lt;/em&gt; a &lt;code&gt;FaqItems=@(new List&amp;lt;SeoSchema.FaqItem&amp;gt; { ... })&lt;/code&gt; param on the &lt;code&gt;&amp;lt;SeoSchema&amp;gt;&lt;/code&gt; component, 6 of the 16 did.&lt;/p&gt;

&lt;p&gt;Those 6 razors are actively emitting FAQPage schema at hydration. Their mirrors have zero FAQPage schema. Users get the schema after WASM boots; Google's initial crawl doesn't. FAQPage is one of Google's most-visible SERP features (the expandable Q&amp;amp;A dropdown), and I was leaving CTR on the floor across 6 blogs for months.&lt;/p&gt;

&lt;p&gt;Fix: I wrote a Node script that regex-extracted the &lt;code&gt;new("Question", "Answer")&lt;/code&gt; tuples from each razor's &lt;code&gt;FaqItems=&lt;/code&gt; block, &lt;code&gt;JSON.stringify()&lt;/code&gt;'d them into the SIMPLE FAQPage schema format (matching what my already-working mirrors used), and injected before &lt;code&gt;&amp;lt;/head&amp;gt;&lt;/code&gt;. 38 Q&amp;amp;A pairs baked in across 6 files. All validated via &lt;code&gt;JSON.parse&lt;/code&gt; post-write.&lt;/p&gt;

&lt;p&gt;The 10 razors &lt;em&gt;without&lt;/em&gt; &lt;code&gt;FaqItems=&lt;/code&gt; are a separate content-authoring problem — they need FAQ Q&amp;amp;A written from scratch + reviewer signoff on YMYL claims. Different scope, deferred.&lt;/p&gt;

&lt;h2&gt;
  
  
  Drift class #3 — asymmetric hreflang cluster
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Discovery&lt;/strong&gt;: my sitemap.xml declares 4 hreflang alternates for my old-vs-new-regime cluster:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;xhtml:link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt; &lt;span class="na"&gt;hreflang=&lt;/span&gt;&lt;span class="s"&gt;"en-IN"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;".../blog/old-vs-new-regime/"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;xhtml:link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt; &lt;span class="na"&gt;hreflang=&lt;/span&gt;&lt;span class="s"&gt;"hi-IN"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;".../blog/hi/old-vs-new-regime/"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;xhtml:link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt; &lt;span class="na"&gt;hreflang=&lt;/span&gt;&lt;span class="s"&gt;"gu-IN"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;".../blog/gu/old-vs-new-regime/"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;xhtml:link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt; &lt;span class="na"&gt;hreflang=&lt;/span&gt;&lt;span class="s"&gt;"x-default"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;".../blog/old-vs-new-regime/"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But the page HTML on 2 of the 3 language variants (English + Hindi mirrors) had only 3 &lt;code&gt;&amp;lt;link rel="alternate"&amp;gt;&lt;/code&gt; tags — missing &lt;code&gt;gu-IN&lt;/code&gt;. The Gujarati mirror correctly had all 4. This is an asymmetric hreflang cluster: the sitemap says GU exists, English + Hindi pages don't reference it back. Google's algorithm for hreflang requires &lt;em&gt;bidirectional&lt;/em&gt; references to trust the cluster.&lt;/p&gt;

&lt;p&gt;Impact: the Gujarati variant may lose SERP visibility in gu-IN searches (no reciprocal reference from English/Hindi means weaker language-cluster signal). Small volume for my site but a real signal loss.&lt;/p&gt;

&lt;p&gt;Fix: 2 additive &lt;code&gt;&amp;lt;link rel="alternate" hreflang="gu-IN"&amp;gt;&lt;/code&gt; tags. All 3 mirrors now show 4-alternate symmetric cluster.&lt;/p&gt;

&lt;h2&gt;
  
  
  Drift class #4 — meta keywords copy-paste template
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Discovery&lt;/strong&gt;: I noticed during the FAQPage injection that 5 blog mirrors shared &lt;em&gt;identical&lt;/em&gt; &lt;code&gt;&amp;lt;meta name="keywords"&amp;gt;&lt;/code&gt; content — the generic homepage keywords like "income tax calculator india fy 2026-27, free tax calculator india no signup..." — even though the blogs were about wildly different topics (NPS 80CCD(1B), PPF vs ELSS, RSU tax, home loan interest, Form 10E).&lt;/p&gt;

&lt;p&gt;Cross-checked: the razors had &lt;em&gt;topical&lt;/em&gt; keywords. The mirrors had the generic homepage boilerplate. Classic mirror-drift on a low-priority attribute nobody remembered to sync.&lt;/p&gt;

&lt;p&gt;Google mostly ignores &lt;code&gt;&amp;lt;meta name="keywords"&amp;gt;&lt;/code&gt; since 2009. But:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bing still uses it&lt;/strong&gt; (per Bing Webmaster docs)&lt;/li&gt;
&lt;li&gt;Yandex uses it&lt;/li&gt;
&lt;li&gt;Some AI content classifiers use it&lt;/li&gt;
&lt;li&gt;Human quality-raters see it — 5 topically-different blog pages sharing identical keywords looks like template-duplicate spam&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Fix: extracted each razor's &lt;code&gt;keywords&lt;/code&gt; content via regex, wrote it back into the corresponding mirror. 5 files, 5 topical keyword strings replacing 5 identical-homepage-boilerplate strings.&lt;/p&gt;

&lt;h2&gt;
  
  
  The audit script
&lt;/h2&gt;

&lt;p&gt;This is the one-liner that started the whole weekend:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Which blog mirrors are missing BreadcrumbList?&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-L&lt;/span&gt; &lt;span class="s1"&gt;'"BreadcrumbList"'&lt;/span&gt; wwwroot/blog/&lt;span class="k"&gt;*&lt;/span&gt;/index.html

&lt;span class="c"&gt;# Which are missing FAQPage?&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-L&lt;/span&gt; &lt;span class="s1"&gt;'"FAQPage"'&lt;/span&gt; wwwroot/blog/&lt;span class="k"&gt;*&lt;/span&gt;/index.html

&lt;span class="c"&gt;# Which are missing Article?&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-L&lt;/span&gt; &lt;span class="s1"&gt;'"@type":"Article"'&lt;/span&gt; wwwroot/blog/&lt;span class="k"&gt;*&lt;/span&gt;/index.html

&lt;span class="c"&gt;# Cross-check razors: which razors HAVE FaqItems but mirrors DON'T have FAQPage?&lt;/span&gt;
&lt;span class="c"&gt;# (this is where the "shippable parity fix" list comes from — the razor already&lt;/span&gt;
&lt;span class="c"&gt;#  has the data, the mirror just needs to be synced)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you have static mirrors for a Blazor WASM SPA and haven't grep'd them for schema completeness in a while, run these three grep commands. I'd bet money you find at least one drift class.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this happens
&lt;/h2&gt;

&lt;p&gt;Static mirrors are a duplication. The DRY-in-code violation is what makes them work — the mirror is deliberately a copy, not generated, because generating them at build time (via prerendering) requires either running Blazor in server mode (defeats the point of static WASM) or shipping a whole SSG pipeline (heavier than the drift risk).&lt;/p&gt;

&lt;p&gt;My repo made the pragmatic call: hand-maintain the mirror, add a lint that catches divergence for a growing list of known bad patterns.&lt;/p&gt;

&lt;p&gt;The lint I have (&lt;code&gt;scripts/lint-mirror-integrity.ps1&lt;/code&gt;) catches YMYL content drift — like a mirror saying "80CCD(1B) is allowed in the New Regime" when the razor correctly says it's disallowed (that class of drift once shipped a real integrity issue to production for weeks).&lt;/p&gt;

&lt;p&gt;But schema-presence isn't in the lint. Neither is meta-keywords drift, or hreflang symmetry. My Saturday audit was manual grep-driven. I'll add these classes to the lint next.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prescription
&lt;/h2&gt;

&lt;p&gt;If you have this architecture:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;grep -L&lt;/code&gt; schema audits&lt;/strong&gt; are cheap enough to run monthly. Add them as CI checks if you can.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-check razor source-of-truth against mirror&lt;/strong&gt; — the &lt;code&gt;FaqItems=&lt;/code&gt; vs &lt;code&gt;&amp;lt;script&amp;gt;"FAQPage"&amp;lt;/script&amp;gt;&lt;/code&gt; mismatch was the highest-signal finding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sitemap ↔ page HTML hreflang symmetry&lt;/strong&gt; — if you have multilingual variants, verify both sides declare each other.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Meta keywords is deprecated but not zero-signal&lt;/strong&gt; — copy-paste template duplication reads as low-effort content to human reviewers, even if Google's algo ignores the tag itself.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Static mirrors solve a real problem (crawler + first-paint UX). They introduce a real risk (drift you don't see). The audit script + lint list keeps that risk bounded.&lt;/p&gt;

&lt;h2&gt;
  
  
  One more thing
&lt;/h2&gt;

&lt;p&gt;My audit found all 4 classes on a Saturday morning. Shipping the fixes across 13 mirror files took a Node script + a couple of hours. The impact: 6 blog pages became eligible for Google FAQ rich results, 7 became eligible for breadcrumb SERP renders, 2 got proper Article JSON-LD, 5 got topical keywords instead of homepage template.&lt;/p&gt;

&lt;p&gt;If you have static mirrors and haven't audited them recently, do it. The scripts above are literally the whole thing.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build client-side Indian tax calculators at &lt;a href="https://smarttaxcalc.in/" rel="noopener noreferrer"&gt;smarttaxcalc.in&lt;/a&gt; — Blazor WASM, no backend, all client-side. This weekend's shipping arc: &lt;a href="https://dev.to/chetansanghani/istrimmablefalse-is-not-enough-how-one-wasm-setting-silently-broke-my-blazor-app-for-4-days-37d4"&gt;IsTrimmable=false is not enough&lt;/a&gt; was article #7 in this series.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>frontend</category>
      <category>seo</category>
    </item>
    <item>
      <title>IsTrimmable=false is not enough: how one WASM setting silently broke my Blazor app for 4 days</title>
      <dc:creator>Chetan Sanghani</dc:creator>
      <pubDate>Thu, 23 Jul 2026 14:32:17 +0000</pubDate>
      <link>https://dev.to/chetansanghani/istrimmablefalse-is-not-enough-how-one-wasm-setting-silently-broke-my-blazor-app-for-4-days-37d4</link>
      <guid>https://dev.to/chetansanghani/istrimmablefalse-is-not-enough-how-one-wasm-setting-silently-broke-my-blazor-app-for-4-days-37d4</guid>
      <description>&lt;p&gt;Last Sunday I split a Blazor WebAssembly app into a shared class library and a Web project. &lt;code&gt;dotnet build&lt;/code&gt;: clean. &lt;code&gt;dotnet publish&lt;/code&gt;: clean. Cloudflare Pages deployed. All my local smoke tests passed.&lt;/p&gt;

&lt;p&gt;Four days later a user emailed: &lt;em&gt;"The Calculate button doesn't work."&lt;/em&gt; Every calculator on the site was throwing this in the browser console:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: DeserializeNoConstructor, JsonConstructorAttribute,
TaxPlanner.Core.Models.TaxRulesConfiguration
Path: $ | LineNumber: 0 | BytePositionInLine: 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This post is what I learned. If you have &lt;code&gt;&amp;lt;PublishTrimmed&amp;gt;true&amp;lt;/PublishTrimmed&amp;gt;&lt;/code&gt; in a Blazor WASM app and you use reflection-based &lt;code&gt;System.Text.Json&lt;/code&gt; — &lt;code&gt;HttpClient.GetFromJsonAsync&amp;lt;T&amp;gt;&lt;/code&gt;, &lt;code&gt;JsonSerializer.Deserialize&amp;lt;T&amp;gt;&lt;/code&gt;, or &lt;code&gt;[JsonSerializable]&lt;/code&gt; NOT declared — this could hit you too.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Update after publishing:&lt;/strong&gt; a reader pushed me to properly instrument the "fix #1 didn't work" mystery instead of hand-waving it. The corrected analysis is in What actually happened at fix #1 below. Short version: reflection was &lt;strong&gt;never&lt;/strong&gt; disabled (&lt;code&gt;TrimMode=partial&lt;/code&gt; keeps the JSON reflection switch on — confirmed via &lt;code&gt;runtimeconfig.json&lt;/code&gt;), and the &lt;code&gt;IsTrimmable=false&lt;/code&gt; build was probably a valid fix that a &lt;strong&gt;caching layer masked&lt;/strong&gt;. Source generation is still the right answer. Kept the original narrative intact so the correction is honest.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Blazor WASM app, .NET 10. Two projects:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;TaxPlanner.Web&lt;/code&gt; — Blazor pages, services, &lt;code&gt;&amp;lt;PublishTrimmed&amp;gt;true&amp;lt;/PublishTrimmed&amp;gt;&lt;/code&gt; + &lt;code&gt;&amp;lt;TrimMode&amp;gt;partial&amp;lt;/TrimMode&amp;gt;&lt;/code&gt; (default for optimized WASM builds).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;TaxPlanner.Core&lt;/code&gt; — data models. The refactor I shipped extracted the record types into this new library.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Core project's csproj had this one line I didn't think twice about:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;PropertyGroup&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;TargetFramework&amp;gt;&lt;/span&gt;net10.0&lt;span class="nt"&gt;&amp;lt;/TargetFramework&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;IsTrimmable&amp;gt;&lt;/span&gt;true&lt;span class="nt"&gt;&amp;lt;/IsTrimmable&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/PropertyGroup&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every guide on optimizing Blazor WASM output size tells you to mark your app assemblies as trimmable. It sounds like the disciplined choice. The Core assembly is small — models + a couple of helpers — and shrinking the payload matters when you're serving to phones.&lt;/p&gt;

&lt;p&gt;The data models are all like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;sealed&lt;/span&gt; &lt;span class="k"&gt;record&lt;/span&gt; &lt;span class="nc"&gt;TaxRulesConfiguration&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;JsonPropertyName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Version&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;JsonPropertyName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"financialYears"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;IReadOnlyList&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;FinancialYear&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;FinancialYears&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;init&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Loaded once at startup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;_rulesConfig&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_httpClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetFromJsonAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TaxRulesConfiguration&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"data/tax-rules.json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;JsonSerializerOptions&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;PropertyNameCaseInsensitive&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Standard stuff. Works fine in development. Passes every build.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the trimmer actually did
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;TrimMode=partial&lt;/code&gt; means: only aggressively trim assemblies marked &lt;code&gt;IsTrimmable=true&lt;/code&gt;. &lt;code&gt;TaxPlanner.Core&lt;/code&gt; was the only such app assembly.&lt;/p&gt;

&lt;p&gt;The trimmer's job is to remove unreachable IL. It does a static reachability analysis: it walks from every entry point (Program.Main, exposed public APIs, etc.), traces which types and members are called, and deletes everything not reached.&lt;/p&gt;

&lt;p&gt;Here's what it saw: &lt;code&gt;TaxRulesConfiguration&lt;/code&gt; is a &lt;code&gt;sealed record&lt;/code&gt; with only &lt;code&gt;{ get; init; }&lt;/code&gt; properties. The compiler-generated parameterless constructor was public. But &lt;strong&gt;nothing in the codebase calls &lt;code&gt;new TaxRulesConfiguration()&lt;/code&gt;&lt;/strong&gt;. The only "caller" is &lt;code&gt;HttpClient.GetFromJsonAsync&amp;lt;T&amp;gt;&lt;/code&gt;, which uses reflection at runtime to look up and invoke that constructor.&lt;/p&gt;

&lt;p&gt;Reflection is invisible to the trimmer. From its perspective, the constructor was unreachable. It got deleted.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;System.Text.Json&lt;/code&gt;'s reflection path then tried to construct the object at runtime, found no public parameterless constructor, no &lt;code&gt;[JsonConstructor]&lt;/code&gt; attribute, no custom converter, and threw:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DeserializeNoConstructor, JsonConstructorAttribute,
TaxPlanner.Core.Models.TaxRulesConfiguration
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;dotnet build&lt;/code&gt;? Green. &lt;code&gt;dotnet publish&lt;/code&gt;? Green. The trimmer doesn't warn on this class of stripping because from its perspective it correctly identified unreachable code. The bug &lt;strong&gt;only exists at runtime, only in the browser, only when the deserialization path fires&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix attempt #1: turn trim off for the assembly
&lt;/h2&gt;

&lt;p&gt;Obvious first move:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;IsTrimmable&amp;gt;&lt;/span&gt;false&lt;span class="nt"&gt;&amp;lt;/IsTrimmable&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I pushed it. Cloudflare rebuilt. I checked bundle size — Core.wasm went from 12 KB to 49 KB, matching exactly the type metadata + constructor IL the trimmer had been eating. That's a great debugging signal on its own: a ~4× jump means "trim was doing real work here"; near-identical size means "trim was doing nothing and might have been silently breaking reflection targets."&lt;/p&gt;

&lt;p&gt;Restarted. Loaded the site. Clicked Calculate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Same error.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is where I lost an hour. My mental model said: opting the assembly out of trim should restore its type metadata, which should be enough for System.Text.Json's reflection to find the constructor. It wasn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happened at fix #1 (updated after a reader's diagnostic)
&lt;/h2&gt;

&lt;p&gt;The original version of this post threw up its hands here — "something about &lt;code&gt;PublishTrimmed=true&lt;/code&gt; breaks reflection regardless of &lt;code&gt;IsTrimmable&lt;/code&gt;." A sharp commenter pushed me to actually instrument it. Two findings changed the story.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First: reflection was never disabled.&lt;/strong&gt; I'd half-assumed &lt;code&gt;PublishTrimmed=true&lt;/code&gt; had flipped &lt;code&gt;System.Text.Json&lt;/code&gt;'s reflection feature switch off — which it &lt;em&gt;does&lt;/em&gt; under &lt;strong&gt;full&lt;/strong&gt; trimming. But this app uses &lt;code&gt;TrimMode=partial&lt;/code&gt;, and I confirmed the switch's real value by reading the published &lt;code&gt;TaxPlanner.Web.runtimeconfig.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"System.Text.Json.JsonSerializer.IsReflectionEnabledByDefault"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the reflection deserializer was available the whole time. That rules out the feature switch and pins the root cause firmly on the &lt;strong&gt;trimmed constructor&lt;/strong&gt; described above — exactly what the &lt;code&gt;DeserializeNoConstructor&lt;/code&gt; error says on the tin. If you want to check your own build, that one line in &lt;code&gt;runtimeconfig.json&lt;/code&gt; tells you whether reflection-based JSON is even on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second: the &lt;code&gt;IsTrimmable=false&lt;/code&gt; build was probably correct — the browser just wasn't running it.&lt;/strong&gt; Recall the bundle jumped 12 KB → 49 KB when I flipped the flag, i.e. the constructor metadata &lt;em&gt;was&lt;/em&gt; restored in the published artifact. If the deployed bundle has the ctor and reflection is on, that build should work. The most likely reason it still threw the identical error: &lt;strong&gt;stale cached assets.&lt;/strong&gt; Cloudflare Pages serves the fingerprinted &lt;code&gt;_framework/*.wasm&lt;/code&gt; files with immutable cache headers, and the Blazor PWA service worker (if you ship it) keeps its own asset cache that only updates after every tab is closed. The click that "still failed" was very likely executing the &lt;em&gt;previous&lt;/em&gt; trimmed bundle, not the fixed one.&lt;/p&gt;

&lt;p&gt;I can't prove that 100% after the fact — the clean confirmation is to compare the served asset's integrity hash against the fresh build's at the time, which I didn't capture. But it fits every observable: green build, restored metadata, byte-identical runtime error, a CDN + service worker between me and the browser. The honest correction: &lt;code&gt;IsTrimmable=false&lt;/code&gt; likely &lt;em&gt;was&lt;/em&gt; a valid code-level fix, and a caching layer made it look useless.&lt;/p&gt;

&lt;p&gt;The pragmatic lesson stands, just for a sharper reason: &lt;strong&gt;&lt;code&gt;IsTrimmable=false&lt;/code&gt; is a fragile defense&lt;/strong&gt; — it depends on the trimmer's reachability analysis &lt;em&gt;and&lt;/em&gt; your cache invalidation both cooperating. Source generation depends on neither: the deserializer is ordinary reachable IL, so no reflection switch, no trim heuristic, and (once the new bundle is actually served) no ambiguity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix attempt #2: source-generation (the real answer)
&lt;/h2&gt;

&lt;p&gt;Microsoft's official recommendation for trim-safe and AOT-safe JSON is &lt;code&gt;System.Text.Json&lt;/code&gt; source generation. Instead of runtime reflection walking your types, the compiler emits the deserialization code at build time. Zero reflection needed. The trimmer sees the generated code as regular reachable IL.&lt;/p&gt;

&lt;p&gt;Setup is two steps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1&lt;/strong&gt;: declare a partial &lt;code&gt;JsonSerializerContext&lt;/code&gt; in your Core library:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;System.Text.Json.Serialization&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="nn"&gt;TaxPlanner.Core.Models&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;TaxPlanner.Core.Json&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;JsonSourceGenerationOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;PropertyNameCaseInsensitive&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ReadCommentHandling&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;System&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JsonCommentHandling&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Skip&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;JsonSerializable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TaxRulesConfiguration&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;partial&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TaxRulesJsonContext&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;JsonSerializerContext&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;[JsonSerializable(typeof(T))]&lt;/code&gt; attribute is what triggers codegen. You list every root type you want serialized/deserialized. The generator walks the object graph — &lt;code&gt;TaxRulesConfiguration&lt;/code&gt; contains a &lt;code&gt;List&amp;lt;FinancialYear&amp;gt;&lt;/code&gt;, each &lt;code&gt;FinancialYear&lt;/code&gt; contains a &lt;code&gt;TaxRegime&lt;/code&gt; and a &lt;code&gt;Deductions&lt;/code&gt;, and so on — and emits deserializer code for every nested type. No need to enumerate them explicitly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2&lt;/strong&gt;: change the call site from the reflection overload to the source-gen overload:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before (reflection path — broken under PublishTrimmed):&lt;/span&gt;
&lt;span class="n"&gt;_rulesConfig&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_httpClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetFromJsonAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TaxRulesConfiguration&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"data/tax-rules.json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;JsonSerializerOptions&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;PropertyNameCaseInsensitive&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// After (source-gen path — trim-safe by construction):&lt;/span&gt;
&lt;span class="n"&gt;_rulesConfig&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_httpClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetFromJsonAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"data/tax-rules.json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;TaxRulesJsonContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Default&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TaxRulesConfiguration&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The overload that takes a &lt;code&gt;JsonTypeInfo&amp;lt;T&amp;gt;&lt;/code&gt; (which &lt;code&gt;TaxRulesJsonContext.Default.TaxRulesConfiguration&lt;/code&gt; is) selects the generated deserializer instead of the reflection-based one.&lt;/p&gt;

&lt;p&gt;Pushed. Cloudflare rebuilt. &lt;code&gt;Core.wasm&lt;/code&gt; went from 49 KB (metadata preserved via &lt;code&gt;IsTrimmable=false&lt;/code&gt;) to 156 KB (metadata + generated deserializer). About ~30 KB brotli-compressed. Downloaded once, cached immutable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Calculate button worked. Every calculator worked.&lt;/strong&gt; Four days of broken production ended.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diagnostic patterns worth internalizing
&lt;/h2&gt;

&lt;p&gt;Three signals I now watch for:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Small assembly, big size jump on &lt;code&gt;IsTrimmable=false&lt;/code&gt;.&lt;/strong&gt; If flipping trim off adds tens of KB of uncompressed WASM to an app-owned assembly, that assembly has reflection-consumed types the trimmer was silently deleting. Investigate those types. Same-size-before-and-after means trim was finding nothing trimmable there — usually safe to leave &lt;code&gt;IsTrimmable=true&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. &lt;code&gt;dotnet build&lt;/code&gt; and &lt;code&gt;dotnet publish&lt;/code&gt; both green does not mean runtime-correct under trim.&lt;/strong&gt; The trimmer emits no warning for stripping a reflection-only constructor because it's doing its job. Trim regressions surface only in the browser, at the moment the deserialization fires. If you change anything trim-related — assembly split, &lt;code&gt;IsTrimmable&lt;/code&gt; flip, DI graph reshaping — you must publish and actually navigate to the affected page in a real browser before believing the change is safe.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. If the type is reflection-consumed, source-gen or nothing.&lt;/strong&gt; &lt;code&gt;[JsonSerializable]&lt;/code&gt; + &lt;code&gt;JsonSerializerContext&lt;/code&gt; is not an optimization; it's the correctness contract under trim. Attributes like &lt;code&gt;[JsonConstructor]&lt;/code&gt; are for choosing a constructor when there are multiple candidates — they don't preserve constructors that the trimmer already deleted. &lt;code&gt;[DynamicallyAccessedMembers]&lt;/code&gt; in principle can preserve members but you have to annotate at the call site, and &lt;code&gt;GetFromJsonAsync&amp;lt;T&amp;gt;&lt;/code&gt; is defined in an external library so you can't add annotations there. Source-gen sidesteps this entire class of problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The soft-fail alternative I considered and rejected
&lt;/h2&gt;

&lt;p&gt;There's a tempting middle ground: keep the reflection-based deserialization, mark the target class with &lt;code&gt;[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)]&lt;/code&gt;, and hope the trimmer preserves everything. This can work in isolated cases. But:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It requires modifying every reflection-consumed type in your assembly (missing one is a silent runtime break)&lt;/li&gt;
&lt;li&gt;It's easy to forget when you add a new type (there's no lint that catches it)&lt;/li&gt;
&lt;li&gt;It preserves more than the deserializer actually needs (partially defeats the trim wins you wanted)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Source-gen scales better: one file per assembly, one &lt;code&gt;[JsonSerializable]&lt;/code&gt; per root type, generator walks the graph and figures out the rest. It also happens to be the pattern that AOT compilation requires, so if you ever move to AOT (RunAOTCompilation=true), your work is already done.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 4-day window, in retrospect
&lt;/h2&gt;

&lt;p&gt;I've been thinking about why this survived so long unnoticed. The commit that introduced the bug was one of 32 shipped that Sunday — an SEO-focused audit day. Every calculator on the site was silently broken from that commit forward. The site's own health dashboard (GA4 exceptions on the "Reviewer" page) was elevated but I had a different working theory for that spike. The failure only showed up when a user opened the browser DevTools and clicked Calculate — which happened four days later when someone reported it.&lt;/p&gt;

&lt;p&gt;Two things would have caught it in minutes instead of days:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Publish locally and click through the app after any assembly-boundary or trim change.&lt;/strong&gt; I did the first (publish worked, bundle size was fine). I didn't do the second. Every "small architecture" change in a WASM app now gets a real-browser smoke test before I consider it done.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An end-to-end test that clicks Calculate on the homepage.&lt;/strong&gt; I don't have Playwright/Selenium set up yet on this project. The 4-day incident convinced me it's worth the ~2 hours of setup.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If your Blazor WASM stack is anything like the one in this post, and you've been leaving &lt;code&gt;IsTrimmable=true&lt;/code&gt; on your app assemblies because "size wins are size wins," here's your reminder: check that the trimmer isn't quietly deleting things &lt;code&gt;System.Text.Json&lt;/code&gt;'s reflection needs.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build client-side Indian tax calculators at &lt;a href="https://smarttaxcalc.in/" rel="noopener noreferrer"&gt;smarttaxcalc.in&lt;/a&gt; — all Blazor WASM, no backend, CA-reviewed. Every incident like this one makes the site a little more resilient.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>csharp</category>
      <category>debugging</category>
      <category>dotnet</category>
      <category>webassembly</category>
    </item>
    <item>
      <title>I built a lint that caught 3 tax-rule lies my manual sweep missed — a pattern-based safety net for hand-maintained SEO</title>
      <dc:creator>Chetan Sanghani</dc:creator>
      <pubDate>Wed, 22 Jul 2026 17:13:29 +0000</pubDate>
      <link>https://dev.to/chetansanghani/i-built-a-lint-that-caught-3-tax-rule-lies-my-manual-sweep-missed-a-pattern-based-safety-net-for-3n9</link>
      <guid>https://dev.to/chetansanghani/i-built-a-lint-that-caught-3-tax-rule-lies-my-manual-sweep-missed-a-pattern-based-safety-net-for-3n9</guid>
      <description>&lt;p&gt;Three Sundays ago I found seven pages on my site independently asserting something factually wrong about Indian tax law. The &lt;code&gt;.razor&lt;/code&gt; sources were all correct. The pre-rendered static HTML mirrors — which is what Google actually indexes — had drifted.&lt;/p&gt;

&lt;p&gt;I fixed all seven by hand. I congratulated myself. I moved on.&lt;/p&gt;

&lt;p&gt;Then this Wednesday I built a lint to catch the pattern automatically. It surfaced three more identical lies on three completely different pages. My manual sweep had missed them.&lt;/p&gt;

&lt;p&gt;Here's what I built, why regex alone doesn't work, and the two-phase pattern that does.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup — why drift happens
&lt;/h2&gt;

&lt;p&gt;SmartTaxCalc.in is a Blazor WebAssembly SPA deployed to Cloudflare Pages. Every blog post has two representations:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Pages/Blog/*.razor&lt;/code&gt;&lt;/strong&gt; — the source of truth. Compiled into the WASM bundle. Rendered client-side after Blazor boots.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;wwwroot/blog/&amp;lt;slug&amp;gt;/index.html&lt;/code&gt;&lt;/strong&gt; — a static HTML mirror. What Cloudflare serves at page load. What Google indexes. What users see for the first 1-2 seconds before Blazor hydrates.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The mirrors are &lt;strong&gt;hand-maintained&lt;/strong&gt;. No build step regenerates them from the razors. So when I fix a fact in a razor, nothing propagates it to the mirror. The mirror keeps the old, wrong content.&lt;/p&gt;

&lt;p&gt;I already had one lint guarding one part of this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="c"&gt;# scripts/lint-title-parity.ps1&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c"&gt;# Ensures the razor's &amp;lt;PageTitle&amp;gt; matches the mirror's &amp;lt;title&amp;gt;.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c"&gt;# Runs in CI on every push touching razor/mirror paths.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That catches SEO titles going out of sync. It does not catch anything below the title tag. Every paragraph of body content is on trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Sunday crisis
&lt;/h2&gt;

&lt;p&gt;Running through an editorial integrity audit, I found the same false claim in seven different blog mirrors:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Section 80CCD(1B) NPS is now allowed in both regimes."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For readers not in India: this is wrong. Section 80CCD(1B) is a ₹50,000 individual NPS deduction that is Old-Tax-Regime-only. The New Regime disallows it. It matters — a taxpayer following that advice would over-claim and face a Section 143(1) intimation.&lt;/p&gt;

&lt;p&gt;The razors said the correct thing everywhere: "80CCD(1B) is Old Regime only." Every mirror I inspected asserted the opposite. Seven of them. Independently. No shared component.&lt;/p&gt;

&lt;p&gt;Root cause: some earlier session (months ago, before I was on the project) fixed the razors after a Budget-2025 misreading. Never touched the mirrors. Title-parity was 94/0 clean the whole time because titles never changed.&lt;/p&gt;

&lt;p&gt;I fixed all seven mirrors in one commit and moved on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lint I built this Wednesday
&lt;/h2&gt;

&lt;p&gt;Three days later, still uneasy that I might have missed one, I built a lint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Naïve attempt&lt;/strong&gt;: search for the deduction name near "New Regime":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$pattern&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'80CCD\(1B\).*(?:allowed|available)(?:.*)?new\s+regime'&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ran it against 100 mirrors. &lt;strong&gt;Seventeen violations.&lt;/strong&gt; All false positives. Every mirror flagged said something like:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Section 80CCD(1B) is &lt;strong&gt;disallowed&lt;/strong&gt; under the New Regime."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The word &lt;code&gt;allowed&lt;/code&gt; is a substring of &lt;code&gt;disallowed&lt;/code&gt;. Word boundaries help a little (&lt;code&gt;\ballowed\b&lt;/code&gt;), but not enough — real sentences frame this rule in many ways: "not allowed", "not permitted", "only in Old Regime", "cannot claim", "forfeit", etc.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two-phase pattern that works
&lt;/h2&gt;

&lt;p&gt;The insight: a &lt;strong&gt;positive assertion&lt;/strong&gt; and a &lt;strong&gt;negation&lt;/strong&gt; can share the same lexical proximity. What distinguishes them is context. So don't try to write one regex that captures both.&lt;/p&gt;

&lt;p&gt;Instead:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Phase 1&lt;/strong&gt;: Find suspicious co-occurrences (the deduction name near "New Regime"). Cast a wide net.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phase 2&lt;/strong&gt;: For each hit, examine the surrounding ~400 characters. If any &lt;strong&gt;safe-indicator word&lt;/strong&gt; appears (&lt;code&gt;disallow&lt;/code&gt;, &lt;code&gt;not allow&lt;/code&gt;, &lt;code&gt;Old Regime only&lt;/code&gt;, &lt;code&gt;without touching&lt;/code&gt;, &lt;code&gt;mistake&lt;/code&gt;, &lt;code&gt;cannot&lt;/code&gt;, etc.), the sentence is likely correcting the claim, not asserting it. Skip.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's the core of the current lint (PowerShell 7):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$forbiddenClaims&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;@(&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;@{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nx"&gt;Name&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'80CCD(1B) claimed to work in New Regime'&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nx"&gt;Pattern&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'(?:80CCD\(1B\)[^\r\n]{0,120}(?:new\s+regime|new\s+tax\s+regime))'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;+&lt;/span&gt;&lt;span class="w"&gt;
                  &lt;/span&gt;&lt;span class="s1"&gt;'|(?:(?:new\s+regime|new\s+tax\s+regime)[^\r\n]{0,120}80CCD\(1B\))'&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nx"&gt;Fact&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Section 80CCD(1B) is Old Regime only. Only 80CCD(2) survives the New Regime."&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="c"&gt;# ... more rules&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="nv"&gt;$safeIndicators&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;@(&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'\bdisallow'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'\bnot\s+allow'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'\bdoes\s+not\s+(?:allow|permit|apply)'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'\bold\s+regime\s+only'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'\bwithout\s+(?:ever\s+)?touching'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'\bmistake'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'\bbelieving\s+80CCD'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="c"&gt;# ~20 more&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="kr"&gt;function&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Test-HasSafeIndicator&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="kr"&gt;param&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="nv"&gt;$window&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="kr"&gt;foreach&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$ind&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kr"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$safeIndicators&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="kr"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$window&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-imatch&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$ind&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kr"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;$true&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="kr"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;$false&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="kr"&gt;foreach&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kr"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$mirrorFiles&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nv"&gt;$bodyContent&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# ...skip &amp;lt;head&amp;gt;...&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="kr"&gt;foreach&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$rule&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kr"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$forbiddenClaims&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nv"&gt;$matches&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;regex&lt;/span&gt;&lt;span class="p"&gt;]::&lt;/span&gt;&lt;span class="n"&gt;Matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$bodyContent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$rule&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Pattern&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Text.RegularExpressions.RegexOptions&lt;/span&gt;&lt;span class="p"&gt;]::&lt;/span&gt;&lt;span class="n"&gt;IgnoreCase&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;

        &lt;/span&gt;&lt;span class="kr"&gt;foreach&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$match&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kr"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$matches&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nv"&gt;$windowStart&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;]::&lt;/span&gt;&lt;span class="n"&gt;Max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$match&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Index&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nv"&gt;$windowEnd&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;]::&lt;/span&gt;&lt;span class="n"&gt;Min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$bodyContent&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$match&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Index&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$match&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Length&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nv"&gt;$window&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$bodyContent&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Substring&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$windowStart&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$windowEnd&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$windowStart&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;

            &lt;/span&gt;&lt;span class="kr"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Test-HasSafeIndicator&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-window&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$window&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kr"&gt;continue&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

            &lt;/span&gt;&lt;span class="c"&gt;# Real violation. Report.&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nv"&gt;$violations&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;pscustomobject&lt;/span&gt;&lt;span class="p"&gt;]@{&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nx"&gt;File&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FullName&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nx"&gt;Rule&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$rule&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Name&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nx"&gt;Match&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$match&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Value&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Two hundred lines total, including all 4 rules and 30-odd safe indicators.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened when I ran it
&lt;/h2&gt;

&lt;p&gt;First run against the "clean" tree (post my Sunday fixes):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Mirrors inspected: 100
Violations: 13
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Thirteen? I'd sworn I fixed all of them.&lt;/p&gt;

&lt;p&gt;Nine were false positives — sentences correctly stating the rule, plus a few worked-example patterns like &lt;em&gt;"Under the Old Regime with HRA claim you would take home X"&lt;/em&gt; (correct, but "HRA" and "New Regime" appeared within 100 chars of each other elsewhere in the paragraph). I extended the safe-indicators list to cover those.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Four were real.&lt;/strong&gt; Different pages, all mine, all live:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;/blog/tax-saving-new-regime/&lt;/code&gt;, line 166 (intro paragraph):&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"For FY 2026-27, a salaried New Regime taxpayer can still claim Rs 75,000 standard deduction, &lt;strong&gt;Rs 50,000 NPS under 80CCD(1B)&lt;/strong&gt;, and an uncapped employer NPS contribution under 80CCD(2)."&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;/blog/nps-tax-benefits/&lt;/code&gt;, line 383 (Bottom Line section):&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"…NPS Tier-1 is one of the highest-leverage tax-saving instruments available — &lt;strong&gt;especially under the New Regime, where it's the only meaningful retirement deduction left. Contributing the full Rs 50,000 under 80CCD(1B)&lt;/strong&gt; saves…"&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;/blog/tax-saving-tips/&lt;/code&gt;, line 166 (intro):&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"…and &lt;strong&gt;Section 80CCD(1B) NPS is now allowed in both regimes&lt;/strong&gt;. This guide cuts through the noise…"&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;code&gt;/blog/section-80c-investments/&lt;/code&gt; — the same class of drift, slightly different phrasing.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I'd manually fixed the &lt;em&gt;other&lt;/em&gt; body sections of exactly these files on Sunday. The intros and bottom-lines were on completely different lines. I hadn't caught them because I was grep-searching for the specific phrasings I'd already found. The lint's proximity pattern didn't care about phrasing — it just noticed the semantic co-occurrence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Same commit that added the lint also fixed the four newly-surfaced lies.&lt;/strong&gt; Verification loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;pwsh &lt;span class="nt"&gt;-File&lt;/span&gt; scripts/lint-mirror-integrity.ps1
&lt;span class="go"&gt;Mirrors inspected: 100
Violations: 0
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why this generalizes
&lt;/h2&gt;

&lt;p&gt;I'm not the only one running a Blazor/Astro/Next.js site with a JS-hydration layer over a static SEO mirror. The exact tech stack varies. The pattern is universal:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Two representations of the same content&lt;/li&gt;
&lt;li&gt;Only one is authoritative (the source)&lt;/li&gt;
&lt;li&gt;Only the other is what search engines actually see&lt;/li&gt;
&lt;li&gt;Sync is manual or "should be automatic but isn't"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The lint doesn't care about the specific technology. It cares about the semantic co-occurrence in whatever HTML the crawler will see.&lt;/p&gt;

&lt;p&gt;Adapt it to your own YMYL (Your-Money-Your-Life) content:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Financial&lt;/strong&gt;: interest rates, tax brackets, deduction limits, loan terms&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Medical&lt;/strong&gt;: dosage claims, treatment eligibility, side-effect statements&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Legal&lt;/strong&gt;: statute-of-limitations, jurisdiction rules, filing deadlines&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Coding tutorials&lt;/strong&gt;: version-specific API claims ("this works in React 17" while React 19 shipped last month)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each rule is one entry: a co-occurrence pattern and a fact. Safe indicators are shared across all rules. Both are easy to grow — the day I discover another drift class, I add one entry to the &lt;code&gt;$forbiddenClaims&lt;/code&gt; array.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the lint does not do
&lt;/h2&gt;

&lt;p&gt;Deliberate non-goals:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It does not regenerate mirrors from razors.&lt;/strong&gt; That's a build-step problem, orders of magnitude bigger. The lint just guards specific known-false claims.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It does not use an LLM.&lt;/strong&gt; LLM-based fact-checking would be more general but expensive and non-deterministic. Regex + safe-indicator words is deterministic, runs in under a second on 100 files, and costs nothing in CI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It doesn't check the razor sources.&lt;/strong&gt; Those are the source of truth. If a razor is wrong, the CA reviewer catches it. The lint's scope is: mirrors must not be worse than razors.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wire it into CI
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;.github/workflows/lint-title-parity.yml&lt;/code&gt; picked up the new job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;mirror-integrity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Mirror body-content YMYL integrity&lt;/span&gt;
  &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
  &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run lint-mirror-integrity&lt;/span&gt;
      &lt;span class="na"&gt;shell&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pwsh&lt;/span&gt;
      &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pwsh -File ./scripts/lint-mirror-integrity.ps1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Path-filtered to razor/mirror/lint-script paths so it doesn't fire on unrelated pushes. Fails the check on any violation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;Three things I didn't expect:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;My "manual sweep" felt thorough because I'd fixed the specific phrasings I'd found.&lt;/strong&gt; I hadn't fixed the pattern. The lint caught what I'd missed because it looked for the pattern, not the phrasing.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Safe-indicator words are the harder half.&lt;/strong&gt; The forbidden-pattern list was five minutes of work. The safe-indicator list has been iterated three times as new false-positives appeared. Every new indicator I add reduces false positives across every rule.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The first run showed me things I didn't want to see.&lt;/strong&gt; Discovering four more lies after I thought the crisis was resolved was uncomfortable. But it's exactly why the lint exists. If it never surfaced a real violation, it wouldn't be earning its CI time.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The current state
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Six rules cover 80CCD(1B), HRA, Section 24(b), plus a MathSolver-schema guard (a Google-validator gotcha I ran into weeks earlier and don't want to accidentally re-introduce).&lt;/li&gt;
&lt;li&gt;Safe-indicators list is at 30-odd terms.&lt;/li&gt;
&lt;li&gt;Runs in about 400 ms across 100 mirror files.&lt;/li&gt;
&lt;li&gt;Zero current violations. Sitting on &lt;code&gt;main&lt;/code&gt; as a tripwire.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The lint is short. The insight is small. But it caught real user-facing YMYL bugs that a human editor and a title-parity lint had both missed. That's a fair trade for 200 lines of PowerShell.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you're running a Blazor / Astro / Next.js site with a similar hydration pattern and want the full script, the SmartTaxCalc repo is public at &lt;a href="https://github.com/chetan00118/IndiaTaxPlanner" rel="noopener noreferrer"&gt;github.com/chetan00118/IndiaTaxPlanner&lt;/a&gt;. The file is &lt;code&gt;scripts/lint-mirror-integrity.ps1&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;If you have a YMYL-content-heavy site and no drift guard, this is your polite nudge.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>automation</category>
      <category>seo</category>
      <category>software</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Six directory emails promised me a dofollow backlink. My 40-line PowerShell verifier said otherwise.</title>
      <dc:creator>Chetan Sanghani</dc:creator>
      <pubDate>Wed, 22 Jul 2026 16:56:16 +0000</pubDate>
      <link>https://dev.to/chetansanghani/six-directory-emails-promised-me-a-dofollow-backlink-my-40-line-powershell-verifier-said-otherwise-37l0</link>
      <guid>https://dev.to/chetansanghani/six-directory-emails-promised-me-a-dofollow-backlink-my-40-line-powershell-verifier-said-otherwise-37l0</guid>
      <description>&lt;p&gt;Every few days now, my inbox gets another version of the same email:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Hi there! 👋 We discovered your product and think it's a great fit. Submit it and get a &lt;strong&gt;high-quality dofollow backlink&lt;/strong&gt; from a trusted domain."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In one evening last week I got &lt;strong&gt;six&lt;/strong&gt; of them. Different brand names, same pitch. And because I'm chasing external-validation signals for an AdSense approval, "dofollow backlink" is exactly the phrase designed to make me click.&lt;/p&gt;

&lt;p&gt;I didn't click. I ran a script instead. This post is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Why "dofollow" is worth verifying at all (and why the standard SEO guidance is now stale)&lt;/li&gt;
&lt;li&gt;The two silent-nofollow discoveries that made me build a verifier (Medium, GitHub)&lt;/li&gt;
&lt;li&gt;The 40-line PowerShell verifier itself + the header dance to get past bot-detection&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The newer trap: sites that actively &lt;em&gt;claim&lt;/em&gt; dofollow — and how the claim falls apart when you check the submission funnel, not just an existing listing&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What "dofollow" means, and why it moves
&lt;/h2&gt;

&lt;p&gt;Every &lt;code&gt;&amp;lt;a href="..."&amp;gt;&lt;/code&gt; on the web is either dofollow or nofollow. Google treats them very differently:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dofollow&lt;/strong&gt; (&lt;code&gt;rel&lt;/code&gt; doesn't contain &lt;code&gt;nofollow&lt;/code&gt; / &lt;code&gt;ugc&lt;/code&gt; / &lt;code&gt;sponsored&lt;/code&gt;): the source domain vouches for the destination. Passes SEO "link equity." Counts toward the destination's ranking authority.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nofollow&lt;/strong&gt; / &lt;strong&gt;UGC&lt;/strong&gt; / &lt;strong&gt;Sponsored&lt;/strong&gt;: the source domain explicitly declines to vouch. Google largely discounts these for ranking. Still fine for referral traffic and brand exposure — just doesn't move the SEO needle.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For an AdSense-approval situation like mine — where the question is "does this site have external validation from real sources" — dofollow domains matter and nofollow domains are decorative.&lt;/p&gt;

&lt;p&gt;The catch: &lt;strong&gt;platforms shift policies.&lt;/strong&gt; What was dofollow in 2020 may be nofollow today, and a cached SEO guide from a few years back gives you the outdated answer with total confidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Discovery 1: Medium is now &lt;code&gt;ugc nofollow&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;I republished a home-loan article on Medium expecting the author-bio + in-article links to be dofollow (per most 2020–2022 SEO guides). Curl'd the live article:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;curl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-sL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://medium.com/@author/article-slug"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;-A&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Mozilla/5.0"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Select-String&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Pattern&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'smarttaxcalc'&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result on every anchor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"z rm"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://smarttaxcalc.in/..."&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"noopener ugc nofollow"&lt;/span&gt; &lt;span class="na"&gt;target=&lt;/span&gt;&lt;span class="s"&gt;"_blank"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;rel="noopener ugc nofollow"&lt;/code&gt; is Medium's current platform-wide default (rolled out ~2023–2024 to kill SEO spam). Every outbound link on Medium is nofollow now, including your author-bio link.&lt;/p&gt;

&lt;h2&gt;
  
  
  Discovery 2: GitHub READMEs are nofollow too — even in your own fork
&lt;/h2&gt;

&lt;p&gt;Same day, I forked an "awesome-personal-finance" repo, added my site, and pushed. Standard advice says: "even if the PR isn't merged, your fork's README anchor is a dofollow link from github.com." Curl the rendered README:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://mint.intuit.com/"&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"nofollow"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Mint&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://smarttaxcalc.in"&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"nofollow"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;SmartTaxCalc&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every external anchor on any GitHub-rendered README is &lt;code&gt;rel="nofollow"&lt;/code&gt;, platform-wide, since ~2020. Even Mint — a heavyweight fintech domain — is nofollow on the upstream repo. Nothing you can do overrides it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Both discoveries invalidated ~30 minutes of prep in one afternoon.&lt;/strong&gt; Both would have been caught by a 30-second check &lt;em&gt;before&lt;/em&gt; the work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson: verify the &lt;code&gt;rel&lt;/code&gt; before you write
&lt;/h2&gt;

&lt;p&gt;Fetch any existing outbound link on the target platform, look at its &lt;code&gt;rel&lt;/code&gt;. If it contains &lt;code&gt;nofollow&lt;/code&gt;, that's the blanket policy — no author, no article, no PR overrides it. I didn't want to remember to do this manually every time, so I wrote it into my weekly analytics script.&lt;/p&gt;

&lt;h2&gt;
  
  
  The verifier — 40 lines of PowerShell
&lt;/h2&gt;

&lt;p&gt;The core loop, minus try/catch scaffolding:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$siteHost&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;Uri&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="nv"&gt;$config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;siteUrl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Host&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="c"&gt;# e.g. "smarttaxcalc.in"&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="kr"&gt;foreach&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$src&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kr"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;knownReferringUrls&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nv"&gt;$resp&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Invoke-WebRequest&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Uri&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$src&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-UseBasicParsing&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-TimeoutSec&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;20&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;-Headers&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$browserHeaders&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="c"&gt;# see next section for the header set&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="c"&gt;# Match every anchor pointing to our host&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nv"&gt;$pattern&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'&amp;lt;a[^&amp;gt;]*href="[^"]*'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;regex&lt;/span&gt;&lt;span class="p"&gt;]::&lt;/span&gt;&lt;span class="n"&gt;Escape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$siteHost&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'[^"]*"[^&amp;gt;]*&amp;gt;'&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nv"&gt;$anchors&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;regex&lt;/span&gt;&lt;span class="p"&gt;]::&lt;/span&gt;&lt;span class="n"&gt;Matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$resp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$pattern&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;System.Text.RegularExpressions.RegexOptions&lt;/span&gt;&lt;span class="p"&gt;]::&lt;/span&gt;&lt;span class="n"&gt;IgnoreCase&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="kr"&gt;foreach&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$m&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kr"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$anchors&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nv"&gt;$tag&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Value&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nv"&gt;$rel&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kr"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$tag&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-match&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'rel="([^"]*)"'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;$Matches&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kr"&gt;else&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

        &lt;/span&gt;&lt;span class="c"&gt;# Dofollow = no nofollow, no ugc, no sponsored in rel.&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="c"&gt;# rel="noopener noreferrer" is fine — security attributes, not SEO.&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nv"&gt;$isDofollow&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-not&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$rel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToLower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-match&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'\b(nofollow|ugc|sponsored)\b'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;

        &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;pscustomobject&lt;/span&gt;&lt;span class="p"&gt;]@{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nx"&gt;sourceUrl&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$src&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nx"&gt;rel&lt;/span&gt;&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$rel&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nx"&gt;isDofollow&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$isDofollow&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;Start-Sleep&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Milliseconds&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;500&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="c"&gt;# polite pause between hosts&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;config.knownReferringUrls&lt;/code&gt; is just a list of URLs where you've published or shared a link back — Medium articles, Dev.to posts, forum threads, directory listings, whatever. Output feeds a scorecard row in my weekly &lt;code&gt;insights.md&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;| Dofollow referring domains | 1 | ≥ 5 | 🔴 |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The header dance for Cloudflare-fronted sites
&lt;/h2&gt;

&lt;p&gt;A bare &lt;code&gt;Invoke-WebRequest&lt;/code&gt; gets 403'd instantly on Medium, Quora, and anything behind Cloudflare. Even &lt;code&gt;-UserAgent 'Mozilla/5.0'&lt;/code&gt; isn't enough. This header set gets me through most sites:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$browserHeaders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;@{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'User-Agent'&lt;/span&gt;&lt;span class="w"&gt;                &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36'&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'Accept'&lt;/span&gt;&lt;span class="w"&gt;                    &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8'&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'Accept-Language'&lt;/span&gt;&lt;span class="w"&gt;           &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'en-US,en;q=0.9'&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'Sec-Ch-Ua'&lt;/span&gt;&lt;span class="w"&gt;                 &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'"Chromium";v="121", "Not A(Brand";v="99"'&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'Sec-Ch-Ua-Mobile'&lt;/span&gt;&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'?0'&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'Sec-Ch-Ua-Platform'&lt;/span&gt;&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'"Windows"'&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'Sec-Fetch-Dest'&lt;/span&gt;&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'document'&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'Sec-Fetch-Mode'&lt;/span&gt;&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'navigate'&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'Sec-Fetch-Site'&lt;/span&gt;&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'none'&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'Upgrade-Insecure-Requests'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'1'&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;Sec-Ch-Ua*&lt;/code&gt; and &lt;code&gt;Sec-Fetch-*&lt;/code&gt; headers are what Chrome sends automatically that a bare HTTP client doesn't. Even with the full set, some sites still block on a TLS-fingerprint check &lt;code&gt;Invoke-WebRequest&lt;/code&gt; can't spoof — for those the verifier reports &lt;code&gt;❓ Unknown (fetch blocked)&lt;/code&gt; rather than misclassifying. When it can't confirm, it says so.&lt;/p&gt;

&lt;h2&gt;
  
  
  The newer trap: when the site &lt;em&gt;emails you&lt;/em&gt; claiming dofollow
&lt;/h2&gt;

&lt;p&gt;Discoveries 1 and 2 were &lt;em&gt;silent&lt;/em&gt; nofollow — platforms that quietly changed policy. The newer pattern is louder: directory sites that email you promising &lt;strong&gt;"a high-quality dofollow backlink from a trusted domain."&lt;/strong&gt; That's the actual selling point in the pitch.&lt;/p&gt;

&lt;p&gt;So I did what the verifier taught me — checked an existing listing on one of these networks before submitting. And here's the twist that makes this worth a whole section: &lt;strong&gt;the existing listing checked out.&lt;/strong&gt; Outbound product links carried:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;rel="noopener noreferrer"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;nofollow&lt;/code&gt;, no &lt;code&gt;ugc&lt;/code&gt;. By the rule above, that's dofollow. Naive verdict: "great, submit."&lt;/p&gt;

&lt;p&gt;Then I opened the actual submission form. That's where it fell apart:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The free tier's dofollow was conditional.&lt;/strong&gt; You got it &lt;em&gt;only if&lt;/em&gt; your product hit a top-3 daily ranking (which needs a vote campaign), &lt;strong&gt;or&lt;/strong&gt; you embedded &lt;em&gt;their&lt;/em&gt; badge on &lt;em&gt;your&lt;/em&gt; site — i.e. a reciprocal link exchange, which is a named Google link-scheme pattern, not a free win.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The "guaranteed dofollow" was a paid tier.&lt;/strong&gt; Buying a dofollow link is, again, squarely against Google's link-scheme guidelines — the last thing you want pointing at a site mid-review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The free launch slots were exhausted on every available date.&lt;/strong&gt; The date picker showed "0 free slots" for every day in range. The paid tier's headline perk? &lt;em&gt;"Skip the free queue."&lt;/em&gt; The free path wasn't a path; it was a funnel.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same operator, it turned out, ran several of these under different brand names and emailed me from each. Live listings looked dofollow; the &lt;em&gt;submission&lt;/em&gt; was gated behind a condition or a payment.&lt;/p&gt;

&lt;h3&gt;
  
  
  The lesson upgrade: verify the funnel, not just a listing
&lt;/h3&gt;

&lt;p&gt;Checking an existing listing's &lt;code&gt;rel&lt;/code&gt; is necessary but &lt;strong&gt;not sufficient.&lt;/strong&gt; A network can serve genuinely dofollow HTML on its live pages while gating new &lt;em&gt;free&lt;/em&gt; submissions behind:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a ranking condition you can't meet without vote-farming,&lt;/li&gt;
&lt;li&gt;a reciprocal-badge requirement (link scheme), or&lt;/li&gt;
&lt;li&gt;a paid tier (paid link = link scheme).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the check I run now has two steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Listing check&lt;/strong&gt; (the script above): does an existing outbound link carry a clean &lt;code&gt;rel&lt;/code&gt;?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Funnel check&lt;/strong&gt; (manual, 2 minutes): open the submit form. What tier grants the dofollow? Is the free tier actually available (slots), or zeroed out to push you to pay? Does "free dofollow" require a badge/reciprocal link?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If step 2 says "dofollow only via payment or reciprocity," the answer is no — regardless of what step 1 showed. A paid or reciprocal dofollow isn't just low-value; it's an active risk if Google is looking at your link profile.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters more than a one-off check
&lt;/h2&gt;

&lt;p&gt;Platform policies drift one-way, and you find out via ranking drops, not announcements: LinkedIn nofollow ~2016, Twitter 2019, Quora 2022, Medium ~2023–2024, GitHub READMEs ~2020. Any pre-2023 SEO guide is potentially wrong about at least one. And the newer directory-spam layer means the &lt;em&gt;claim&lt;/em&gt; of dofollow is now marketing copy, not a fact — the fact lives in the &lt;code&gt;rel&lt;/code&gt; attribute and the submission funnel.&lt;/p&gt;

&lt;p&gt;Two rules I now live by:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Nobody's claim of "dofollow" counts. The &lt;code&gt;rel&lt;/code&gt; attribute counts.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;An existing listing's &lt;code&gt;rel&lt;/code&gt; isn't the offer. The submission tier is the offer — check it.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Steal the pattern
&lt;/h2&gt;

&lt;p&gt;The verifier runs alongside my GSC / GA4 / Bing / PageSpeed fetches — one script, one double-click, all marketing signals in one place. Two changes to fit your setup:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Point &lt;code&gt;$config.siteUrl&lt;/code&gt; and &lt;code&gt;$config.knownReferringUrls&lt;/code&gt; at your domain + your list.&lt;/li&gt;
&lt;li&gt;For 403-heavy platforms, either add retry logic or just report them &lt;code&gt;Unknown&lt;/code&gt; (I do the latter — I already know their policies).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you're a solo founder shipping links across platforms and hoping some count toward SEO: run the listing check weekly, run the funnel check before every submission, and stop being surprised.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm &lt;a href="https://smarttaxcalc.in/about/" rel="noopener noreferrer"&gt;Chetan Sanghani&lt;/a&gt;, building &lt;a href="https://smarttaxcalc.in" rel="noopener noreferrer"&gt;SmartTaxCalc.in&lt;/a&gt; — a free, browser-based Indian tax + finance calculator platform. Previously wrote about &lt;a href="https://dev.to/chetansanghani/googles-mathsolver-json-ld-validator-is-stricter-than-schemaorg-i-learned-that-the-hard-way-over-3eb0"&gt;Google's MathSolver JSON-LD validator quirks&lt;/a&gt;, &lt;a href="https://dev.to/chetansanghani/how-i-got-lcp-under-2s-on-a-38-page-blazor-webassembly-site-cloudflare-pages-4hec"&gt;dropping mobile LCP under 2s on Blazor WASM&lt;/a&gt;, and &lt;a href="https://dev.to/chetansanghani/a-self-cleaning-product-hunt-teaser-banner-in-blazor-wasm-100-lines-auto-hides-after-launch-186p"&gt;a self-cleaning Product Hunt teaser banner&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>automation</category>
      <category>programming</category>
      <category>seo</category>
    </item>
    <item>
      <title>Launching SmartTaxCalc on Product Hunt today 🚀</title>
      <dc:creator>Chetan Sanghani</dc:creator>
      <pubDate>Sat, 18 Jul 2026 07:43:26 +0000</pubDate>
      <link>https://dev.to/chetansanghani/launching-smarttaxcalc-on-product-hunt-today-3joh</link>
      <guid>https://dev.to/chetansanghani/launching-smarttaxcalc-on-product-hunt-today-3joh</guid>
      <description>&lt;p&gt;Just went live on Product Hunt: &lt;strong&gt;&lt;a href="https://www.producthunt.com/products/smarttaxcalc" rel="noopener noreferrer"&gt;SmartTaxCalc — 38 free Indian tax + finance calculators&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you've followed my &lt;a href="https://dev.to/chetansanghani"&gt;previous&lt;/a&gt; writeups on the Blazor WASM + Cloudflare Pages stack, this is the culmination — 38 tools, 30+ blog articles, all client-side, CA-reviewed, no signup, no data collection.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's live
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Live tools&lt;/strong&gt;: &lt;a href="https://smarttaxcalc.in/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=ph_launch_2026&amp;amp;utm_content=launch_post" rel="noopener noreferrer"&gt;smarttaxcalc.in&lt;/a&gt; — free forever, mobile-friendly, works offline once loaded (Blazor WASM caches locally)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PH launch page&lt;/strong&gt;: &lt;a href="https://www.producthunt.com/products/smarttaxcalc" rel="noopener noreferrer"&gt;producthunt.com/products/smarttaxcalc&lt;/a&gt; — upvotes appreciated 🙏&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I learned (5 quick engineering wins from the build)
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;a href="https://dev.to/chetansanghani/one-cut-my-blazor-wasm-lcp-by-500ms-the-invisible-file-at-the-end-of-the-3afo"&gt;LCP preload trick&lt;/a&gt;&lt;/strong&gt; — a 5.6 KB JSON at the tail of Blazor's critical chain was costing 5.5s of "element render delay." One &lt;code&gt;&amp;lt;link rel="preload"&amp;gt;&lt;/code&gt; in &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; fixed it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;a href="https://dev.to/chetansanghani/how-i-got-lcp-under-2s-on-a-38-page-blazor-webassembly-site-cloudflare-pages-4hec"&gt;Getting LCP under 2s on Blazor WASM&lt;/a&gt;&lt;/strong&gt; — six mechanical fixes across a weekend, real commit hashes + before/after numbers.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;a href="https://dev.to/chetansanghani/a-self-cleaning-product-hunt-teaser-banner-in-blazor-wasm-100-lines-auto-hides-after-launch-186p"&gt;Self-cleaning PH teaser banner in Blazor&lt;/a&gt;&lt;/strong&gt; — 100 lines, auto-hides after launch date via &lt;code&gt;DateTime.UtcNow &amp;lt; KillDateUtc&lt;/code&gt; in &lt;code&gt;OnInitialized&lt;/code&gt;. No manual removal.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;a href="https://dev.to/chetansanghani/googles-mathsolver-json-ld-validator-is-stricter-than-schemaorg-i-learned-that-the-hard-way-over-3eb0"&gt;GSC's MathSolver JSON-LD validator is stricter than Schema.org&lt;/a&gt;&lt;/strong&gt; — learned the hard way that "valid according to schema.org" ≠ "valid according to Google's Rich Results validator."&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;a href="https://dev.to/chetansanghani/medium-and-github-both-went-nofollow-so-i-built-a-40-line-powershell-backlink-verifier-2dpe"&gt;Medium and GitHub both went nofollow&lt;/a&gt;&lt;/strong&gt; — so I built a 40-line PowerShell backlink verifier that curls each referring page + greps for &lt;code&gt;rel="nofollow"&lt;/code&gt;. Now runs nightly.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Blazor WebAssembly&lt;/strong&gt; on &lt;strong&gt;Cloudflare Pages&lt;/strong&gt; (free tier)&lt;/li&gt;
&lt;li&gt;Pre-rendered static HTML mirrors for SEO (crawler sees content immediately, Blazor hydrates on top for interactivity)&lt;/li&gt;
&lt;li&gt;No backend — every calculation runs client-side in the browser&lt;/li&gt;
&lt;li&gt;No signup, no email capture, no data sent to any server&lt;/li&gt;
&lt;li&gt;CA-reviewed by an ICAI-registered Chartered Accountant (Ankitkumar Italiya, membership 644575)&lt;/li&gt;
&lt;li&gt;91 razor pages, 91 matching static mirrors, title-parity enforced by pre-commit lint&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Total spend to date: &lt;strong&gt;~$0&lt;/strong&gt; (Cloudflare Pages free tier + a domain).&lt;/p&gt;

&lt;h2&gt;
  
  
  Ask
&lt;/h2&gt;

&lt;p&gt;If you're a Blazor dev, Indian expat/NRI, or anyone in the CA/tax space — please:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://www.producthunt.com/products/smarttaxcalc" rel="noopener noreferrer"&gt;Upvote on Product Hunt&lt;/a&gt;&lt;/strong&gt; if the tool looks useful&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Try it&lt;/strong&gt; at &lt;a href="https://smarttaxcalc.in/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=ph_launch_2026&amp;amp;utm_content=launch_post" rel="noopener noreferrer"&gt;smarttaxcalc.in&lt;/a&gt; — 38 calculators to explore&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reply here&lt;/strong&gt; with feedback on the tech choices — always curious about how others approach Blazor SEO / CWV / SPA deployment&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Also: if there's a specific Blazor architectural pattern you want a deep-dive on (like "how to prerender content that Blazor also renders without double-render CLS issues"), drop a comment. Article #7 in the series is TBD.&lt;/p&gt;

&lt;p&gt;Thanks for the support 🙏&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>fintech</category>
      <category>showdev</category>
      <category>webdev</category>
    </item>
    <item>
      <title>One &lt;link rel=preload&gt; cut my Blazor WASM LCP by 500ms — the invisible file at the end of the critical chain</title>
      <dc:creator>Chetan Sanghani</dc:creator>
      <pubDate>Fri, 17 Jul 2026 16:19:20 +0000</pubDate>
      <link>https://dev.to/chetansanghani/one-cut-my-blazor-wasm-lcp-by-500ms-the-invisible-file-at-the-end-of-the-3afo</link>
      <guid>https://dev.to/chetansanghani/one-cut-my-blazor-wasm-lcp-by-500ms-the-invisible-file-at-the-end-of-the-3afo</guid>
      <description>&lt;p&gt;Two weeks ago I &lt;a href="https://dev.to/chetansanghani/how-i-got-lcp-under-2s-on-a-38-page-blazor-webassembly-site-cloudflare-pages-4hec"&gt;wrote about getting my Blazor WebAssembly site's LCP under 2 seconds&lt;/a&gt;. I thought I was done.&lt;/p&gt;

&lt;p&gt;I wasn't.&lt;/p&gt;

&lt;p&gt;Last week I ran a fresh PageSpeed Insights audit and saw this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FCP:  0.3s   ✅ Great
LCP:  3.5s   ⚠️ Needs Improvement
TBT:  2,360ms 🔴 Poor
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;FCP was 0.3 seconds. &lt;strong&gt;Something was painted in under a third of a second.&lt;/strong&gt; So how was LCP 3.5 seconds? What was the browser waiting for?&lt;/p&gt;

&lt;p&gt;The "LCP breakdown" panel in PageSpeed had the answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Subpart              Duration
Time to first byte   0 ms
Element render delay 5,500 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;5.5 seconds of "element render delay."&lt;/strong&gt; Not download time. Not parse time. Not layout. Something was preventing the browser from painting the LCP element for five and a half seconds after it was ready.&lt;/p&gt;

&lt;p&gt;Let me walk through the diagnosis and the three-line fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "Network dependency tree" was the smoking gun
&lt;/h2&gt;

&lt;p&gt;PageSpeed Insights has an under-appreciated panel: &lt;strong&gt;Network dependency tree&lt;/strong&gt;. It shows the critical request chain — every resource the browser had to fetch before it could paint the LCP element.&lt;/p&gt;

&lt;p&gt;Mine looked like this (edited for brevity):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Maximum critical path latency: 5,464 ms
Initial Navigation
  https://smarttaxcalc.in       169 ms   14.6 KiB
  /js/site-helpers.js          251 ms    8.7 KiB
  /_framework/dotnet.js         642 ms   17.5 KiB
  /_framework/dotnet.runtime… 740 ms   57.7 KiB
  /data/tax-rules.json       5,464 ms    5.6 KiB   ← ??
  /_framework/dotnet.native…    719 ms   37.9 KiB
  /_framework/dotnet.native…wasm  920 ms  1,155.7 KiB
  ... 40 more .wasm files ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice the &lt;code&gt;/data/tax-rules.json&lt;/code&gt; line. &lt;strong&gt;5.6 KB. 5,464 ms wall-clock.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That file is tiny. Downloading it should take a few hundred milliseconds max. Why was the wall-clock 5.5 seconds?&lt;/p&gt;

&lt;p&gt;Because the browser was fetching it &lt;strong&gt;at the end of the chain&lt;/strong&gt;, after all 43 .wasm assembly files had finished loading.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this happens on every Blazor WASM app
&lt;/h2&gt;

&lt;p&gt;Blazor WebAssembly's boot sequence is roughly:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Browser fetches &lt;code&gt;index.html&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Parses &lt;code&gt;&amp;lt;script src="_framework/blazor.webassembly.js"&amp;gt;&lt;/code&gt; — starts downloading.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;blazor.webassembly.js&lt;/code&gt; downloads and starts running.&lt;/li&gt;
&lt;li&gt;It fetches &lt;code&gt;blazor.boot.json&lt;/code&gt; (the manifest of runtime + your assemblies).&lt;/li&gt;
&lt;li&gt;It fetches &lt;strong&gt;all&lt;/strong&gt; the .wasm files in parallel.&lt;/li&gt;
&lt;li&gt;It instantiates the runtime.&lt;/li&gt;
&lt;li&gt;It starts your &lt;code&gt;Program.cs&lt;/code&gt; main.&lt;/li&gt;
&lt;li&gt;Your DI container resolves services, including &lt;code&gt;HttpClient&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Your app's initial render kicks off.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Now&lt;/strong&gt; any &lt;code&gt;HttpClient.GetFromJsonAsync("/data/tax-rules.json")&lt;/code&gt; call runs.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 10 sits at the end of the wall-clock, waiting for everything above it to finish.&lt;/p&gt;

&lt;p&gt;If your app renders content that depends on that JSON — in my case a tax slabs table that's a large above-fold element — LCP does not fire until step 10 completes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;FCP fires much earlier&lt;/strong&gt; because the pre-rendered static HTML is painted at step 1. But LCP measures the LARGEST content, and Blazor re-paints the largest content only after step 10.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix — three lines in &lt;code&gt;index.html&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The browser has no idea &lt;code&gt;/data/tax-rules.json&lt;/code&gt; is on the critical path until Blazor's runtime asks for it. But we can tell it, using a &lt;code&gt;&amp;lt;link rel="preload"&amp;gt;&lt;/code&gt; hint at the very top of &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here's the diff:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- Preload critical assets --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"preload"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"_framework/blazor.webassembly.js?v=..."&lt;/span&gt; &lt;span class="na"&gt;as=&lt;/span&gt;&lt;span class="s"&gt;"script"&lt;/span&gt; &lt;span class="na"&gt;fetchpriority=&lt;/span&gt;&lt;span class="s"&gt;"high"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- NEW: Preload tax-rules.json — end of Blazor's critical request chain --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"preload"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"data/tax-rules.json"&lt;/span&gt; &lt;span class="na"&gt;as=&lt;/span&gt;&lt;span class="s"&gt;"fetch"&lt;/span&gt; &lt;span class="na"&gt;fetchpriority=&lt;/span&gt;&lt;span class="s"&gt;"high"&lt;/span&gt; &lt;span class="na"&gt;crossorigin&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"preconnect"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://www.googletagmanager.com"&lt;/span&gt; &lt;span class="na"&gt;crossorigin&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three attributes matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;as="fetch"&lt;/code&gt;&lt;/strong&gt; — matches the &lt;code&gt;HttpClient.GetFromJsonAsync()&lt;/code&gt; request that will happen later. Without this, the browser preloads it as the wrong type and skips using the cached copy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fetchpriority="high"&lt;/code&gt;&lt;/strong&gt; — hints that the browser should start this fetch immediately, not wait until the current parse yields.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;crossorigin&lt;/code&gt;&lt;/strong&gt; — required for &lt;code&gt;as="fetch"&lt;/code&gt; preloads to match a CORS-enabled fetch. Even for same-origin, Blazor's &lt;code&gt;HttpClient&lt;/code&gt; sends CORS-mode requests.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Before / after (real numbers, mobile Lighthouse, Slow 4G throttled)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;th&gt;Delta&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;FCP&lt;/td&gt;
&lt;td&gt;0.4s&lt;/td&gt;
&lt;td&gt;0.3s&lt;/td&gt;
&lt;td&gt;▼25%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LCP&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1985ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1518ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;▼&lt;strong&gt;467ms&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Element render delay&lt;/td&gt;
&lt;td&gt;5,500ms&lt;/td&gt;
&lt;td&gt;180ms&lt;/td&gt;
&lt;td&gt;▼97%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network critical path latency&lt;/td&gt;
&lt;td&gt;5,464ms&lt;/td&gt;
&lt;td&gt;106ms&lt;/td&gt;
&lt;td&gt;▼98%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CLS&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;same&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 5,500ms element render delay collapsed to 180ms. LCP dropped 467ms on mobile.&lt;/p&gt;

&lt;p&gt;Verified a day later via the PageSpeed Insights API in my nightly analytics fetch — the improvement stuck across measurements.&lt;/p&gt;

&lt;h2&gt;
  
  
  When this pattern applies
&lt;/h2&gt;

&lt;p&gt;Anywhere in your Blazor WASM app you're doing something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Program.cs or a startup component&lt;/span&gt;
&lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;OnInitializedAsync&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;Http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetFromJsonAsync&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;TaxConfig&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"data/tax-rules.json"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that JSON blocks the first meaningful render (i.e., the LCP element depends on it), preload it in &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;. The file can be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A JSON config file&lt;/li&gt;
&lt;li&gt;A CSV/TSV data file&lt;/li&gt;
&lt;li&gt;A pre-computed lookup table&lt;/li&gt;
&lt;li&gt;Any other static resource under &lt;code&gt;wwwroot/&lt;/code&gt; that Blazor fetches during boot&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the file is under 20 KB it costs nothing to preload. If it's larger, weigh the bandwidth cost against the LCP win.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap I fell into first — assuming Blazor boot was fixed cost
&lt;/h2&gt;

&lt;p&gt;I spent months blaming Blazor for slow LCP. Every diagnosis started with "Blazor WASM is inherently 3-4 seconds to boot."&lt;/p&gt;

&lt;p&gt;That's true in the aggregate. But the aggregate hid the fact that &lt;strong&gt;one specific 5.6 KB file&lt;/strong&gt; was sitting at the tail of the chain, single-handedly adding 3+ seconds to my perceived boot time.&lt;/p&gt;

&lt;p&gt;If I'd read the "Network dependency tree" panel two months earlier, I would have shipped this fix in October.&lt;/p&gt;

&lt;p&gt;Look at that panel first. Every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anti-pattern — don't preload everything
&lt;/h2&gt;

&lt;p&gt;You get one or two "high priority" preload slots that meaningfully move the needle. If you preload every static asset:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The browser can't prioritise the truly critical ones.&lt;/li&gt;
&lt;li&gt;Bandwidth for &lt;code&gt;blazor.boot.json&lt;/code&gt; (which is the actual entry point) gets contended.&lt;/li&gt;
&lt;li&gt;Lighthouse will flag "Unused preload" audits.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Rule of thumb: preload only resources that would otherwise be at the end of the critical chain — meaning Blazor's runtime asks for them AFTER everything else is done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus lessons from the same optimization pass
&lt;/h2&gt;

&lt;p&gt;While I was at it, I also tried:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Enabling &lt;code&gt;&amp;lt;RunAOTCompilation&amp;gt;true&amp;lt;/RunAOTCompilation&amp;gt;&lt;/code&gt;&lt;/strong&gt; — Cloudflare Pages CI build failed silently ("No deployment available"). Turns out the CI environment ships the base .NET SDK without the &lt;code&gt;wasm-tools&lt;/code&gt; workload, and &lt;code&gt;dotnet workload restore&lt;/code&gt; in a MSBuild target doesn't pull the emcc + binaryen native binaries. Reverted the same night. If you're on GitHub Actions with proper toolchain, this is worth trying.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Deferring Google Analytics until first user interaction&lt;/strong&gt; — moved 275ms of gtag.js main-thread evaluation out of Lighthouse's measurement window. TBT dropped ~500ms. Real users still get tracked as soon as they interact.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Disabling Cloudflare Web Analytics beacon&lt;/strong&gt; — 11 KB + 17ms main thread. Redundant if you already use GA4. Toggle it off in the Cloudflare dashboard under Analytics → Web Analytics.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Combined with the preload fix, my mobile Performance score went from 47 (desktop) → 73 (mobile) with mobile LCP firmly in the "Good" range.&lt;/p&gt;

&lt;p&gt;Desktop LCP is still 3.5s though, and that's a longer story — Blazor SPAs replace pre-rendered content on wider viewports differently. That'll be article #6.&lt;/p&gt;




&lt;p&gt;If you're on Blazor WebAssembly and your LCP feels stuck, open PageSpeed Insights, scroll to the Network dependency tree, and look at the last line. There's probably a 5 KB file sitting there costing you seconds.&lt;/p&gt;

&lt;p&gt;The site: &lt;a href="https://smarttaxcalc.in" rel="noopener noreferrer"&gt;smarttaxcalc.in&lt;/a&gt; — a free Indian tax + finance calculator platform. 38 tools, CA-reviewed, all Blazor WebAssembly.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>frontend</category>
      <category>performance</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Medium and GitHub both went nofollow — so I built a 40-line PowerShell backlink verifier</title>
      <dc:creator>Chetan Sanghani</dc:creator>
      <pubDate>Thu, 09 Jul 2026 17:17:00 +0000</pubDate>
      <link>https://dev.to/chetansanghani/medium-and-github-both-went-nofollow-so-i-built-a-40-line-powershell-backlink-verifier-o41</link>
      <guid>https://dev.to/chetansanghani/medium-and-github-both-went-nofollow-so-i-built-a-40-line-powershell-backlink-verifier-o41</guid>
      <description>&lt;p&gt;I burned about 30 minutes of work on Monday shipping content to Medium and GitHub in the belief they'd give me dofollow backlinks. Both turned out to be platform-wide nofollow. Both discoveries came from a single &lt;code&gt;curl&lt;/code&gt; command that I should have run &lt;em&gt;before&lt;/em&gt; the work, not after.&lt;/p&gt;

&lt;p&gt;This post is: (1) the two discoveries and why the standard SEO guidance is now stale, (2) the 40-line PowerShell verifier I wrote so I never make the mistake again, and (3) the specific header dance you need to actually get past bot-detection on Medium and Quora.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "dofollow" means, and why it moves
&lt;/h2&gt;

&lt;p&gt;Every &lt;code&gt;&amp;lt;a href="..."&amp;gt;&lt;/code&gt; on the web is either dofollow or nofollow. Google (and other search engines) treats them very differently:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dofollow&lt;/strong&gt; (&lt;code&gt;rel&lt;/code&gt; doesn't contain &lt;code&gt;nofollow&lt;/code&gt; / &lt;code&gt;ugc&lt;/code&gt; / &lt;code&gt;sponsored&lt;/code&gt;): the source domain vouches for the destination. Passes SEO "link equity." Counts toward how Google decides the destination's ranking authority.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nofollow&lt;/strong&gt; / &lt;strong&gt;UGC&lt;/strong&gt; / &lt;strong&gt;Sponsored&lt;/strong&gt;: the source domain explicitly declines to vouch. Google largely discounts these for ranking purposes. Still useful for referral traffic and brand exposure, but doesn't move the SEO needle.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For an AdSense-approval situation like mine — where reviewers look at "does this site have external validation from real sources" — dofollow domains matter. Nofollow domains are decorative.&lt;/p&gt;

&lt;p&gt;Now the catch: &lt;strong&gt;platforms shift policies.&lt;/strong&gt; What was dofollow in 2020 may be nofollow today, and cached SEO guides from a few years back get you the outdated answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Discovery 1: Medium is now &lt;code&gt;ugc nofollow&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;I republished a Section 24(b) home-loan article on Medium, expecting the author-bio + in-article links to be dofollow (per most SEO guides from 2020-2022). Curl'd the live article:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;curl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-sL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://medium.com/@author/article-slug"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;-A&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Mozilla/5.0"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Select-String&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Pattern&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'smarttaxcalc'&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result on every anchor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"z rm"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://smarttaxcalc.in/..."&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"noopener ugc nofollow"&lt;/span&gt; &lt;span class="na"&gt;target=&lt;/span&gt;&lt;span class="s"&gt;"_blank"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;rel="noopener ugc nofollow"&lt;/code&gt; is Medium's current default — they &lt;a href="https://help.medium.com/" rel="noopener noreferrer"&gt;rolled it out platform-wide around 2023-2024&lt;/a&gt; to kill SEO spam. Every outbound link on Medium is now nofollow, including your author-bio link. Google discounts them all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Discovery 2: GitHub READMEs are nofollow too — even in your own fork
&lt;/h2&gt;

&lt;p&gt;Same day, I forked an "awesome-personal-finance" repo, added SmartTaxCalc to the list, and pushed. Standard SEO advice says: "even if the PR isn't merged, your fork at &lt;code&gt;github.com/YOUR-USER/awesome-XXX/README.md&lt;/code&gt; still has the anchor to your site — that's a dofollow link from github.com."&lt;/p&gt;

&lt;p&gt;Wrong, at least since ~2020. Curl the rendered README:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://mint.intuit.com/"&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"nofollow"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Mint&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://smarttaxcalc.in"&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"nofollow"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;SmartTaxCalc&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every external anchor on any GitHub-rendered README is &lt;code&gt;rel="nofollow"&lt;/code&gt;. GitHub applies this platform-wide to reduce link-farming via repo forks. Even Mint on the upstream repo (a heavily authoritative domain in the fintech space) is nofollow. There's nothing you can do to force it dofollow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Both discoveries invalidated ~30 minutes of preparation work in the same afternoon.&lt;/strong&gt; The Medium article was still worth publishing (referral traffic + canonical protection so Medium doesn't outrank the original), but I wouldn't have spent time on the GitHub PR pack if I'd known.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson: curl before you write
&lt;/h2&gt;

&lt;p&gt;Both would have been caught by a 30-second check &lt;em&gt;before&lt;/em&gt; the work: fetch any existing outbound link on the target platform, look at its &lt;code&gt;rel&lt;/code&gt; attribute. If it contains &lt;code&gt;nofollow&lt;/code&gt;, that's the platform's blanket policy — no author, no article, no PR can override it.&lt;/p&gt;

&lt;p&gt;But I don't want to remember to do this manually every time. So I wrote it into my weekly analytics script. Every Sunday, along with GSC/GA4/Bing exports, the script now curls every referring URL I care about and reports which anchors are dofollow vs nofollow.&lt;/p&gt;

&lt;h2&gt;
  
  
  The verifier — 40 lines of PowerShell
&lt;/h2&gt;

&lt;p&gt;The core loop, minus try/catch scaffolding:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$siteHost&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;Uri&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="nv"&gt;$config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;siteUrl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Host&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="c"&gt;# e.g. "smarttaxcalc.in"&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="kr"&gt;foreach&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$src&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kr"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;knownReferringUrls&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nv"&gt;$resp&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Invoke-WebRequest&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Uri&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$src&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-UseBasicParsing&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-TimeoutSec&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;20&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;-Headers&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$browserHeaders&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="c"&gt;# see next section for the header set&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="c"&gt;# Match every anchor pointing to our host&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nv"&gt;$pattern&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'&amp;lt;a[^&amp;gt;]*href="[^"]*'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;regex&lt;/span&gt;&lt;span class="p"&gt;]::&lt;/span&gt;&lt;span class="n"&gt;Escape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$siteHost&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'[^"]*"[^&amp;gt;]*&amp;gt;'&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nv"&gt;$anchors&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;regex&lt;/span&gt;&lt;span class="p"&gt;]::&lt;/span&gt;&lt;span class="n"&gt;Matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$resp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$pattern&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;System.Text.RegularExpressions.RegexOptions&lt;/span&gt;&lt;span class="p"&gt;]::&lt;/span&gt;&lt;span class="n"&gt;IgnoreCase&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="kr"&gt;foreach&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$m&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kr"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$anchors&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nv"&gt;$tag&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Value&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nv"&gt;$rel&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kr"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$tag&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-match&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'rel="([^"]*)"'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;$Matches&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kr"&gt;else&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

        &lt;/span&gt;&lt;span class="c"&gt;# Dofollow = no nofollow, no ugc, no sponsored in rel.&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="c"&gt;# rel="noopener noreferrer" is fine — it's a security attribute, not SEO.&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nv"&gt;$isDofollow&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-not&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$rel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToLower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-match&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'\b(nofollow|ugc|sponsored)\b'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;

        &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;pscustomobject&lt;/span&gt;&lt;span class="p"&gt;]@{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nx"&gt;sourceUrl&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$src&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nx"&gt;rel&lt;/span&gt;&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$rel&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nx"&gt;isDofollow&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$isDofollow&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;Start-Sleep&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Milliseconds&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;500&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="c"&gt;# polite pause between hosts&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;config.knownReferringUrls&lt;/code&gt; is just a list of URLs where you've published or shared a link back to your site — Medium article URLs, Quora answer URLs, Dev.to posts, forum posts, whatever.&lt;/p&gt;

&lt;p&gt;Output goes into a CSV (&lt;code&gt;backlink-rel-verification.csv&lt;/code&gt;) and — this is where it stops being just a report — feeds a scorecard row in my weekly insights.md:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;| Dofollow referring domains | 1 | ≥ 5 | 🔴 |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If Medium ever &lt;em&gt;does&lt;/em&gt; switch back to dofollow (unlikely), or a new post I published turns out to be a hidden dofollow win, I'll see it in Sunday's report without doing anything. Same in reverse: if a platform I currently count as dofollow shifts to nofollow silently, the scorecard row updates.&lt;/p&gt;

&lt;h2&gt;
  
  
  The header dance for Medium and Quora
&lt;/h2&gt;

&lt;p&gt;The naive &lt;code&gt;Invoke-WebRequest -Uri $url -UseBasicParsing&lt;/code&gt; gets 403'd immediately on Medium, Quora, and any Cloudflare-fronted site. Even adding &lt;code&gt;-UserAgent 'Mozilla/5.0'&lt;/code&gt; isn't enough — they check other headers too.&lt;/p&gt;

&lt;p&gt;This is the header set that gets me through most sites:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$browserHeaders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;@{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'User-Agent'&lt;/span&gt;&lt;span class="w"&gt;                &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36'&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'Accept'&lt;/span&gt;&lt;span class="w"&gt;                    &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8'&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'Accept-Language'&lt;/span&gt;&lt;span class="w"&gt;           &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'en-US,en;q=0.9'&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'Accept-Encoding'&lt;/span&gt;&lt;span class="w"&gt;           &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'gzip, deflate, br'&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'Sec-Ch-Ua'&lt;/span&gt;&lt;span class="w"&gt;                 &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'"Chromium";v="121", "Not A(Brand";v="99"'&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'Sec-Ch-Ua-Mobile'&lt;/span&gt;&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'?0'&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'Sec-Ch-Ua-Platform'&lt;/span&gt;&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'"Windows"'&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'Sec-Fetch-Dest'&lt;/span&gt;&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'document'&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'Sec-Fetch-Mode'&lt;/span&gt;&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'navigate'&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'Sec-Fetch-Site'&lt;/span&gt;&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'none'&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'Sec-Fetch-User'&lt;/span&gt;&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'?1'&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s1"&gt;'Upgrade-Insecure-Requests'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'1'&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;Sec-Ch-Ua*&lt;/code&gt; and &lt;code&gt;Sec-Fetch-*&lt;/code&gt; headers are what Chrome sends automatically that a bare HTTP client doesn't. Without them, Cloudflare's bot detection has a very high true-positive rate on your requests.&lt;/p&gt;

&lt;p&gt;Even with the full set, &lt;strong&gt;Medium and Quora still block on my Windows box&lt;/strong&gt; — probably a TLS fingerprint check that &lt;code&gt;Invoke-WebRequest&lt;/code&gt; can't spoof (curl.exe fails the same way). For those, the verifier reports &lt;code&gt;❓ Unknown (fetch blocked)&lt;/code&gt; rather than misclassifying as nofollow. When it can't confirm, it says so.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the scorecard actually shows
&lt;/h2&gt;

&lt;p&gt;Running against 6 known referring URLs — 3 Dev.to articles, 1 Medium republish, 1 GitHub Awesome-List fork, 1 Quora answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;| Source     | Rel                     | Dofollow?               |
|------------|-------------------------|-------------------------|
| dev.to     | noopener noreferrer     | ✅ Yes (x10 anchors)    |
| medium.com | (fetch blocked 403)     | ❓ Unknown              |
| github.com | nofollow                | ❌ No                   |
| quora.com  | (fetch blocked 403)     | ❓ Unknown              |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Unique dofollow referring domains: 1&lt;/strong&gt; (dev.to). Both Medium and GitHub, which I &lt;em&gt;thought&lt;/em&gt; would boost this count to 3, contribute zero. The Quora and Medium fetch blocks aren't a huge loss — I already know from a prior manual curl (on a Linux box with a different TLS fingerprint) that both are nofollow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters more than a one-off check
&lt;/h2&gt;

&lt;p&gt;Two reasons.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One&lt;/strong&gt;: platform policies drift, and you find out via ranking drops rather than an announcement. LinkedIn made outbound links nofollow around 2016. Twitter went nofollow in 2019. Medium moved to &lt;code&gt;ugc nofollow&lt;/code&gt; around 2023-2024. Quora went nofollow-by-default in 2022. GitHub went nofollow on README external links around 2020. The pattern is one-way — platforms don't go &lt;em&gt;back&lt;/em&gt; to dofollow. Any pre-2023 SEO guide you're reading is potentially wrong about at least one of these.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two&lt;/strong&gt;: if you publish enough content across enough platforms, you lose track. I have 3 Dev.to articles, 2 Quora answers, 34 LinkedIn Company Page posts, 1 Medium republish, and 1 GitHub fork. Manually curling each one every Sunday to check the rel attribute is real work. Encoding it into the analytics script — 40 lines of PowerShell — means it becomes a green/red row on the weekly report, and I only pay attention when something changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Steal the pattern
&lt;/h2&gt;

&lt;p&gt;The full script lives in &lt;a href="https://github.com/chetan00118/IndiaTaxPlanner/blob/main/analytics/fetch-analytics.ps1" rel="noopener noreferrer"&gt;SmartTaxCalc's analytics folder&lt;/a&gt; (search for &lt;code&gt;BACKLINK REL-ATTRIBUTE VERIFIER&lt;/code&gt;). It runs alongside GSC / GA4 / Bing / PageSpeed fetches — one script, one double-click, all your marketing signals in one place.&lt;/p&gt;

&lt;p&gt;Two changes to make it fit your setup:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Change &lt;code&gt;$config.siteUrl&lt;/code&gt; and &lt;code&gt;$config.knownReferringUrls&lt;/code&gt; to your domain + your list&lt;/li&gt;
&lt;li&gt;Depending on your platform mix, you might want to add retry logic for the 403-heavy platforms — I chose to just report them as &lt;code&gt;Unknown&lt;/code&gt; because I already know their policies&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you're an indie hacker or solo founder shipping content to multiple platforms and hoping some of them count toward your SEO signal, run this once a week and stop being surprised.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm &lt;a href="https://smarttaxcalc.in/about/" rel="noopener noreferrer"&gt;Chetan Sanghani&lt;/a&gt;, building &lt;a href="https://smarttaxcalc.in" rel="noopener noreferrer"&gt;SmartTaxCalc.in&lt;/a&gt; — a free, browser-based Indian tax + finance calculator platform. Previously wrote about &lt;a href="https://dev.to/chetansanghani/googles-mathsolver-json-ld-validator-is-stricter-than-schemaorg-i-learned-that-the-hard-way-over-3eb0"&gt;Google's MathSolver JSON-LD validator quirks&lt;/a&gt;, &lt;a href="https://dev.to/chetansanghani/how-i-got-lcp-under-2s-on-a-38-page-blazor-webassembly-site-cloudflare-pages-4hec"&gt;dropping mobile LCP under 2s on Blazor WASM&lt;/a&gt;, and &lt;a href="https://dev.to/chetansanghani/a-self-cleaning-product-hunt-teaser-banner-in-blazor-wasm-100-lines-auto-hides-after-launch-186p"&gt;a self-cleaning Product Hunt teaser banner&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>automation</category>
      <category>programming</category>
      <category>seo</category>
      <category>showdev</category>
    </item>
    <item>
      <title>A self-cleaning Product Hunt teaser banner in Blazor WASM — 100 lines, auto-hides after launch, GA4-tracked</title>
      <dc:creator>Chetan Sanghani</dc:creator>
      <pubDate>Mon, 06 Jul 2026 15:47:16 +0000</pubDate>
      <link>https://dev.to/chetansanghani/a-self-cleaning-product-hunt-teaser-banner-in-blazor-wasm-100-lines-auto-hides-after-launch-186p</link>
      <guid>https://dev.to/chetansanghani/a-self-cleaning-product-hunt-teaser-banner-in-blazor-wasm-100-lines-auto-hides-after-launch-186p</guid>
      <description>&lt;p&gt;I'm launching &lt;a href="https://smarttaxcalc.in" rel="noopener noreferrer"&gt;SmartTaxCalc.in&lt;/a&gt; on Product Hunt on &lt;strong&gt;Tuesday, 14 July 2026&lt;/strong&gt;. It's a 38-tool Blazor WebAssembly tax + finance calculator I've written about here before (&lt;a href="https://dev.to/chetansanghani/googles-mathsolver-json-ld-validator-is-stricter-than-schemaorg-i-learned-that-the-hard-way-over-3eb0"&gt;the SEO/schema saga&lt;/a&gt;, and &lt;a href="https://dev.to/chetansanghani/how-i-got-lcp-under-2s-on-a-38-page-blazor-webassembly-site-cloudflare-pages-4hec"&gt;dropping mobile LCP from 6-8s to under 2s&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;The Product Hunt launch algorithm heavily rewards products that arrive with a real coming-soon follower base — day-of upvotes correlate strongly with pre-launch "Notify me" clicks. My PH page started with &lt;strong&gt;1 follower&lt;/strong&gt;. I had 9 days to get to 50+.&lt;/p&gt;

&lt;p&gt;The obvious answer: post on LinkedIn, ask friends, DM your network. All of that has ceilings (you can only ask a favor once). The non-obvious answer that has no ceiling: &lt;strong&gt;convert your own organic search traffic&lt;/strong&gt; into PH followers automatically.&lt;/p&gt;

&lt;p&gt;This is the ~100 lines of Blazor code that does that, plus the design decisions I made along the way. It's also self-cleaning — after the launch date, the banner disappears with no manual work required. Steal the pattern for your own launch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;SmartTaxCalc gets modest but real organic traffic — mostly from Google Search Console impressions on tax-season queries. That traffic is the warmest possible audience for a PH launch (they already found the site, they're in the target demo). But how do you route them to a PH page without:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Disrupting the tax content (they came for a tax calculator, not a marketing pitch)&lt;/li&gt;
&lt;li&gt;Cannibalizing the existing tax-season banner (which drives users to /tax-calendar/ — a real retention lever)&lt;/li&gt;
&lt;li&gt;Leaving code debt after 14 July (a dead PH banner still on the site in September)&lt;/li&gt;
&lt;li&gt;Losing the dismiss preference across page navigations (SPA reality — no page refresh)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Those constraints ruled out a modal, a full-width interrupt, and a "hardcoded remove after launch" approach.&lt;/p&gt;

&lt;h2&gt;
  
  
  The design
&lt;/h2&gt;

&lt;p&gt;Slim horizontal bar at the top of every page. Sits ABOVE the existing tax-season banner. PH-brand orange, different from the tax-season banner's yellow/red so both are visually distinguishable when stacked. Dismissible per-user via &lt;code&gt;localStorage&lt;/code&gt;. Auto-hides after a hard kill date. GA4 events on click + dismiss for measurement.&lt;/p&gt;

&lt;p&gt;Structurally, it mirrors the existing &lt;code&gt;TaxSeasonBanner.razor&lt;/code&gt; conventions (same DOM shape, same a11y attributes) so the two components have consistent tab-order and screen-reader behavior when both are visible.&lt;/p&gt;

&lt;h2&gt;
  
  
  The component
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Shared/ProductHuntTeaserBanner.razor&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@inject IJSRuntime JS

@if (Visible &amp;amp;&amp;amp; !Dismissed)
{
    &amp;lt;aside class="ph-teaser-banner" role="complementary"
           aria-label="Product Hunt launch teaser"&amp;gt;
        &amp;lt;div class="ph-teaser-banner-content"&amp;gt;
            &amp;lt;span class="ph-teaser-banner-icon" aria-hidden="true"&amp;gt;🚀&amp;lt;/span&amp;gt;
            &amp;lt;span class="ph-teaser-banner-text"&amp;gt;
                &amp;lt;strong&amp;gt;Launching on Product Hunt Tue 14 July&amp;lt;/strong&amp;gt;
                &amp;lt;span class="ph-teaser-banner-sub"&amp;gt;Follow to get notified when we go live.&amp;lt;/span&amp;gt;
            &amp;lt;/span&amp;gt;
            &amp;lt;a class="ph-teaser-banner-cta"
               href="https://www.producthunt.com/products/smarttaxcalc"
               target="_blank"
               rel="noopener"
               @onclick="OnCtaClicked"&amp;gt;
                Notify me on PH →
            &amp;lt;/a&amp;gt;
            &amp;lt;button class="ph-teaser-banner-close"
                    @onclick="OnDismissClicked"
                    aria-label="Dismiss Product Hunt teaser"
                    type="button"&amp;gt;&amp;amp;times;&amp;lt;/button&amp;gt;
        &amp;lt;/div&amp;gt;
    &amp;lt;/aside&amp;gt;
}

@code {
    // Kill date: banner stops showing after this instant regardless of dismissal state.
    // 2026-07-14 23:59 IST = 18:29 UTC — one full day AFTER launch so day-of visitors
    // see it, but 07-15 onwards is clean.
    private static readonly DateTime KillDateUtc =
        new DateTime(2026, 7, 14, 18, 29, 0, DateTimeKind.Utc);

    private const string DismissKey = "ph-teaser-dismissed-2026-07-14";

    private bool Visible;
    private bool Dismissed;

    protected override void OnInitialized()
    {
        // Server-side timing gate. If we're past the kill date, don't render at all —
        // no localStorage check needed, no re-render churn, no bytes shipped.
        Visible = DateTime.UtcNow &amp;lt; KillDateUtc;
    }

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender &amp;amp;&amp;amp; Visible)
        {
            try
            {
                var dismissed = await JS.InvokeAsync&amp;lt;string?&amp;gt;(
                    "localStorage.getItem", DismissKey);
                if (dismissed == "1")
                {
                    Dismissed = true;
                    StateHasChanged();
                }
            }
            catch
            {
                // localStorage unavailable (private mode, edge cases) — banner just shows.
                // Failing "open" is better than failing "hidden" for a follower-growth tool.
            }
        }
    }

    private async Task OnCtaClicked()
    {
        try
        {
            await JS.InvokeVoidAsync("trackEvent", "ph_teaser_cta_clicked",
                new { launch = "2026-07-14" });
        }
        catch { }
    }

    private async Task OnDismissClicked()
    {
        Dismissed = true;
        try
        {
            await JS.InvokeVoidAsync("localStorage.setItem", DismissKey, "1");
            await JS.InvokeVoidAsync("trackEvent", "ph_teaser_dismissed",
                new { launch = "2026-07-14" });
        }
        catch { }
        StateHasChanged();
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole thing. About 90 lines of C# + Razor including comments and whitespace.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design decisions worth calling out
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Kill-date is server-side, dismiss is client-side
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;OnInitialized&lt;/code&gt; gate runs in Blazor's render tree before any HTML is emitted. After 2026-07-14 18:29 UTC, the whole &lt;code&gt;&amp;lt;aside&amp;gt;&lt;/code&gt; never renders — no wasted bytes, no dead DOM, no need for the JS to check anything.&lt;/p&gt;

&lt;p&gt;The dismiss check has to be client-side because it depends on the user's &lt;code&gt;localStorage&lt;/code&gt;. So it lives in &lt;code&gt;OnAfterRenderAsync&lt;/code&gt; and updates via &lt;code&gt;StateHasChanged&lt;/code&gt; if the user previously dismissed.&lt;/p&gt;

&lt;p&gt;This split matters. If I'd tried to do the kill-date check client-side too, I'd have shipped the banner HTML to every user post-launch, then hidden it with JS — wasteful and briefly visible during hydration. Server-side gate is free.&lt;/p&gt;

&lt;h3&gt;
  
  
  localStorage key is versioned by launch
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;DismissKey&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"ph-teaser-dismissed-2026-07-14"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;-2026-07-14&lt;/code&gt; suffix means my next PH launch (if I do a re-launch or feature launch later) uses a different key — users who dismissed the July teaser will still see the future teaser. No dismiss-forever bug.&lt;/p&gt;

&lt;h3&gt;
  
  
  Failing open, not closed
&lt;/h3&gt;

&lt;p&gt;The try/catch around &lt;code&gt;localStorage.getItem&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;catch&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// localStorage unavailable (private mode, edge cases) — banner just shows.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If localStorage throws (Safari private mode, ITP restrictions, some enterprise browsers), we DON'T hide the banner — we show it. For a follower-growth tool, false-positive display beats false-negative hiding.&lt;/p&gt;

&lt;h3&gt;
  
  
  Kill date uses UTC everywhere
&lt;/h3&gt;

&lt;p&gt;The Cloudflare Pages server runs in UTC. The user's browser could be in any timezone. Blazor WASM runs in the browser but I'm doing the check at component initialization which happens after WASM boots — timezone at that point depends on how Blazor was invoked. Sticking with &lt;code&gt;DateTime.UtcNow&lt;/code&gt; + a UTC kill-date instant sidesteps every possible drift.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CSS — matching existing patterns
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.ph-teaser-banner&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;linear-gradient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;135deg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;#ff6b35&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;#da552f&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c"&gt;/* PH brand orange */&lt;/span&gt;
    &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#ffffff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.5rem&lt;/span&gt; &lt;span class="m"&gt;1rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.9rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;line-height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1.35&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;border-bottom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.ph-teaser-banner-content&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;max-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1200px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="nb"&gt;auto&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;align-items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;center&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="py"&gt;gap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.75rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;flex-wrap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;wrap&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.ph-teaser-banner-cta&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#ffffff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;font-weight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;700&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.35rem&lt;/span&gt; &lt;span class="m"&gt;0.75rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;border-radius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;solid&lt;/span&gt; &lt;span class="n"&gt;rgba&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0.35&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nl"&gt;min-height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;32px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;inline-flex&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;align-items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;center&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.ph-teaser-banner-close&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;transparent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;inherit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;font-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1.5rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;pointer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;0.25rem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.75&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;min-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;32px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;min-height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;32px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.dark-mode&lt;/span&gt; &lt;span class="nc"&gt;.ph-teaser-banner&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;linear-gradient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;135deg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;#c94820&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;#a83a1b&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things worth noting:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;32×32 minimum touch targets&lt;/strong&gt; on the CTA and dismiss button (WCAG 2.5.5 — Success Criterion for touch target size). This is easy to forget on slim horizontal bars where the visual height is smaller. The button padding + &lt;code&gt;min-height: 32px&lt;/code&gt; guarantees the tap area meets the standard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dark-mode variant&lt;/strong&gt; because SmartTaxCalc has a full dark-mode toggle. Any new component that ships without a &lt;code&gt;.dark-mode .foo&lt;/code&gt; variant looks jarring for the ~30% of users on dark mode.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring into layout
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Layout/MainLayout.razor&lt;/code&gt; — one line, above the existing tax-season banner:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;header class="app-header"&amp;gt;...&amp;lt;/header&amp;gt;

@* PH launch teaser — active 2026-07-05 → 2026-07-14 EOD IST *@
&amp;lt;ProductHuntTeaserBanner /&amp;gt;

@* Tax-season-aware banner — May-Jul ITR, Dec advance tax, Mar year-end *@
&amp;lt;TaxSeasonBanner /&amp;gt;

&amp;lt;main class="main-content"&amp;gt;
    @Body
&amp;lt;/main&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stacking order matters. PH banner is time-critical (9-day window), tax banner is a permanent retention CTA. Time-critical goes on top.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cache-buster gotcha
&lt;/h2&gt;

&lt;p&gt;Cloudflare Pages caches CSS aggressively. Deploying the new CSS with the same filename means returning users don't see the new styles until their browser cache expires (often hours or days).&lt;/p&gt;

&lt;p&gt;Every CSS/JS reference on the site uses a query-string version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/css/app.css?v=20260705a"&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"stylesheet"&lt;/span&gt; &lt;span class="na"&gt;fetchpriority=&lt;/span&gt;&lt;span class="s"&gt;"high"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"preload"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/_framework/blazor.webassembly.js?v=20260620j"&lt;/span&gt; &lt;span class="na"&gt;as=&lt;/span&gt;&lt;span class="s"&gt;"script"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On any deploy that ships new CSS, bump the &lt;code&gt;v=&lt;/code&gt; string. I ran this one-liner to update every static HTML file in the wwwroot mirror:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;perl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-i&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-pe&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'s|app\.css\?v=20260703a|app.css?v=20260705a|g'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;grep&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-rlE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'app\.css\?v=20260703a'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;frontend/web/TaxPlanner.Web/wwwroot/&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Skipping this step meant users on returning-visit cache would see the banner in DOM (Blazor renders it) but with &lt;strong&gt;no styles&lt;/strong&gt; — invisible text/button on a normal background. Nothing worse than a launch banner that only works for first-visit users.&lt;/p&gt;

&lt;h2&gt;
  
  
  The telemetry
&lt;/h2&gt;

&lt;p&gt;Two GA4 events I care about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ph_teaser_cta_clicked&lt;/code&gt; — fires when the user clicks "Notify me". This is the metric that matters. It correlates directly with PH follower growth.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ph_teaser_dismissed&lt;/code&gt; — fires when the user clicks the ×. If this is &amp;gt;30% of impressions, the banner is annoying and I should tune it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I don't track banner impressions separately — Blazor renders it if &lt;code&gt;OnInitialized&lt;/code&gt; allows AND the user hasn't dismissed. Impression count = pageview count from GA4 minus &lt;code&gt;ph_teaser_dismissed&lt;/code&gt; count minus post-kill-date pageviews.&lt;/p&gt;

&lt;h2&gt;
  
  
  Realistic conversion math
&lt;/h2&gt;

&lt;p&gt;Here's the funnel math I set targets against:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Daily organic pageviews on SmartTaxCalc: ~50-80 (Indian tax queries)&lt;/li&gt;
&lt;li&gt;Percent of pageviews that click Notify me: 2-5% (informed guess; will measure)&lt;/li&gt;
&lt;li&gt;Expected daily new followers: 1-4 from the banner alone&lt;/li&gt;
&lt;li&gt;Nine days of banner active pre-launch: 9-36 followers&lt;/li&gt;
&lt;li&gt;Combined with LinkedIn + personal DMs: target 50+&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;50 pre-launch followers is the informal PH threshold for "you have a shot at Top 20 of the day." Below 20 and the launch is dead on arrival. Above 100 and you're guaranteed Top 10 barring a bad launch-day thumbnail.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I skipped
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Modal/interrupt version&lt;/strong&gt;: kills UX for the tax content (users came for a calculator, not a launch pitch)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cookie banner-style at page bottom&lt;/strong&gt;: lower click-through, competes visually with the actual cookie consent banner&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Homepage-only version&lt;/strong&gt;: 60% of my traffic lands on deep pages (calculator URLs from Google), not the homepage&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Manual removal after launch&lt;/strong&gt;: not scalable if I do another launch later — kill-date automates the "remove" step&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Two takeaways
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Time-bounded UI needs a self-cleaning mechanism from day one.&lt;/strong&gt; Every "temporary" banner I've seen without a kill date has ended up living on production long past its usefulness. The kill-date constant is 3 lines of code and saves you a manual removal PR.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Convert your own traffic before you buy other people's.&lt;/strong&gt; LinkedIn ads, PH-influencer shoutouts, upvote-swap groups — all lower conversion than your own site visitors who already trust you. If you're launching anything, this component (or the equivalent in your stack) is the highest-leverage growth code you can ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  The launch itself
&lt;/h2&gt;

&lt;p&gt;If you use tax calculators or you're a founder / indie hacker who wants to follow the launch curve, &lt;a href="https://www.producthunt.com/products/smarttaxcalc" rel="noopener noreferrer"&gt;click Notify me on our PH page&lt;/a&gt;. Launch is &lt;strong&gt;Tuesday 14 July 2026 at 12:01 AM PDT (12:31 PM IST)&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If this banner pattern is useful, feel free to lift it. Blazor + Razor + a few lines of CSS is all it takes.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;About me&lt;/strong&gt;: I'm Chetan Sanghani — I build &lt;a href="https://smarttaxcalc.in" rel="noopener noreferrer"&gt;SmartTaxCalc&lt;/a&gt;, a free CA-reviewed Indian tax calculator platform. This is my third article on Dev.to in the "Building SmartTaxCalc" series. Previous posts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/chetansanghani/googles-mathsolver-json-ld-validator-is-stricter-than-schemaorg-i-learned-that-the-hard-way-over-3eb0"&gt;Google's MathSolver JSON-LD validator is stricter than schema.org — I learned that the hard way over 4 rejections&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/chetansanghani/how-i-got-lcp-under-2s-on-a-38-page-blazor-webassembly-site-cloudflare-pages-4hec"&gt;How I got LCP under 2s on a 38-page Blazor WebAssembly site (Cloudflare Pages)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>analytics</category>
      <category>dotnet</category>
      <category>marketing</category>
      <category>showdev</category>
    </item>
    <item>
      <title>How I got LCP under 2s on a 38-page Blazor WebAssembly site (Cloudflare Pages)</title>
      <dc:creator>Chetan Sanghani</dc:creator>
      <pubDate>Sat, 04 Jul 2026 11:06:06 +0000</pubDate>
      <link>https://dev.to/chetansanghani/how-i-got-lcp-under-2s-on-a-38-page-blazor-webassembly-site-cloudflare-pages-4hec</link>
      <guid>https://dev.to/chetansanghani/how-i-got-lcp-under-2s-on-a-38-page-blazor-webassembly-site-cloudflare-pages-4hec</guid>
      <description>&lt;p&gt;I run &lt;a href="https://smarttaxcalc.in" rel="noopener noreferrer"&gt;SmartTaxCalc.in&lt;/a&gt; — a free Indian tax calculator platform built as a Blazor WebAssembly SPA on Cloudflare Pages. 38 tools, 20+ blog articles, all client-side. Very static, very cheap to host, easy to iterate.&lt;/p&gt;

&lt;p&gt;For the first several months, mobile Largest Contentful Paint (LCP) was &lt;strong&gt;6-8 seconds&lt;/strong&gt;. That's failing Core Web Vitals by a mile. Every Lighthouse run screamed at me. And every SEO agency told me the same three things:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"It's Blazor. It's slow. Rewrite in Next.js."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Yeah, no. I like Blazor. I've shipped .NET for a decade. I don't want to rewrite for the sake of a Lighthouse score.&lt;/p&gt;

&lt;p&gt;So over one weekend I ran a proper performance pass. Six mechanical fixes, six commits, mostly one-liners. By the end of it, mobile LCP was &lt;strong&gt;under 2 seconds&lt;/strong&gt; — well inside the "Good" threshold — on every measured page.&lt;/p&gt;

&lt;p&gt;Here's exactly what worked. Real commit hashes, real numbers, and honest notes on what didn't help.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup — what "LCP on Blazor WASM" actually means
&lt;/h2&gt;

&lt;p&gt;Blazor WebAssembly is a Single Page Application. The initial HTML is a small shell (&lt;code&gt;index.html&lt;/code&gt;) with a &lt;code&gt;&amp;lt;base href="/"&amp;gt;&lt;/code&gt;, some CSS &lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt; tags, and a &lt;code&gt;&amp;lt;script src="_framework/blazor.webassembly.js"&amp;gt;&lt;/code&gt;. That JavaScript downloads the .NET runtime, your app DLLs, and boots the Blazor renderer, which then constructs the DOM.&lt;/p&gt;

&lt;p&gt;LCP is measured on the largest content element rendered above the fold. On a fresh Blazor site, that element usually doesn't exist until Blazor finishes booting and renders your first component. So "LCP" and "time-to-Blazor-hydrated" tend to be the same thing.&lt;/p&gt;

&lt;p&gt;That said — a well-architected Blazor site with static HTML mirrors (pre-rendered content) can have LCP fire on the static content BEFORE Blazor hydrates, using Blazor purely for interactivity. That's the model my site uses.&lt;/p&gt;

&lt;p&gt;So the LCP battle here is really a "get the browser to render the largest text/image element as fast as possible" battle. Blazor still boots, but LCP doesn't wait for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before numbers — Lighthouse mobile, throttled 4G, Moto G4 CPU
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Page&lt;/th&gt;
&lt;th&gt;Mobile LCP (before)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Homepage&lt;/td&gt;
&lt;td&gt;~6.2s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tax calculator (deep interactive page)&lt;/td&gt;
&lt;td&gt;~7.8s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blog article (mostly static content)&lt;/td&gt;
&lt;td&gt;~5.9s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 6-8s range is what you get when you serve unminified CSS, ship a service worker that races the main thread, and don't preconnect to your CDN edges.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 1: Delete the service worker (commit &lt;code&gt;910c606&lt;/code&gt;)
&lt;/h2&gt;

&lt;p&gt;The counter-intuitive one.&lt;/p&gt;

&lt;p&gt;I originally shipped a service worker in June 2026 to fix a different bug (stale HTML cache after a build-tool migration). The SW's activate handler would detect its own version, unregister itself, and clear all caches. It was a one-time cleanup for existing users who were pinned to the old broken cache.&lt;/p&gt;

&lt;p&gt;Two weeks later, every recurring visitor had had that cleanup fire. &lt;strong&gt;New visitors didn't need a service worker at all&lt;/strong&gt; — but I was still registering one on every page load.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before — every page did this&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;serviceWorker&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hostname&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;localhost&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;serviceWorker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/service-worker.js?v=cleanup-20260613&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;updateViaCache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
    &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Removing that block saved:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One network request (&lt;code&gt;/service-worker.js&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;~50-100ms of post-load main-thread work as the browser evaluates the SW script&lt;/li&gt;
&lt;li&gt;One CPU spike that competed with Blazor's own boot&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Impact&lt;/strong&gt;: ~200ms LCP win.&lt;/p&gt;

&lt;p&gt;I left the &lt;code&gt;service-worker.js&lt;/code&gt; file in &lt;code&gt;wwwroot&lt;/code&gt; for now — old bookmarks that had registered the SW will still fetch it and run the self-unregister logic. That's a slow-decay migration, safe to leave.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 2: Removed a version-check IIFE that force-reloaded users (commit &lt;code&gt;910c606&lt;/code&gt;)
&lt;/h2&gt;

&lt;p&gt;This one hurt when I found it.&lt;/p&gt;

&lt;p&gt;Buried in &lt;code&gt;index.html&lt;/code&gt; was a small self-invoking function that read a &lt;code&gt;version&lt;/code&gt; field from &lt;code&gt;/version.json&lt;/code&gt;, compared it to a value stored in &lt;code&gt;localStorage&lt;/code&gt;, and if they mismatched, called &lt;code&gt;location.reload(true)&lt;/code&gt; — a hard reload.&lt;/p&gt;

&lt;p&gt;The intent was noble: force existing users to fetch new HTML after each deploy so they'd never see stale content. But the effect on a first-visit user was:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Browser fetches &lt;code&gt;index.html&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Browser starts parsing → hits the IIFE&lt;/li&gt;
&lt;li&gt;IIFE fetches &lt;code&gt;/version.json&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;IIFE reads &lt;code&gt;localStorage&lt;/code&gt;, finds no version stored → sets &lt;code&gt;oldVersion = null&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;IIFE sees &lt;code&gt;null !== '20260620j'&lt;/code&gt; → calls &lt;code&gt;location.reload(true)&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Browser tears down the entire page and starts over.&lt;/strong&gt; LCP timer resets.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The first-visit user was silently reloading their own page before LCP could fire. Which meant their LCP was measured on the SECOND load, but the timer count-up started from the FIRST navigation. Result: catastrophic LCP.&lt;/p&gt;

&lt;p&gt;Deleted the IIFE entirely. LCP-first-visit dropped by an estimated 2 seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Impact&lt;/strong&gt;: ~2000ms LCP win on first visits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 3: Preconnect coverage on 7 pages that were missing (commit &lt;code&gt;910c606&lt;/code&gt;)
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;&amp;lt;link rel="preconnect"&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;link rel="dns-prefetch"&amp;gt;&lt;/code&gt; don't do anything visible, but on a slow mobile connection they save hundreds of milliseconds by opening DNS + TCP + TLS connections to third-party origins BEFORE the browser actually needs them.&lt;/p&gt;

&lt;p&gt;I had preconnect hints on the homepage and some tools, but 7 static HTML mirrors were missing them entirely. Namely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"preconnect"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://www.googletagmanager.com"&lt;/span&gt; &lt;span class="na"&gt;crossorigin&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"preconnect"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://pagead2.googlesyndication.com"&lt;/span&gt; &lt;span class="na"&gt;crossorigin&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"dns-prefetch"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://adservice.google.com"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"dns-prefetch"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://ep1.adtrafficquality.google"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are the origins my site talks to for analytics + AdSense (site-wide). Without preconnect, the browser has to do the DNS lookup + TCP handshake + TLS negotiation only when the first request to those origins fires — which is AFTER my HTML is fully parsed.&lt;/p&gt;

&lt;p&gt;With preconnect, those handshakes happen IN PARALLEL with HTML parsing. On a 4G mobile connection, that's ~300-500ms saved.&lt;/p&gt;

&lt;p&gt;Added the four &lt;code&gt;preconnect&lt;/code&gt;/&lt;code&gt;dns-prefetch&lt;/code&gt; lines to every static HTML mirror. Nothing fancy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Impact&lt;/strong&gt;: ~300ms LCP win on cold pageviews.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 4: Cache-buster pattern for CSS/JS (commit &lt;code&gt;a9255e8&lt;/code&gt;)
&lt;/h2&gt;

&lt;p&gt;Not a direct LCP fix, but a prerequisite for all the others actually reaching users.&lt;/p&gt;

&lt;p&gt;If your CSS is at &lt;code&gt;/css/app.css&lt;/code&gt; and you update it, browsers and CDN edges will keep serving the old content — because &lt;code&gt;/css/app.css&lt;/code&gt; is the same URL as yesterday. Cache poisoning.&lt;/p&gt;

&lt;p&gt;Standard fix: append a version query string that changes on every deploy: &lt;code&gt;/css/app.css?v=20260703a&lt;/code&gt;. When the version changes, the URL changes, and every cache treats it as a new resource.&lt;/p&gt;

&lt;p&gt;Baked this into every HTML mirror:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"preload"&lt;/span&gt; &lt;span class="na"&gt;as=&lt;/span&gt;&lt;span class="s"&gt;"style"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/css/app.css?v=20260703a"&lt;/span&gt; &lt;span class="na"&gt;fetchpriority=&lt;/span&gt;&lt;span class="s"&gt;"high"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/css/app.css?v=20260703a"&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"stylesheet"&lt;/span&gt; &lt;span class="na"&gt;fetchpriority=&lt;/span&gt;&lt;span class="s"&gt;"high"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"preload"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/_framework/blazor.webassembly.js?v=20260620j"&lt;/span&gt; &lt;span class="na"&gt;as=&lt;/span&gt;&lt;span class="s"&gt;"script"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also added &lt;code&gt;fetchpriority="high"&lt;/code&gt; on the CSS &lt;code&gt;&amp;lt;link&amp;gt;&lt;/code&gt; — a browser hint that tells Chrome to prioritize this request over async scripts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Impact&lt;/strong&gt;: without this, none of the other CSS/JS fixes would deploy reliably. LCP win: indirect but load-bearing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 5: Cloudflare Brotli edge compression
&lt;/h2&gt;

&lt;p&gt;This one you don't code — you enable it in the Cloudflare dashboard. But it's the biggest single win of the six.&lt;/p&gt;

&lt;p&gt;My app.css is 131KB raw. That's not small. GZIP compresses it to ~40KB on the wire (typical text ratio 3:1). Brotli — Cloudflare's default compression for modern browsers — takes it to &lt;strong&gt;~24KB&lt;/strong&gt; (ratio 5.5:1).&lt;/p&gt;

&lt;p&gt;For a slow 4G mobile connection at 1 Mbps effective throughput:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;131KB raw = ~1050ms transfer time&lt;/li&gt;
&lt;li&gt;40KB gzip = ~320ms transfer time&lt;/li&gt;
&lt;li&gt;24KB brotli = ~192ms transfer time&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's a ~700ms LCP win on the render-blocking CSS alone. FREE. Just toggle Brotli in Cloudflare Speed → Optimization.&lt;/p&gt;

&lt;p&gt;If you're on Cloudflare and Brotli isn't on, this is your fastest-possible perf win. Zero code changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Impact&lt;/strong&gt;: ~700ms LCP win.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 6: Blazor WASM bundle size — &lt;code&gt;PublishTrimmed&lt;/code&gt; + linking + InvariantGlobalization
&lt;/h2&gt;

&lt;p&gt;The .NET runtime + your app DLLs downloaded on first visit is normally 3-5MB of WASM. That's a lot for a mobile connection. Three project-file switches shrink it significantly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;PropertyGroup&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;PublishTrimmed&amp;gt;&lt;/span&gt;true&lt;span class="nt"&gt;&amp;lt;/PublishTrimmed&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;BlazorWebAssemblyEnableLinking&amp;gt;&lt;/span&gt;true&lt;span class="nt"&gt;&amp;lt;/BlazorWebAssemblyEnableLinking&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;InvariantGlobalization&amp;gt;&lt;/span&gt;true&lt;span class="nt"&gt;&amp;lt;/InvariantGlobalization&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/PropertyGroup&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;PublishTrimmed&lt;/code&gt; — .NET's IL linker strips unused types. Removes everything from mscorlib you're not calling. Typically 50-70% size reduction.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;BlazorWebAssemblyEnableLinking&lt;/code&gt; — same idea but Blazor-specific.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;InvariantGlobalization&lt;/code&gt; — removes the ICU globalization data (~1.5MB). Only viable if you don't need locale-aware string formatting. For a tax calculator that runs in one language, safe.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Combined effect on my site: Blazor bundle dropped from ~5MB to ~1.8MB. That's a big deal on first-visit mobile.&lt;/p&gt;

&lt;p&gt;Note: this doesn't affect LCP directly IF your static HTML already renders the content (which mine does). But it dramatically speeds up time-to-interactive, which affects INP (Interaction to Next Paint) — the other Core Web Vital.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Impact&lt;/strong&gt;: 200-400ms INP win + faster hydration.&lt;/p&gt;

&lt;h2&gt;
  
  
  After numbers — Lighthouse mobile, same throttling profile
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Page&lt;/th&gt;
&lt;th&gt;LCP (before)&lt;/th&gt;
&lt;th&gt;LCP (after)&lt;/th&gt;
&lt;th&gt;Delta&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Homepage&lt;/td&gt;
&lt;td&gt;6.2s&lt;/td&gt;
&lt;td&gt;1.84s&lt;/td&gt;
&lt;td&gt;-4.36s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Currency converter&lt;/td&gt;
&lt;td&gt;7.1s&lt;/td&gt;
&lt;td&gt;1.21s&lt;/td&gt;
&lt;td&gt;-5.89s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Salary tax guide&lt;/td&gt;
&lt;td&gt;5.9s&lt;/td&gt;
&lt;td&gt;1.56s&lt;/td&gt;
&lt;td&gt;-4.34s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tax calendar&lt;/td&gt;
&lt;td&gt;5.5s&lt;/td&gt;
&lt;td&gt;1.55s&lt;/td&gt;
&lt;td&gt;-3.95s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HRA calculator&lt;/td&gt;
&lt;td&gt;7.4s&lt;/td&gt;
&lt;td&gt;1.54s&lt;/td&gt;
&lt;td&gt;-5.86s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every measured page is now under the 2.5s "Good" LCP threshold. Most are under 2s.&lt;/p&gt;

&lt;p&gt;The homepage cut ~4.4 seconds from real user-facing render time with 6 commits of mechanical work. No framework rewrite. Blazor is still Blazor.&lt;/p&gt;

&lt;h2&gt;
  
  
  What did NOT help (I'm being honest)
&lt;/h2&gt;

&lt;p&gt;Not everything I tried worked. Skip these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Minifying CSS manually&lt;/strong&gt;. Cloudflare's Brotli already compresses better than any hand-minifier. Diminishing returns.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lazy-loading below-fold sections with &lt;code&gt;content-visibility: auto&lt;/code&gt;&lt;/strong&gt;. Improved paint but caused CLS (layout shifts) that hurt the CLS Core Web Vital more than the LCP improvement helped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Removing Google Analytics 4&lt;/strong&gt;. Yes, GA4 slows things down. No, removing it is not viable for a site that needs to measure user behavior. Async loading is enough — the perf cost is real but small (~50-100ms), and the observability win is huge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom preloading of individual DLLs&lt;/strong&gt;. Blazor's default boot flow is already tuned. My hand-crafted preloads made things worse.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Two takeaways for anyone doing this
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Delete more than you add.&lt;/strong&gt; My biggest wins were removing things — service workers, version-check IIFEs, unused globalization data. Every one of those was "added carefully to fix a specific bug last month" and had turned into dead weight. Audit your legacy carefully.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The CDN dashboard is code.&lt;/strong&gt; Toggling Brotli in Cloudflare beat every code-level optimization I did. Don't skip the "settings you can change without deploying" — they're often the fastest win.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result
&lt;/h2&gt;

&lt;p&gt;Site: &lt;a href="https://smarttaxcalc.in" rel="noopener noreferrer"&gt;smarttaxcalc.in&lt;/a&gt; — free Indian income tax calculator, 38 tools, Blazor WASM on Cloudflare Pages.&lt;/p&gt;

&lt;p&gt;Mobile Core Web Vitals: all Good.&lt;br&gt;&lt;br&gt;
Framework: unchanged. Still Blazor.&lt;br&gt;&lt;br&gt;
Total code changed: ~15 lines across 6 commits.&lt;/p&gt;

&lt;p&gt;If you're on Blazor WASM and hitting the "just rewrite it" wall from external advice, try the six mechanical fixes above first. Most of them are one-line changes. Blazor is faster than it gets credit for — you just need to stop actively slowing it down.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;About me&lt;/strong&gt;: I'm Chetan Sanghani — I build &lt;a href="https://smarttaxcalc.in" rel="noopener noreferrer"&gt;SmartTaxCalc&lt;/a&gt;, a free CA-reviewed Indian tax calculator platform. I write about frontend performance, SEO for JavaScript apps, and building small-team B2C products on .NET. Previous article: &lt;a href="https://dev.to/chetansanghani/googles-mathsolver-json-ld-validator-is-stricter-than-schemaorg-i-learned-that-the-hard-way-over-3eb0"&gt;Google's MathSolver JSON-LD validator is stricter than schema.org — I learned that the hard way over 4 rejections&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>performance</category>
      <category>seo</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Google's MathSolver JSON-LD validator is stricter than schema.org. I learned that the hard way over 4 rounds of fixes.</title>
      <dc:creator>Chetan Sanghani</dc:creator>
      <pubDate>Thu, 02 Jul 2026 16:10:46 +0000</pubDate>
      <link>https://dev.to/chetansanghani/googles-mathsolver-json-ld-validator-is-stricter-than-schemaorg-i-learned-that-the-hard-way-over-3eb0</link>
      <guid>https://dev.to/chetansanghani/googles-mathsolver-json-ld-validator-is-stricter-than-schemaorg-i-learned-that-the-hard-way-over-3eb0</guid>
      <description>&lt;p&gt;Last week, Google Search Console flagged 10 pages on my Indian tax calculator suite with structured-data errors:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Missing field &lt;code&gt;usageInfo&lt;/code&gt; — 10 items&lt;/li&gt;
&lt;li&gt;Invalid value in field &lt;code&gt;itemtype&lt;/code&gt; — 10 items&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pages all had &lt;code&gt;MathSolver&lt;/code&gt; JSON-LD blocks. I had 38 similar blocks across the site. The natural assumption: I made a schema mistake once, and it propagated to all 38 tools.&lt;/p&gt;

&lt;p&gt;Four commits and 76 files later, I discovered the real problem — and it wasn't a schema mistake at all.&lt;/p&gt;

&lt;p&gt;Here's the full journey, with commit hashes, so you can skip the six hours I spent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup: what the schema looked like
&lt;/h2&gt;

&lt;p&gt;For context, &lt;a href="https://smarttaxcalc.in" rel="noopener noreferrer"&gt;SmartTaxCalc.in&lt;/a&gt; is a suite of 38 free Indian tax calculators (HRA, Advance Tax, NRI residency, DTAA, RSU vesting, etc.). Every tool page had a &lt;code&gt;MathSolver&lt;/code&gt; JSON-LD block that looked roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"@context"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://schema.org"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"@type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MathSolver"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Fixed Deposit (FD) Calculator"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://smarttaxcalc.in/tools/fd-calculator/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mathExpression"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"maturity = principal * (1 + rate/(4*100))^(4*tenure_years)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"isAccessibleForFree"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"inLanguage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"en-IN"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mainEntityOfPage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://smarttaxcalc.in/tools/fd-calculator/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"publisher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"@type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Organization"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SmartTaxCalc"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://smarttaxcalc.in"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reviewedBy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"@type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Person"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CA Ankitkumar Italiya"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"identifier"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ICAI:644575"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://smarttaxcalc.in/reviewer/ankitkumar-italiya/"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"citation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"@type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Legislation"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Section 80C and Section 194A of the Income Tax Act, 1961"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"legislationJurisdiction"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"@type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Country"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"India"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"additionalProperty"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"@type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PropertyValue"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Applicable Regime"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Both regimes"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"@type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PropertyValue"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Applicable AYs"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"All AYs"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every field maps to a valid schema.org property. &lt;code&gt;citation&lt;/code&gt; on &lt;code&gt;CreativeWork&lt;/code&gt; (which &lt;code&gt;MathSolver&lt;/code&gt; inherits from) legitimately accepts &lt;code&gt;Legislation&lt;/code&gt; or &lt;code&gt;CreativeWork&lt;/code&gt;. &lt;code&gt;additionalProperty&lt;/code&gt; is &lt;code&gt;PropertyValue&lt;/code&gt;. &lt;code&gt;spatialCoverage&lt;/code&gt;/&lt;code&gt;legislationJurisdiction&lt;/code&gt; on &lt;code&gt;Country&lt;/code&gt; — all valid per &lt;a href="https://schema.org/MathSolver" rel="noopener noreferrer"&gt;schema.org's own docs&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;But Google's validator disagreed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round 1: Fix the obvious (commit &lt;code&gt;32d1a24&lt;/code&gt;)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Hypothesis&lt;/strong&gt;: The GSC error strings gave me two things to fix.&lt;/p&gt;

&lt;p&gt;For "Missing field usageInfo" — Google's &lt;a href="https://developers.google.com/search/docs/appearance/structured-data/math-solvers" rel="noopener noreferrer"&gt;MathSolver docs&lt;/a&gt; mark &lt;code&gt;usageInfo&lt;/code&gt; as required. I hadn't included it. Easy fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gi"&gt;+  "usageInfo": "https://smarttaxcalc.in/methodology/",
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For "Invalid value in field itemtype" — I assumed the &lt;code&gt;citation.@type: Legislation&lt;/code&gt; was the problem. &lt;code&gt;Legislation&lt;/code&gt; is more specific than &lt;code&gt;CreativeWork&lt;/code&gt;; maybe Google's rich-result validator only accepts the broader type in citation slots.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;   "citation": {
&lt;span class="gd"&gt;-    "@type": "Legislation",
&lt;/span&gt;&lt;span class="gi"&gt;+    "@type": "CreativeWork",
&lt;/span&gt;     "name": "Section 80C and Section 194A of the Income Tax Act, 1961",
&lt;span class="gd"&gt;-    "legislationJurisdiction": {
&lt;/span&gt;&lt;span class="gi"&gt;+    "spatialCoverage": {
&lt;/span&gt;       "@type": "Country",
       "name": "India"
     }
   }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Applied to all 38 razor files + 38 static HTML mirrors. 76 files, one commit. Deployed. &lt;strong&gt;Rich Results Test result: still "1 invalid item detected."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Reasonable step, wrong root cause. Time to peel the next layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round 2: Strip the "obviously invalid on MathSolver" properties (commit &lt;code&gt;6311067&lt;/code&gt;)
&lt;/h2&gt;

&lt;p&gt;Second hypothesis: some of the fields I kept aren't valid on &lt;code&gt;MathSolver&lt;/code&gt; at all, even though they were valid schema.org somewhere.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;additionalProperty&lt;/code&gt; — checking schema.org more carefully — is defined on &lt;code&gt;Place&lt;/code&gt;, &lt;code&gt;Product&lt;/code&gt;, &lt;code&gt;QualitativeValue&lt;/code&gt;, &lt;code&gt;QuantitativeValue&lt;/code&gt;, and &lt;code&gt;StatisticalPopulation&lt;/code&gt;. &lt;strong&gt;Not &lt;code&gt;CreativeWork&lt;/code&gt;.&lt;/strong&gt; So &lt;code&gt;MathSolver&lt;/code&gt; (which extends &lt;code&gt;CreativeWork&lt;/code&gt;) shouldn't have &lt;code&gt;additionalProperty&lt;/code&gt; even if I want it there.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;citation.spatialCoverage: Country&lt;/code&gt; — the &lt;code&gt;Country&lt;/code&gt; subtype felt exotic; Google's stricter validator might reject it in favor of the plain &lt;code&gt;Place&lt;/code&gt; type.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;-  "additionalProperty": [
-    { "@type": "PropertyValue", "name": "Applicable Regime", "value": "Both regimes" },
-    { "@type": "PropertyValue", "name": "Applicable AYs", "value": "All AYs" }
-  ]
&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;   "citation": {
     "@type": "CreativeWork",
     "name": "...",
&lt;span class="gd"&gt;-    "spatialCoverage": {
-      "@type": "Country",
-      "name": "India"
-    }
&lt;/span&gt;   }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;76 files again. Deploy. &lt;strong&gt;Rich Results Test result: still "1 invalid item detected."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;At this point I was staring at a &lt;code&gt;MathSolver&lt;/code&gt; block with only &lt;code&gt;publisher.Organization&lt;/code&gt;, &lt;code&gt;reviewedBy.Person&lt;/code&gt;, and &lt;code&gt;citation.CreativeWork&lt;/code&gt; as nested types. The same page had a &lt;code&gt;WebApplication&lt;/code&gt; block validating fine with &lt;code&gt;Organization&lt;/code&gt; + &lt;code&gt;Offer&lt;/code&gt; nested. So &lt;code&gt;Organization&lt;/code&gt;/&lt;code&gt;Person&lt;/code&gt; clearly weren't the problem.&lt;/p&gt;

&lt;p&gt;By elimination, the offender had to be &lt;code&gt;CreativeWork&lt;/code&gt; in citation. Or the &lt;code&gt;MathSolver&lt;/code&gt; @type itself. But I hadn't proven the latter yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round 3: Make citation a plain text string (commit &lt;code&gt;e642939&lt;/code&gt;)
&lt;/h2&gt;

&lt;p&gt;Third hypothesis: any nested &lt;code&gt;@type&lt;/code&gt; at all inside &lt;code&gt;MathSolver&lt;/code&gt;'s &lt;code&gt;citation&lt;/code&gt; gets rejected. schema.org allows &lt;code&gt;citation&lt;/code&gt; to accept &lt;code&gt;Text&lt;/code&gt; OR &lt;code&gt;CreativeWork&lt;/code&gt; — switching to Text kills the last nested @type without losing information.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;-  "citation": {
-    "@type": "CreativeWork",
-    "name": "Section 80C and Section 194A of the Income Tax Act, 1961",
-    "alternateName": "..."
-  }
&lt;/span&gt;&lt;span class="gi"&gt;+  "citation": "Section 80C and Section 194A of the Income Tax Act, 1961"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deploy. Rich Results Test. &lt;strong&gt;Same error. "Invalid value in field itemtype."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Now the parsed MathSolver had only &lt;code&gt;publisher.Organization&lt;/code&gt; + &lt;code&gt;reviewedBy.Person&lt;/code&gt; as nested types — both of which validated fine on the same page's &lt;code&gt;WebApplication&lt;/code&gt; block, and both of which are standard &lt;code&gt;CreativeWork&lt;/code&gt;-inherited properties.&lt;/p&gt;

&lt;p&gt;If a completely spec-clean &lt;code&gt;MathSolver&lt;/code&gt; with only unambiguous nested types still fails validation, the problem isn't the schema. It's the &lt;code&gt;MathSolver&lt;/code&gt; type itself in this context.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round 4: Remove MathSolver entirely (commit &lt;code&gt;64a86db&lt;/code&gt;)
&lt;/h2&gt;

&lt;p&gt;I re-read Google's MathSolver documentation with fresh eyes. The doc opens with:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Math solver structured data helps users find math solver services in Google Search results."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And later:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Math solvers help users solve math problems by generating step-by-step solutions..."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Google MathSolver is a feature-gated schema. It's for &lt;a href="https://www.wolframalpha.com/" rel="noopener noreferrer"&gt;Wolfram Alpha&lt;/a&gt;, &lt;a href="https://photomath.com/" rel="noopener noreferrer"&gt;Photomath&lt;/a&gt;, &lt;a href="https://www.symbolab.com/" rel="noopener noreferrer"&gt;Symbolab&lt;/a&gt; — educational platforms that solve math problems.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A tax calculator suite is not a math solver, no matter how many equations we run.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Google's validator wasn't rejecting a specific field. It was rejecting the eligibility of the entire schema for our use case. The "Invalid value in field itemtype" error was Google's way of saying "this schema doesn't apply here, please stop." I was fighting a feature gate, not a validation bug.&lt;/p&gt;

&lt;p&gt;Removed the &lt;code&gt;MathSolver&lt;/code&gt; block from all 38 tool razors and 38 static mirrors. 1,861 lines deleted across 76 files.&lt;/p&gt;

&lt;p&gt;Rich Results Test result: 0 MathSolver items detected. No error. WebApplication + BreadcrumbList + HowTo all still valid, still eligible for their respective rich results.&lt;/p&gt;

&lt;p&gt;The GSC error will clear as Google re-crawls the pages over the next 3-7 days.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson that would have saved me 6 hours
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Read the target audience paragraph of any Google structured-data doc before rolling it out at scale.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Google's structured-data docs almost always open with a "who this is for" statement. For MathSolver:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Math solver structured data helps users find math solver services in Google Search results."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Not "math-related content." Not "sites that display formulas." &lt;strong&gt;Math solver &lt;em&gt;services&lt;/em&gt;.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I skipped that sentence because I was building tax calculators — of course they involve math. Six hours later I learned that Google's definition of "math solver" is narrower than mine, and its validator enforces the definition rigidly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two practical takeaways
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Feature-gated schemas exist. Detect them early.
&lt;/h3&gt;

&lt;p&gt;Not every schema Google documents is available to every publisher. &lt;code&gt;MathSolver&lt;/code&gt;, &lt;code&gt;EmployerAggregateRating&lt;/code&gt;, &lt;code&gt;JobPosting&lt;/code&gt;, &lt;code&gt;DatasetProperty&lt;/code&gt; — these get feature-gated to specific verticals. Even a valid schema fails validation if you're the wrong publisher.&lt;/p&gt;

&lt;p&gt;Look for these signals BEFORE implementing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Docs open with "helps users find [specific type of service]"&lt;/li&gt;
&lt;li&gt;Named examples in the docs are all one narrow vertical&lt;/li&gt;
&lt;li&gt;Google's rich result gallery for that type shows only 3-5 major players&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If any of those signal apply, validate ONE page in Rich Results Test &lt;strong&gt;before&lt;/strong&gt; rolling out the schema to your entire site.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. "Invalid value in field itemtype" is Google's catch-all rejection.
&lt;/h3&gt;

&lt;p&gt;The error name suggests something specific — a particular &lt;code&gt;@type&lt;/code&gt; value in a particular slot. In practice, Google throws this error whenever &lt;em&gt;anything&lt;/em&gt; about your schema doesn't match its narrower-than-schema.org expectation.&lt;/p&gt;

&lt;p&gt;If you see this error and you're SURE your schema is spec-clean, the answer isn't more schema polish. The answer is: are you using this schema type in a context Google intended it for?&lt;/p&gt;

&lt;h2&gt;
  
  
  Post-mortem: what I'd change
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Locked into a guideline doc&lt;/strong&gt; (&lt;a href="https://smarttaxcalc.in/" rel="noopener noreferrer"&gt;&lt;code&gt;docs/guidelines/content-and-integrity.md&lt;/code&gt;&lt;/a&gt; in my repo) — never add MathSolver JSON-LD on this site again. Also captured the anti-pattern for future contributors: validate ONE test page in Rich Results Test before rolling any new schema type across all 38 tools.&lt;/p&gt;

&lt;p&gt;If you're implementing structured data for a niche use case, budget the "test one page first" step. It's the difference between one file changed and 76 files changed four times in a row.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm &lt;a href="https://smarttaxcalc.in/about/" rel="noopener noreferrer"&gt;Chetan Sanghani&lt;/a&gt;, building &lt;a href="https://smarttaxcalc.in" rel="noopener noreferrer"&gt;SmartTaxCalc.in&lt;/a&gt; — 38 free Indian tax calculators, editorially reviewed by CA Ankitkumar Italiya (ICAI M-644575). Feedback welcome — especially if you've hit similar feature-gate walls with other Google structured data types.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>debugging</category>
      <category>google</category>
      <category>seo</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
