<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[CalcHub Tools Blog]]></title><description><![CDATA[CalcHub Tools Blog]]></description><link>https://calchubtools.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sat, 07 Nov 2026 09:48:05 GMT</lastBuildDate><atom:link href="https://calchubtools.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Why minimum payments take 19 years: simulating credit card payoff in JavaScript]]></title><description><![CDATA[Simulate credit card payoff month by month in ~20 lines of JavaScript, and handle the edge cases: negative amortization and the final payment.]]></description><link>https://calchubtools.hashnode.dev/why-minimum-payments-take-19-years-simulating-credit-card-payoff-in-javascript</link><guid isPermaLink="true">https://calchubtools.hashnode.dev/why-minimum-payments-take-19-years-simulating-credit-card-payoff-in-javascript</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[finance]]></category><category><![CDATA[Tutorial]]></category><category><![CDATA[beginnersguide]]></category><dc:creator><![CDATA[CalcHub Tools]]></dc:creator><pubDate>Thu, 05 Nov 2026 10:16:31 GMT</pubDate><content:encoded><![CDATA[<p>Loan calculators get to use a tidy closed-form formula. Credit cards don't. The minimum payment changes every month because it's a percentage of a balance that keeps changing. The honest way to answer "how long until I'm debt-free?" is to simulate it, one month at a time.</p>
<p>The answer is usually uncomfortable. I'll show the code first, then the numbers.</p>
<h2>The model</h2>
<p>Card issuers differ in the details, but a typical minimum payment is:</p>
<blockquote>
<p><strong>1% of the balance + that month's interest</strong>, with a floor of about $25 (or the whole balance if that's smaller).</p>
</blockquote>
<p>Interest is the APR ÷ 12, applied to the balance. Strictly speaking, most cards use an average daily balance, but monthly compounding gets within a few dollars.</p>
<pre><code class="language-js">function simulate(balance, apr, paymentFn, maxMonths = 1200) {
  const r = apr / 12;
  let months = 0, interestPaid = 0;
  while (balance &gt; 0.005) {
    if (months &gt;= maxMonths) return null;        // never pays off
    const interest = balance * r;
    const pay = Math.min(paymentFn(balance, interest), balance + interest);
    if (pay &lt;= interest) return null;             // negative amortization
    interestPaid += interest;
    balance = balance + interest - pay;
    months++;
  }
  return { months, interestPaid };
}

const minimum = (b, i) =&gt; Math.max(25, b * 0.01 + i);
const fixed = amount =&gt; () =&gt; amount;
</code></pre>
<p>Passing the payment rule in as a function keeps the same loop working for "pay the minimum", "pay $250 a month" and "pay 3% of the balance".</p>
<h2>The numbers for a $5,000 balance at 24% APR</h2>
<pre><code class="language-js">simulate(5000, 0.24, minimum);      // { months: 234, interestPaid: ≈ 8887 }
simulate(5000, 0.24, fixed(150));   // { months: 56,  interestPaid: ≈ 3322 }
simulate(5000, 0.24, fixed(250));   // { months: 26,  interestPaid: ≈ 1449 }
simulate(5000, 0.24, fixed(100));   // null: $100 never covers the $100 of interest
</code></pre>
<p>Paying only the minimum takes <strong>234 months, or nearly 19.5 years</strong>, and costs about <strong>$8,900 in interest</strong>. That's more than the original balance. If you lock in a fixed $250 a month, which is the <em>first</em> month's minimum, you're done in just over two years.</p>
<p>That gap comes from how the minimum shrinks as the balance shrinks. Each month you pay a little less, so the principal goes down more and more slowly. If you keep paying a fixed amount instead, the share that goes to principal grows every month.</p>
<h2>Edge case 1: payments that never win</h2>
<p><code>fixed(100)</code> returns <code>null</code> because 24% / 12 = 2% a month, and 2% of $5,000 is exactly $100. The balance never moves. Any payment at or below the monthly interest gives you <strong>negative amortization</strong>: the balance stays put or grows. A calculator should say so plainly rather than loop forever. That's why there's both a <code>pay &lt;= interest</code> check and a <code>maxMonths</code> guard.</p>
<h2>Edge case 2: the last payment</h2>
<p>Without <code>Math.min(..., balance + interest)</code>, the final month overpays and you get a negative balance and inflated interest totals. Real statements simply bill whatever is left.</p>
<h2>Edge case 3: cents</h2>
<p>Issuers round interest to the cent each cycle. If you want to match a statement exactly, keep the balance in integer cents:</p>
<pre><code class="language-js">const interest = Math.round(balanceCents * r); // integer cents
</code></pre>
<p>Over 234 months, float drift adds up to a few cents. That's harmless for planning, but people do notice when it doesn't match.</p>
<h2>Is there a formula at all?</h2>
<p>For a <strong>fixed</strong> payment there is. Solving the annuity equation for n gives:</p>
<pre><code class="language-js">const monthsToPayoff = (B, apr, M) =&gt; {
  const r = apr / 12;
  return -Math.log(1 - (B * r) / M) / Math.log(1 + r);
};
monthsToPayoff(5000, 0.24, 250); // ≈ 25.6 → 26 payments
</code></pre>
<p>It returns <code>NaN</code> when <code>M &lt;= B·r</code>, which is the same negative-amortization case. For percentage-based minimums with a floor, the simulation is simpler and easier to trust.</p>
<h2>Avalanche vs snowball in five more lines</h2>
<p>Once you have <code>simulate</code>, comparing payoff strategies across several cards is a loop. Each month, pay the minimum on every card. Then put the extra money on the card with the highest APR (avalanche) or the smallest balance (snowball). Avalanche always pays the least interest. Snowball gives you quick wins, and for many people that's what keeps them going. The simulation can tell you exactly how many dollars that motivation costs.</p>
<h2>Try it with your own numbers</h2>
<p>I built a small, free <a href="https://loanmath.pages.dev/credit-card-payoff-calculator/">credit card payoff calculator</a> on this same loop. It runs entirely in the browser, with no sign-up and nothing sent anywhere. Enter a balance, APR and payment, and it shows the months and total interest.</p>
<p>The main takeaway doesn't need a calculator, though. <strong>Pick a fixed payment and keep paying it, even as the required minimum falls.</strong> That one habit is the difference between 2 years and 19.</p>
]]></content:encoded></item><item><title><![CDATA[The loan payment formula, explained (and implemented in 15 lines of JavaScript)]]></title><description><![CDATA[Where the loan payment formula comes from, how to build an amortization schedule, why extra payments save interest, and why results differ by a cent.]]></description><link>https://calchubtools.hashnode.dev/the-loan-payment-formula-explained-and-implemented-in-15-lines-of-javascript</link><guid isPermaLink="true">https://calchubtools.hashnode.dev/the-loan-payment-formula-explained-and-implemented-in-15-lines-of-javascript</guid><category><![CDATA[finance]]></category><category><![CDATA[Beginner Developers]]></category><category><![CDATA[Tutorial]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[Math]]></category><dc:creator><![CDATA[CalcHub Tools]]></dc:creator><pubDate>Sun, 01 Nov 2026 10:04:59 GMT</pubDate><content:encoded><![CDATA[<p>Every mortgage, car loan and personal-loan calculator runs on the same one-line formula. It looks intimidating, but it falls out of a simple idea. Once you understand it, you can build a full amortization schedule in a few lines and see why paying a little extra helps so much.</p>
<h2>The formula</h2>
<p>For a loan of principal <strong>P</strong>, a monthly rate <strong>r</strong> (annual rate ÷ 12) and <strong>n</strong> monthly payments:</p>
<pre><code>M = P · r / (1 − (1 + r)^−n)
</code></pre>
<pre><code class="language-js">function monthlyPayment(principal, annualRate, months) {
  const r = annualRate / 12;
  if (r === 0) return principal / months;   // 0% promo loans: avoid divide-by-zero
  return principal * r / (1 - Math.pow(1 + r, -months));
}

monthlyPayment(25000, 0.07, 60); // ≈ 495.03
</code></pre>
<h2>Where it comes from (the short version)</h2>
<p>A loan is "fair" when the present value of all your payments equals the amount borrowed. A payment made <em>k</em> months from now is worth <code>M / (1 + r)^k</code> today, so:</p>
<pre><code>P = M/(1+r) + M/(1+r)^2 + … + M/(1+r)^n
</code></pre>
<p>That's a geometric series. Summing it gives <code>P = M · (1 − (1+r)^−n) / r</code>, and solving for M gives the formula above. That's the whole derivation.</p>
<h2>The amortization schedule</h2>
<p>Each month, interest is charged on the <strong>remaining balance</strong>, and whatever is left of the payment reduces the principal:</p>
<pre><code class="language-js">function schedule(principal, annualRate, months, extra = 0) {
  const r = annualRate / 12, pay = monthlyPayment(principal, annualRate, months);
  let balance = principal, rows = [];
  while (balance &gt; 0.005 &amp;&amp; rows.length &lt; 1000) {
    const interest = balance * r;
    const toPrincipal = Math.min(pay + extra - interest, balance);
    balance -= toPrincipal;
    rows.push({ month: rows.length + 1, interest, toPrincipal, balance });
  }
  return rows;
}
</code></pre>
<p>For $25,000 at 7% over 5 years, the first payment of $495.03 is <strong>$145.83 interest</strong> and only $349.20 principal. By the final year, almost all of each payment goes to principal. That's why loans feel like they barely move at first.</p>
<p>Total interest over the full term is about <strong>$4,702</strong>.</p>
<h2>Why extra payments are so effective</h2>
<p>Every extra dollar goes straight to principal, and principal is what interest is charged on. So it saves interest for every remaining month.</p>
<pre><code class="language-js">const base = schedule(25000, 0.07, 60);
const plus = schedule(25000, 0.07, 60, 100);
const sum = rows =&gt; rows.reduce((s, x) =&gt; s + x.interest, 0);
console.log(base.length, sum(base).toFixed(0));  // 60 months, ~4702
console.log(plus.length, sum(plus).toFixed(0));  // 49 months, ~3763
</code></pre>
<p>Adding $100 a month finishes <strong>11 months sooner</strong> and saves roughly <strong>$940</strong> in interest. Before you do this, check that your lender applies extra payments to principal and doesn't charge prepayment penalties.</p>
<h2>Why two calculators disagree by a cent</h2>
<p>If you compare your result with a bank statement, you'll sometimes be off by a few cents. Common reasons:</p>
<ol>
<li><strong>Rounding each month.</strong> Banks round interest and payments to the cent <em>every period</em>. A pure floating-point schedule doesn't. To match a statement, round each row: <code>Math.round(x * 100) / 100</code>, or work in integer cents.</li>
<li><strong>The last payment.</strong> Rounding errors accumulate, so lenders usually adjust the final payment. The <code>Math.min(..., balance)</code> above handles that.</li>
<li><strong>Day-count conventions.</strong> Some loans accrue interest daily (actual/365) rather than as a clean 1/12 of the annual rate, so months with 31 days cost a bit more.</li>
<li><strong>APR vs interest rate.</strong> APR includes fees. The payment formula uses the note rate, not the APR.</li>
<li><strong>Floating point.</strong> <code>0.1 + 0.2 !== 0.3</code> in JavaScript. It rarely matters for display, but for money logic you persist, prefer integer cents or a decimal library.</li>
</ol>
<h2>Edge cases to handle</h2>
<ul>
<li><code>annualRate === 0</code> → straight division (handled above).</li>
<li>Very long terms with tiny rates → <code>Math.pow</code> is fine; avoid looping to compound.</li>
<li>Validate inputs: negative months or a rate entered as <code>7</code> instead of <code>0.07</code> is the classic bug. Decide whether your UI takes percentages and convert once.</li>
</ul>
<h2>Try it</h2>
<p>If you'd rather not run this in a console, <a href="https://loanmath.pages.dev/loan-calculator/">LoanMath</a> is a free calculator I built on exactly this math, with an amortization schedule (there's a separate <a href="https://loanmath.pages.dev/extra-payment-calculator/">extra-payment calculator</a> for the comparison above).</p>
<p><em>This post explains the math and isn't financial advice. Your loan agreement's terms always win.</em></p>
]]></content:encoded></item><item><title><![CDATA[Merge, split and rotate PDFs entirely in the browser with pdf-lib]]></title><description><![CDATA[A hands-on tutorial: build a client-side PDF merger with pdf-lib and the File API, then add page extraction and rotation. No backend, so files never leave…]]></description><link>https://calchubtools.hashnode.dev/merge-split-and-rotate-pdfs-entirely-in-the-browser-with-pdf-lib</link><guid isPermaLink="true">https://calchubtools.hashnode.dev/merge-split-and-rotate-pdfs-entirely-in-the-browser-with-pdf-lib</guid><category><![CDATA[privacy]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[pdf]]></category><category><![CDATA[Web Development]]></category><dc:creator><![CDATA[CalcHub Tools]]></dc:creator><pubDate>Sat, 31 Oct 2026 10:10:20 GMT</pubDate><content:encoded><![CDATA[<p>"Merge these three PDFs" is one of the most common office tasks, and most online tools handle it by uploading your documents to someone else's server. For contracts, payslips or medical forms, that's an uncomfortable trade. You can do it all in the browser with <a href="https://pdf-lib.js.org/">pdf-lib</a>, an MIT-licensed library that reads and writes PDFs in pure JavaScript.</p>
<p>Here's a minimal but real implementation.</p>
<h2>Setup</h2>
<pre><code class="language-html">&lt;input type="file" id="files" accept="application/pdf" multiple&gt;
&lt;button id="merge"&gt;Merge&lt;/button&gt;
&lt;script src="https://unpkg.com/pdf-lib@1.17.1/dist/pdf-lib.min.js"&gt;&lt;/script&gt;
</code></pre>
<p>(Or <code>npm i pdf-lib</code> and <code>import { PDFDocument, degrees } from 'pdf-lib'</code>. In production, self-host the file or pin it with Subresource Integrity rather than loading a CDN script unpinned.)</p>
<h2>Merging</h2>
<p>The core idea: create an empty document, then <strong>copy pages</strong> from each source into it.</p>
<pre><code class="language-js">const { PDFDocument } = PDFLib;

async function mergePdfs(files) {
  const out = await PDFDocument.create();
  for (const file of files) {
    const src = await PDFDocument.load(await file.arrayBuffer());
    const pages = await out.copyPages(src, src.getPageIndices());
    pages.forEach(p =&gt; out.addPage(p));
  }
  return out.save(); // Uint8Array
}

document.getElementById('merge').onclick = async () =&gt; {
  const files = [...document.getElementById('files').files];
  const bytes = await mergePdfs(files);
  download(bytes, 'merged.pdf');
};

function download(bytes, name) {
  const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }));
  const a = Object.assign(document.createElement('a'), { href: url, download: name });
  a.click();
  setTimeout(() =&gt; URL.revokeObjectURL(url), 1000);
}
</code></pre>
<p>Why <code>copyPages</code> and not just <code>addPage(src.getPage(0))</code>? Each page refers to objects that belong to its own document: fonts, images, content streams. <code>copyPages</code> deep-copies those references into the target document. Adding a page that belongs to another document doesn't work.</p>
<p><strong>Order matters.</strong> <code>input.files</code> comes back in whatever order the OS file picker returns, which isn't always the order the user means. A real UI should show the list and let users reorder it before merging.</p>
<h2>Extracting a page range</h2>
<p>The same pattern with a subset of indices:</p>
<pre><code class="language-js">async function extract(file, from, to) {           // 1-based, inclusive
  const src = await PDFDocument.load(await file.arrayBuffer());
  const out = await PDFDocument.create();
  const idx = Array.from({ length: to - from + 1 }, (_, i) =&gt; from - 1 + i);
  (await out.copyPages(src, idx)).forEach(p =&gt; out.addPage(p));
  return out.save();
}
</code></pre>
<p>"Split every page into its own file" is just this in a loop. Zip the results with something like JSZip if there are many.</p>
<h2>Rotating pages</h2>
<pre><code class="language-js">const { degrees } = PDFLib;

async function rotateAll(file, by = 90) {
  const doc = await PDFDocument.load(await file.arrayBuffer());
  for (const page of doc.getPages()) {
    const current = page.getRotation().angle;
    page.setRotation(degrees((current + by) % 360));
  }
  return doc.save();
}
</code></pre>
<p>Rotation is stored as a page attribute, so this is lossless and instant. No re-rendering happens.</p>
<h2>Things that will bite you</h2>
<ul>
<li><strong>Encrypted PDFs.</strong> pdf-lib can't decrypt password-protected files, and <code>load()</code> throws on them by default. Catch the error and tell the user to remove the password first. <code>{ ignoreEncryption: true }</code> exists, but it doesn't actually decrypt anything, so the output is often broken.</li>
<li><strong>Memory.</strong> Everything sits in RAM. A few hundred MB of scans can crash a mobile tab, so process files one at a time and warn above a size threshold.</li>
<li><strong>Form fields.</strong> Copying pages from several documents with AcroForm fields can produce duplicate field names. If forms matter, flatten them first (<code>doc.getForm().flatten()</code>).</li>
<li><strong>File size.</strong> Merged output is roughly the sum of the inputs. pdf-lib doesn't recompress images. If you need smaller files, that's a separate step.</li>
</ul>
<h2>Proving it stays local</h2>
<p>Users have no reason to take "we don't upload your files" on faith. Make it checkable: serve the page as static files, avoid sending file data to analytics, and invite people to watch the Network tab. With the code above, the only request should be the initial script load.</p>
<p>I used exactly this approach for <a href="https://pdfpocket.pages.dev/merge-pdf/">PDFPocket's merge tool</a>, a free set of browser-only PDF utilities (merge, split, rotate, JPG to PDF). Feel free to use it as a reference, or take the snippets above and build your own in an afternoon.</p>
]]></content:encoded></item><item><title><![CDATA[Compress images in the browser with Canvas and WebP (no server, no upload)]]></title><description><![CDATA[A practical walkthrough of client-side image compression: decoding, resizing on a canvas, encoding to WebP/JPEG, and the gotchas.]]></description><link>https://calchubtools.hashnode.dev/compress-images-in-the-browser-with-canvas-and-webp-no-server-no-upload</link><guid isPermaLink="true">https://calchubtools.hashnode.dev/compress-images-in-the-browser-with-canvas-and-webp-no-server-no-upload</guid><category><![CDATA[Tutorial]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[web performance]]></category><category><![CDATA[image processing]]></category><dc:creator><![CDATA[CalcHub Tools]]></dc:creator><pubDate>Thu, 29 Oct 2026 10:06:19 GMT</pubDate><content:encoded><![CDATA[<p>Most "compress image" features upload your photo to a server, run it through an encoder and send it back. Browsers can do that whole pipeline locally now, which is faster for the user, cheaper for you, and better for privacy. Here's how it works, step by step.</p>
<h2>The pipeline</h2>
<pre><code class="language-plaintext">File  →  decode (ImageBitmap)  →  draw on canvas at target size  →  encode (toBlob)  →  Blob / download
</code></pre>
<p>Each step has one browser API and at least one gotcha.</p>
<h2>1. Decode the file</h2>
<pre><code class="language-js">const input = document.querySelector('input[type=file]');
input.addEventListener('change', async () =&gt; {
  const file = input.files[0];
  const bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' });
  // bitmap.width / bitmap.height are the real pixel dimensions
});
</code></pre>
<p><code>createImageBitmap</code> decodes off the main thread in most browsers and accepts a <code>File</code> directly. The <code>imageOrientation: 'from-image'</code> option tells it to respect the EXIF orientation tag. Phone photos are often stored sideways with a "rotate 90°" flag. Current browsers apply that flag by default, but setting it explicitly documents the intent.</p>
<h2>2. Resize: this is where most of the savings come from</h2>
<p>People reach for the quality slider first, but <strong>dimensions matter more</strong>. A 4032×3024 phone photo shown in an 800 px column is carrying about 19× more pixels than it needs.</p>
<pre><code class="language-js">function fit(w, h, maxW, maxH) {
  const scale = Math.min(1, maxW / w, maxH / h); // never upscale
  return [Math.round(w * scale), Math.round(h * scale)];
}

const [w, h] = fit(bitmap.width, bitmap.height, 1600, 1600);
const canvas = new OffscreenCanvas(w, h);           // or document.createElement('canvas')
const ctx = canvas.getContext('2d');
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(bitmap, 0, 0, w, h);
bitmap.close(); // free memory early, big photos are tens of MB decoded
</code></pre>
<p>For very large downscales (say 8×), a single <code>drawImage</code> can look a bit soft or aliased. Halving the size in a few steps gives cleaner results.</p>
<h2>3. Encode with a quality setting</h2>
<pre><code class="language-js">const blob = await canvas.convertToBlob({ type: 'image/webp', quality: 0.8 });
// with a regular &lt;canvas&gt;: canvas.toBlob(cb, 'image/webp', 0.8)
</code></pre>
<ul>
<li><code>quality</code> (0–1) applies to lossy formats (<code>image/jpeg</code>, <code>image/webp</code>) and is ignored for PNG.</li>
<li>For photos, <strong>0.75–0.85</strong> is usually the sweet spot. Below about 0.6, artifacts show up around edges and in skies.</li>
<li><strong>Feature-detect WebP encoding.</strong> If a browser can't encode the type you ask for, it quietly falls back to PNG. Check <code>blob.type</code> afterwards:</li>
</ul>
<pre><code class="language-js">if (blob.type !== 'image/webp') {
  // fall back to JPEG
}
</code></pre>
<h2>4. Don't make it bigger</h2>
<p>Re-encoding an already well-compressed JPEG can produce a <em>larger</em> file. Always compare sizes and keep the original if you didn't win:</p>
<pre><code class="language-js">const result = blob.size &lt; file.size ? blob : file;
</code></pre>
<h2>Gotchas worth knowing</h2>
<p><strong>Transparency.</strong> JPEG has no alpha channel, so transparent PNG pixels turn black. Fill the canvas with white first (<code>ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, w, h)</code>) or keep WebP/PNG for images with transparency.</p>
<p><strong>Metadata is dropped.</strong> Canvas re-encoding strips EXIF, including GPS location. That's usually a privacy <em>feature</em> for images you publish, but tell your users, because photographers sometimes want their copyright metadata kept.</p>
<p><strong>Memory.</strong> A decoded 24-megapixel image is about 96 MB of RGBA. On phones, process files one at a time, call <code>bitmap.close()</code>, and consider a Web Worker with <code>OffscreenCanvas</code> so the UI stays responsive.</p>
<p><strong>Color profiles.</strong> Wide-gamut (Display P3) photos can shift slightly when drawn to a default sRGB canvas. For web publishing this is normally fine, but it's worth knowing if a user reports "my colors changed".</p>
<h2>Putting it together</h2>
<pre><code class="language-js">async function compress(file, { max = 1600, type = 'image/webp', quality = 0.8 } = {}) {
  const bmp = await createImageBitmap(file, { imageOrientation: 'from-image' });
  const [w, h] = fit(bmp.width, bmp.height, max, max);
  const c = new OffscreenCanvas(w, h);
  const ctx = c.getContext('2d');
  if (type === 'image/jpeg') { ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, w, h); }
  ctx.imageSmoothingQuality = 'high';
  ctx.drawImage(bmp, 0, 0, w, h);
  bmp.close();
  let out = await c.convertToBlob({ type, quality });
  if (out.type !== type) out = await c.convertToBlob({ type: 'image/jpeg', quality });
  return out.size &lt; file.size ? out : file;
}
</code></pre>
<p>About 20 lines and no dependencies. It handles most "make this photo small enough to upload" cases.</p>
<h2>When to use a real encoder instead</h2>
<p>Canvas encoders are tuned for speed, not maximum compression. If you need the smallest possible files, such as for a build pipeline or a CDN, tools like <code>sharp</code>/libvips, MozJPEG or libavif (also available as WebAssembly) will beat <code>toBlob</code> at the same visual quality.</p>
<p>If you just want to try the browser approach on your own photos without writing code, <a href="https://pixsquish.pages.dev/compress-image/">PixSquish</a> is a small free tool I made that runs this kind of pipeline entirely client-side. Open DevTools → Network while you use it and you'll see the image never leaves the page.</p>
]]></content:encoded></item><item><title><![CDATA[What's actually inside a JWT? Decoding one by hand in 10 lines of JavaScript]]></title><description><![CDATA[If you've ever copied an Authorization: Bearer eyJhbGciOi... header out of DevTools and wondered what's in it, the good news is that you don't need a…]]></description><link>https://calchubtools.hashnode.dev/what-s-actually-inside-a-jwt-decoding-one-by-hand-in-10-lines-of-javascript</link><guid isPermaLink="true">https://calchubtools.hashnode.dev/what-s-actually-inside-a-jwt-decoding-one-by-hand-in-10-lines-of-javascript</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[Security]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[Beginner Developers]]></category><dc:creator><![CDATA[CalcHub Tools]]></dc:creator><pubDate>Wed, 28 Oct 2026 15:32:04 GMT</pubDate><content:encoded><![CDATA[<p>If you've ever copied an <code>Authorization: Bearer eyJhbGciOi...</code> header out of DevTools and wondered what's in it, the good news is that you don't need a library to find out. A JSON Web Token is plain text, and you can decode it with a few lines of JavaScript.</p>
<p>That ease is also the point of this post. The thing that's easy for you to read is just as easy for anyone else to read.</p>
<h2>The shape of a JWT</h2>
<p>A JWT (RFC 7519) in its common signed form (JWS) has three parts separated by dots:</p>
<pre><code class="language-plaintext">xxxxx.yyyyy.zzzzz
header.payload.signature
</code></pre>
<ul>
<li><strong>Header</strong>: JSON that describes the token, usually <code>{"alg":"HS256","typ":"JWT"}</code>.</li>
<li><strong>Payload</strong>: JSON "claims" such as <code>sub</code> (subject / user id), <code>exp</code> (expiry), <code>iat</code> (issued at), plus anything custom.</li>
<li><strong>Signature</strong>: a MAC or digital signature over <code>header.payload</code>, made with a secret or private key.</li>
</ul>
<p>The header and payload are <strong>Base64URL-encoded</strong>, not encrypted. Base64URL is Base64 with <code>-</code> instead of <code>+</code>, <code>_</code> instead of <code>/</code>, and the trailing <code>=</code> padding removed.</p>
<h2>Decoding it yourself</h2>
<pre><code class="language-js">function base64UrlDecode(str) {
  // Base64URL -&gt; Base64
  let b64 = str.replace(/-/g, '+').replace(/_/g, '/');
  // restore padding
  while (b64.length % 4) b64 += '=';
  // atob gives a "binary string"; decode as UTF-8 so non-ASCII claims survive
  const bytes = Uint8Array.from(atob(b64), c =&gt; c.charCodeAt(0));
  return new TextDecoder().decode(bytes);
}

function decodeJwt(token) {
  const [h, p, s] = token.split('.');
  if (!h || !p) throw new Error('Not a JWT');
  return {
    header: JSON.parse(base64UrlDecode(h)),
    payload: JSON.parse(base64UrlDecode(p)),
    signature: s, // raw bytes, Base64URL; not human-readable
  };
}
</code></pre>
<p>Try it with the classic example token from the spec ecosystem:</p>
<pre><code class="language-js">const t = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.' +
  'eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.' +
  'SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';

console.log(decodeJwt(t).payload);
// { sub: '1234567890', name: 'John Doe', iat: 1516239022 }
</code></pre>
<p>Two details people often miss:</p>
<ol>
<li><strong>The UTF-8 step.</strong> <code>atob()</code> alone breaks on names like "José" or emoji, because it returns one character per byte. Passing the bytes through <code>TextDecoder</code> fixes that.</li>
<li><strong>Padding.</strong> Base64URL removes <code>=</code> padding, and some decoders refuse input without it. Add it back until the length is a multiple of 4.</li>
</ol>
<p>In Node 16+ you can skip the helper: <code>Buffer.from(part, 'base64url').toString('utf8')</code>.</p>
<h2>Reading the time claims</h2>
<p><code>exp</code>, <code>iat</code> and <code>nbf</code> are <strong>seconds</strong> since the Unix epoch, not milliseconds:</p>
<pre><code class="language-js">const { exp } = decodeJwt(token).payload;
const expiresAt = new Date(exp * 1000);
const expired = Date.now() &gt;= exp * 1000;
</code></pre>
<p>Forgetting the <code>* 1000</code> makes every token look like it expired in January 1970. It's one of the most common JWT bugs I've seen in front-end code.</p>
<h2>Decoding is not verifying</h2>
<p>This is the part that matters. Everything above tells you what a token <strong>claims</strong>. It doesn't tell you whether the claims are true. Anyone can write this:</p>
<pre><code class="language-js">const fake = btoa('{"alg":"HS256"}') + '.' + btoa('{"sub":"admin"}') + '.whatever';
</code></pre>
<p>Only the signature check proves who issued the token. On the server, always verify with a maintained library (for example <code>jose</code> in Node) and:</p>
<ul>
<li><strong>Pin the algorithm</strong> you expect. Don't let the token's own <code>alg</code> header pick it, and never accept <code>alg: "none"</code>.</li>
<li><strong>Check</strong> <code>exp</code><strong>,</strong> <code>nbf</code><strong>,</strong> <code>iss</code> <strong>and</strong> <code>aud</code><strong>.</strong> A valid signature on an expired token, or on a token meant for a different service, should still be rejected.</li>
<li><strong>Don't put secrets in the payload.</strong> It's readable by anyone who holds the token. If you need confidentiality, use encrypted JWTs (JWE) or keep the data server-side.</li>
</ul>
<p>Front-end decoding is fine for UX, like showing a username or warning before a session expires. It is never an authorization decision.</p>
<h2>A note on pasting tokens into websites</h2>
<p>Production tokens are credentials. Before you paste one into an online decoder, check that it decodes <strong>locally</strong> in your browser. Open the Network tab and confirm nothing is sent when you paste. I built the <a href="https://devknife.pages.dev/jwt-decoder/">JWT decoder on DevKnife</a> to work that way: it's plain client-side JavaScript, much like the snippet above, with the time claims converted to readable dates. You can also just keep the 10-line function in your snippets folder.</p>
<h2>TL;DR</h2>
<ul>
<li>A JWT is <code>base64url(header).base64url(payload).signature</code>.</li>
<li>Decoding takes about 10 lines: swap the URL-safe characters, add padding, <code>atob</code>, then <code>TextDecoder</code>.</li>
<li><code>exp</code>/<code>iat</code> are in seconds.</li>
<li>Decoded ≠ verified. Verify on the server with a pinned algorithm, and keep secrets out of the payload.</li>
</ul>
]]></content:encoded></item></channel></rss>