<?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[Evan Mercer's Blog]]></title><description><![CDATA[Evan Mercer's Blog]]></description><link>https://evanmercer.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Tue, 27 Oct 2026 12:48:14 GMT</lastBuildDate><atom:link href="https://evanmercer.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Why a video URL opens in a tab but fails in JavaScript fetch]]></title><description><![CDATA[A video lookup can succeed and still leave the browser user unable to save a file. Cross-origin navigation, JavaScript fetches, and the HTML download…]]></description><link>https://evanmercer.hashnode.dev/why-a-video-url-opens-in-a-tab-but-fails-in-javascript-fetch</link><guid isPermaLink="true">https://evanmercer.hashnode.dev/why-a-video-url-opens-in-a-tab-but-fails-in-javascript-fetch</guid><dc:creator><![CDATA[Evan Mercer]]></dc:creator><pubDate>Mon, 12 Oct 2026 23:24:35 GMT</pubDate><content:encoded><![CDATA[<p>A video lookup can succeed and still leave the browser user unable to save a file. Cross-origin navigation, JavaScript fetches, and the HTML download attribute follow different browser rules. Treating them as interchangeable produces buttons that work in one test and fail on another device.</p>
<p>I maintain Twitter Viewer, including a <a href="https://twitterviewer.net/twitter-video-downloader">twitter video downloader</a> for native videos in public X posts. The examples below explain browser delivery choices in general; they are not a description of the service's internal implementation.</p>
<h2>A link can navigate even when fetch is blocked</h2>
<p>A normal anchor asks the browser to navigate. A fetch asks JavaScript to read a response. Cross-origin servers need to permit that script access through CORS. A video URL may open in a new tab while <code>fetch(url)</code> fails with a CORS error. That difference does not prove the media URL is broken.</p>
<pre><code class="language-html">&lt;a href="https://media.example.test/clip.mp4" target="_blank" rel="noopener"&gt;
  Open video file
&lt;/a&gt;
</code></pre>
<p>The <code>.test</code> address is a placeholder, not a working video. Substitute a source you control to experiment. The browser may show a player instead of a save dialog, depending on the response and browser behavior. Label the button according to the action you can actually guarantee.</p>
<h2>The download attribute is a hint with boundaries</h2>
<p>For a same-origin file, a link such as <code>&lt;a href="/exports/clip.mp4" download="clip.mp4"&gt;Save MP4&lt;/a&gt;</code> is a useful starting point. It does not mean an arbitrary remote server will accept your requested filename or force the same behavior across browsers. The server's headers and the origin relationship still matter.</p>
<p>Do not promise that adding <code>download</code> makes every third-party media URL become a local file. Test your actual response headers, delivery origin, and mobile browsers. A successful desktop test against a localhost file does not exercise the same conditions as a cross-origin production response.</p>
<h2>A blob download trades flexibility for resource use</h2>
<p>If a server permits fetch access, you can read the response as a blob and create an object URL:</p>
<pre><code class="language-js">async function saveAllowedFile(url, filename) {
  const response = await fetch(url);
  if (!response.ok) throw new Error(`Download failed: ${response.status}`);
  const blob = await response.blob();
  const objectUrl = URL.createObjectURL(blob);
  const link = document.createElement('a');
  link.href = objectUrl;
  link.download = filename;
  document.body.append(link);
  link.click();
  link.remove();
  // Keep the URL alive briefly for the browser to start consuming it.
  setTimeout(() =&gt; URL.revokeObjectURL(objectUrl), 60_000);
}
</code></pre>
<p>This is a small-file example. <code>blob()</code> waits for the complete response, so the browser must receive the file before the save action starts. Large media can place substantial pressure on memory or storage, especially on phones. The timeout is cleanup, not a signal that the transfer has completed. For large files, prefer a delivery design that lets the browser manage the transfer rather than buffering the entire asset in application JavaScript.</p>
<h2>A server proxy creates new responsibilities</h2>
<p>A controlled server can send an appropriate content type and a <code>Content-Disposition</code> attachment header. But proxying arbitrary user-supplied URLs creates a server-side request boundary, operational bandwidth costs, and abuse risks. Restrict sources, validate redirects, and avoid buffering a large response entirely in memory merely to set a filename.</p>
<p>A failed upstream lookup, a blocked cross-origin read, and an interrupted file transfer should have different messages. The first may need the user to check the source post. The second may require a delivery-path change. The third may need a retry after the connection recovers. Keeping those failures distinct is more useful than placing all three behind a generic “download failed” alert.</p>
]]></content:encoded></item><item><title><![CDATA[Checking a Downloaded Video's Real Resolution and Orientation with ffprobe]]></title><description><![CDATA[When you save a clip from social media, the label you picked ("720p", "HD", "1080×1920") and the file you get don't always match what a player shows…]]></description><link>https://evanmercer.hashnode.dev/checking-a-downloaded-video-s-real-resolution-and-orientation-with-ffprobe</link><guid isPermaLink="true">https://evanmercer.hashnode.dev/checking-a-downloaded-video-s-real-resolution-and-orientation-with-ffprobe</guid><dc:creator><![CDATA[Evan Mercer]]></dc:creator><pubDate>Tue, 29 Sep 2026 01:02:51 GMT</pubDate><content:encoded><![CDATA[<p>When you save a clip from social media, the label you picked ("720p", "HD", "1080×1920") and the file you get don't always match what a player shows. <code>ffprobe</code> tells you the truth in one command: the stored width and height, any rotation flag, the codec, the bitrate and the duration. This note shows the commands I use, how to read the output, and the three cases that trip people up: vertical video labelled as "1920p", rotation metadata, and files that look sharp but have a low bitrate.</p>
<h2>Install</h2>
<p><code>ffprobe</code> ships with FFmpeg.</p>
<pre><code class="language-bash"># macOS
brew install ffmpeg
# Debian / Ubuntu
sudo apt install ffmpeg
# Windows (winget)
winget install Gyan.FFmpeg
</code></pre>
<h2>The one command</h2>
<pre><code class="language-bash">ffprobe -v error -select_streams v:0 \
  -show_entries stream=codec_name,width,height,r_frame_rate,bit_rate:stream_side_data=rotation:format=duration,size \
  -of default=noprint_wrappers=1 clip.mp4
</code></pre>
<p>Example output for a vertical clip (your numbers will differ):</p>
<pre><code class="language-text">codec_name=h264
width=720
height=1280
r_frame_rate=30/1
bit_rate=2071584
duration=18.466000
size=4812345
</code></pre>
<p>Reading it:</p>
<ul>
<li><code>width</code> and <code>height</code> are the stored frame size. Here the video is vertical: 720 wide, 1280 tall.</li>
<li><code>r_frame_rate</code> is frames per second as a fraction (<code>30/1</code> is 30 fps, <code>30000/1001</code> is 29.97).</li>
<li><code>bit_rate</code> is in bits per second, so about 2.1 Mbit/s.</li>
<li><code>size</code> is the file size in bytes; <code>duration</code> is in seconds.</li>
</ul>
<p>If the <code>rotation</code> line appears (for example <code>rotation=-90</code>), keep reading below.</p>
<h2>"1080×1920" is not "1920p"</h2>
<p>Resolution labels like 720p and 1080p describe the <strong>shorter</strong> side of a landscape frame. A vertical 1080×1920 video has exactly the same number of pixels as a landscape 1920×1080 one. It's "1080p, vertical", not a higher resolution. Download pages that list sizes as width×height make this visible: if the first number is smaller, the video is portrait.</p>
<p>A quick check that prints the orientation:</p>
<pre><code class="language-bash">ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=p=0 clip.mp4 \
  | awk -F, '{ print ($1 &lt; $2 ? "portrait" : ($1 &gt; $2 ? "landscape" : "square")), $1 "x" $2 }'
</code></pre>
<h2>Rotation metadata</h2>
<p>Some files are stored landscape with a flag telling players to rotate them. <code>ffprobe</code> shows the stored size, so a clip that plays as portrait can report <code>width=1280 height=720</code> plus <code>rotation=-90</code>. Players that ignore the flag show it sideways.</p>
<p>To bake the rotation into the pixels so every player shows it correctly (FFmpeg applies the flag automatically when re-encoding):</p>
<pre><code class="language-bash">ffmpeg -i clip.mp4 -c:a copy clip_upright.mp4
</code></pre>
<p>This re-encodes the video, so the file changes size and loses a little quality. Only do it when a player you care about gets the orientation wrong.</p>
<h2>Sharp-looking but low bitrate</h2>
<p>Two files with the same resolution can differ a lot in quality. Bitrate is the better guide. Rough numbers for H.264 at 30 fps:</p>
<ul>
<li>720p: around 1.5–3 Mbit/s looks fine for talking heads; fast motion needs more.</li>
<li>1080p: around 3–6 Mbit/s for typical social clips.</li>
</ul>
<p>A 1080p file at 800 kbit/s will show blocky artefacts in motion, whatever its label says. If you downloaded several versions of the same clip, compare <code>bit_rate</code> and <code>size</code>, not just the resolution.</p>
<h2>Checking a whole folder</h2>
<pre><code class="language-bash">for f in *.mp4; do
  printf '%s\t' "$f"
  ffprobe -v error -select_streams v:0 -show_entries stream=width,height,bit_rate \
    -of csv=p=0 "$f"
done
</code></pre>
<p>Each line is the file name, a tab, then width, height and bitrate separated by commas. Replace the commas with tabs if you want to paste it into a spreadsheet.</p>
<h2>Where these files came from</h2>
<p>I use this mostly on clips saved with <a href="https://twittervideodownload.pro/">Twitter Video Download</a>, a free tool I make for saving public X video and GIF posts as MP4. It lists the versions a post offers by their width×height, which is where the "is 1080×1920 better than 1280×720?" question keeps coming up. The answer is always in <code>ffprobe</code>: compare orientation and bitrate, then keep the version that suits the screen you'll watch it on.</p>
]]></content:encoded></item><item><title><![CDATA[Same problem, different platform: what carried over from an X viewer to a TikTok story viewer]]></title><description><![CDATA[A while ago I built Twitter Viewer, a small free tool for reading public X profiles and posts without logging in. Later I noticed the same pattern on…]]></description><link>https://evanmercer.hashnode.dev/same-problem-different-platform-what-carried-over-from-an-x-viewer-to-a-tiktok-story-viewer</link><guid isPermaLink="true">https://evanmercer.hashnode.dev/same-problem-different-platform-what-carried-over-from-an-x-viewer-to-a-tiktok-story-viewer</guid><dc:creator><![CDATA[Evan Mercer]]></dc:creator><pubDate>Wed, 23 Sep 2026 00:00:33 GMT</pubDate><content:encoded><![CDATA[<p>A while ago I built Twitter Viewer, a small free tool for reading public X profiles and posts without logging in. Later I noticed the same pattern on TikTok: someone sends a link, the recipient doesn't have the app, and a public post sits behind an "open the app or sign in" wall. So I built <a href="https://tikstories.com/">TikStories</a>, which shows a public TikTok account's stories, and its recent videos, without an account.</p>
<p>On paper it's the same product twice. In practice, about half of what I'd learned carried over cleanly and the other half didn't apply at all. This is a note on which half was which, because I suspect it generalises to anyone building "view public content without the login wall" tools for more than one platform.</p>
<h2>What carried over</h2>
<h3>1. Treat the input box as the product</h3>
<p>On both platforms, the first thing a user does is paste something, and what they paste is messy: a bare handle, a handle with an <code>@</code>, a full profile URL, a URL from the share sheet with tracking parameters hanging off the end. The first version of any tool like this tends to handle one of those forms well and fail the others.</p>
<p>The lesson that carried over: accept every reasonable shape of input and normalise it before doing anything else. TikStories takes a bare handle (<code>jasonderulo</code>) or a full <code>tiktok.com/@username</code> link, and strips tracking parameters from pasted links before the lookup runs.</p>
<h3>2. The lookup shouldn't carry anyone's identity</h3>
<p>The principle carried over: a "no login" tool shouldn't quietly borrow someone's login. In TikStories the lookup runs server-side with no cookies and no session attached, so no user's account (and no account of mine) is part of the request; the video itself then streams from TikTok's CDN like any public page. The way I describe it carried over too: be precise about what "anonymous" covers, and don't claim more.</p>
<h3>3. Private stays private, and say so up front</h3>
<p>TikStories can't open private accounts. A private account's stories are only served to its approved followers, so there's nothing public to fetch. Stating that plainly on the page turned out to be as much a product feature as anything the tool does, because it sets expectations before someone pastes a private account and assumes the tool is broken.</p>
<h2>What didn't carry over</h2>
<h3>1. Most of the time, there's nothing to show</h3>
<p>A public X profile nearly always has something on it: posts are persistent. A TikTok account's stories are the opposite: most accounts, most of the time, have no active story. That flips the default state of the product. On X, "empty" is an edge case. On TikTok, "empty" is the normal case.</p>
<p>If you port the X mental model directly, you end up with a story viewer that shows a blank page for most lookups. TikStories instead falls back to the account's recent videos when no story is live, so the page still answers the question the user came with.</p>
<h3>2. Time is part of the content</h3>
<p>X posts don't expire. TikTok stories disappear 24 hours after posting, which makes "how long is this still here?" a real question that didn't exist on the other platform. Each story tile in TikStories shows the hours left before it expires. It's a small detail, but it's the one piece of UI that had no equivalent in the earlier tool at all.</p>
<h3>3. "Can I save it?" means something different</h3>
<p>With persistent posts, reading is the whole job. With content that vanishes in a day, the obvious next question is whether you can keep it. TikStories is viewing-only for now, and it keeps no archive: once a story's 24 hours pass, it's gone, and the tool can't bring it back. Being clear about that is more useful than implying otherwise.</p>
<h2>The general rule I'd take from this</h2>
<p>If you're building the same kind of tool for a second platform, copy the parts that are about <strong>people</strong> (messy input, honesty about privacy, clear limits) and re-derive the parts that are about <strong>content</strong> (how often there's something to show, whether it expires, what users want to do with it). The first set is surprisingly stable across platforms. The second set is where the new platform is actually different, and where a straight port quietly fails.</p>
]]></content:encoded></item><item><title><![CDATA[Designing around content that expires: three decisions from building a TikTok story viewer]]></title><description><![CDATA[Most of the small tools I build deal with content that sits still. A tweet from 2019 is the same tweet today. A TikTok story is not like that: it is gone…]]></description><link>https://evanmercer.hashnode.dev/designing-around-content-that-expires-three-decisions-from-building-a-tiktok-story-viewer</link><guid isPermaLink="true">https://evanmercer.hashnode.dev/designing-around-content-that-expires-three-decisions-from-building-a-tiktok-story-viewer</guid><dc:creator><![CDATA[Evan Mercer]]></dc:creator><pubDate>Mon, 21 Sep 2026 01:41:41 GMT</pubDate><content:encoded><![CDATA[<p>Most of the small tools I build deal with content that sits still. A tweet from 2019 is the same tweet today. A TikTok story is not like that: it is gone 24 hours after it was posted, and nothing brings it back.</p>
<p>I ended up building a viewer for exactly that kind of content — <a href="https://tikstories.com/">TikStories</a> — and the expiry turned out to drive almost every design decision in it. This is a write-up of the three that mattered, because the same shape shows up in anything that displays ephemeral data.</p>
<h2>1. A timestamp is not enough; show what is left</h2>
<p>The obvious thing to render on a story tile is when it was posted. "Posted 6 hours ago" is true, it is easy, and it is almost useless here.</p>
<p>What a reader actually wants to know is <em>how long they have</em>. Those are the same number in different clothes, but only one of them answers the question. "18 hours left" tells you that you can come back tonight. "6 hours ago" makes you do the subtraction yourself, and you have to already know that TikTok's window is 24 hours to do it at all.</p>
<p>So each tile shows the hours remaining rather than the age. It is a one-line transformation of the same field, and it is the single change that made the tool feel different from the other story viewers I tried while deciding whether to build one. Several of them showed nothing at all about time, which means every story on the page looked equally fresh whether it had a day left or ten minutes.</p>
<p>The general rule: for expiring content, render the distance to the deadline, not the distance from the event. The user is standing at the deadline end.</p>
<h2>2. The empty state is the common case, so it cannot be an empty page</h2>
<p>Here is the thing that catches you out when you build a story viewer: <strong>most accounts have no active story at any given moment.</strong> Stories are a burst behaviour. Someone posts three in an evening and then nothing for a week.</p>
<p>That means the "no results" branch is not an edge case you handle for completeness. It is the branch that runs most of the time. A viewer that renders a blank panel with "no stories found" is, statistically, a blank panel.</p>
<p>TikStories falls back to the account's recent videos instead. Same input, same page, but when there is no story to show, you still get something that answers the underlying question — <em>what is this account posting?</em> — rather than a dead end. The search was not wasted; it just landed on the ordinary case.</p>
<p>If you are building anything with a sparse primary result, it is worth asking what the second-best answer is, and whether you can show that instead of nothing. The honest version of that question is: what did the user probably want, if the literal thing they asked for does not exist right now?</p>
<h2>3. Say plainly what the tool cannot do</h2>
<p>Tools in this category have a credibility problem, and it is self-inflicted. Search for a TikTok story viewer and you will find pages promising to open <strong>private</strong> accounts. They cannot. A private account's stories are only ever served to its approved followers — there is no public version of that content sitting somewhere waiting to be fetched. A page claiming otherwise is either lying or harvesting logins.</p>
<p>I decided early that the limits would be written on the page rather than discovered by the user after wasting five minutes. There are four of them and they are all permanent:</p>
<ul>
<li><strong>Private accounts cannot be opened.</strong> Not by this tool, not by any tool.</li>
<li><strong>Expired stories cannot be recovered.</strong> Once the 24 hours are up, there is no archive.</li>
<li><strong>It cannot show who viewed your own story.</strong> That list only exists inside the app.</li>
<li><strong>It is a viewer, not a downloader.</strong> No save button.</li>
</ul>
<p>Writing the limits down costs one short section and buys the only thing that matters in a category full of scams: a reader who believes the rest of the page. It also quietly filters out the traffic that was never going to be happy — people looking for a private-account viewer bounce immediately, which is the correct outcome for both of us.</p>
<h2>Where this applies beyond stories</h2>
<p>The pattern is not TikTok-specific. Anything with a TTL — a temporary share link, a live auction, a status that clears at the end of the day, a token that expires — has the same three questions sitting underneath it:</p>
<ol>
<li>Are you showing the user the age, when they need the remaining time?</li>
<li>Is your empty state the common case, and does it still answer something?</li>
<li>Have you written down what the thing genuinely cannot do, before a user finds out the hard way?</li>
</ol>
<p>None of those require clever engineering. They are just the questions that stop getting asked once you are deep enough in the implementation to have forgotten what the page looks like to someone arriving cold.</p>
<p>I build these small tools outside a day job, so "small enough to keep running" is its own constraint — but the three decisions above cost nothing to keep, which is probably why they survived.</p>
]]></content:encoded></item><item><title><![CDATA[The states nobody designs for in a "paste a link, get a file" tool]]></title><description><![CDATA[A "paste a link, get a file" tool looks trivial from the outside — one input, one button, one result. I've found the opposite is true: the harder a tool is…]]></description><link>https://evanmercer.hashnode.dev/the-states-nobody-designs-for-in-a-paste-a-link-get-a-file-tool</link><guid isPermaLink="true">https://evanmercer.hashnode.dev/the-states-nobody-designs-for-in-a-paste-a-link-get-a-file-tool</guid><dc:creator><![CDATA[Evan Mercer]]></dc:creator><pubDate>Thu, 17 Sep 2026 23:07:28 GMT</pubDate><content:encoded><![CDATA[<p>A "paste a link, get a file" tool looks trivial from the outside — one input, one button, one result. I've found the opposite is true: the harder a tool is to describe, the more the UX rides entirely on how it handles the states that aren't the happy path. Here are the ones I keep having to design for on <a href="https://xvideodownload.app/">X Video Downloader</a>, a small tool that turns an X (Twitter) post link into a downloadable video, GIF, or photo set.</p>
<h2>The four states everyone designs for</h2>
<p>Paste a valid link to a public post with a video → show a preview and a download button. That's the state every mockup shows, and it's maybe 60% of real traffic.</p>
<h2>The states that actually decide whether the tool feels trustworthy</h2>
<p><strong>Valid link, no downloadable media.</strong> Someone pastes a link to a text-only post. This isn't an error — the link worked fine, there's just nothing to extract. Showing a generic "Something went wrong" here is actively misleading; it makes people think the <em>tool</em> is broken when the <em>input</em> was just never going to produce a result.</p>
<p><strong>Valid link, private or deleted content.</strong> The post used to exist, or exists but isn't public. A tool that can't distinguish "I can't reach this" from "this genuinely isn't accessible to anyone" ends up training its users to interpret every failure as a bug report waiting to be filed.</p>
<p><strong>Malformed input.</strong> People paste screenshots' worth of surrounding text, a shortened link, a link with tracking parameters, or a URL to the profile instead of the post. The fix isn't "validate strictly and reject" — it's parsing generously and telling people specifically what was missing, not just that the input was "invalid."</p>
<p><strong>Ambiguous media.</strong> A post with a video <em>and</em> a quoted post that also has a video. Which one did the user mean? Guessing wrong silently is worse than asking.</p>
<h2>Why this matters more than the happy path</h2>
<p>None of these states are exotic edge cases — collectively they're a large share of real usage, just spread thin across many small variations. A tool that only handles the golden path teaches people, one bad experience at a time, that it's unreliable, even when the failure was entirely appropriate given the input. The fix isn't more error handling in the generic sense; it's naming each failure precisely enough that the person pasting the link knows whether the problem is theirs to fix or just isn't fixable at all.</p>
<p>I don't think this is unique to link-paste tools — it's the same shape of problem as any interface built around "hand me one piece of input, I'll do the rest." The demo is always the easy 20%. The product is the other 80%.</p>
]]></content:encoded></item><item><title><![CDATA[After Nitter: what's left of third-party X front-ends]]></title><description><![CDATA[Nitter didn't die in 2024 - the cost of running it changed. What I found testing public instances in August 2026, and why this category of tool is…]]></description><link>https://evanmercer.hashnode.dev/after-nitter-what-s-left-of-third-party-x-front-ends</link><guid isPermaLink="true">https://evanmercer.hashnode.dev/after-nitter-what-s-left-of-third-party-x-front-ends</guid><category><![CDATA[Twitter]]></category><category><![CDATA[Open Source]]></category><category><![CDATA[privacy]]></category><dc:creator><![CDATA[Evan Mercer]]></dc:creator><pubDate>Fri, 14 Aug 2026 15:19:17 GMT</pubDate><content:encoded><![CDATA[<p>If you go looking for a working Nitter instance today, you get a confusing answer. Some are gone.
Some show you a bot check. A couple work perfectly well. And the flagship instance returns HTTP
200 with an empty body, which is its own kind of answer.</p>
<p>I went through the list properly in August 2026, because I kept seeing "Nitter is dead" repeated
as settled fact and it doesn't match what my browser shows me. Here's what's actually there, and
why I think this category of tool is structurally hard to keep alive — which is the more useful
question anyway.</p>
<h2>What happened in 2024</h2>
<p>The short version, from the project's own record rather than from memory:</p>
<p>In late January 2024, X stopped allowing the creation of guest accounts. Guest accounts were the
mechanism that let unauthenticated clients pull public content — per the project's maintainer,
they were anonymous accounts valid for about a month, created when an older version of the mobile
app was launched for the first time. On 28 January it became clear no new ones could be obtained.
By 16 February, the maintainer stated on GitHub that all public instances would soon be dead, with
no viable path forward that didn't involve account suspensions. The two-word version he posted —
"Nitter is dead." — is the sentence that got quoted everywhere afterwards.</p>
<p>That's where most write-ups stop. It's also where the interesting part starts.</p>
<h2>It didn't stay dead — the price changed</h2>
<p>The repository is not archived. It was created in June 2019, has 13.4k stars, and its most recent
commit is dated 13 August 2026 — the day before I wrote this. That is not the commit history of a
dead project. And the README now carries a line that explains the whole situation in one sentence:</p>
<blockquote>
<p>Running a Nitter instance now requires real accounts, since Twitter removed the previous
methods. This does not affect users.</p>
</blockquote>
<p>Read that from an operator's point of view. The project didn't find a clever way around the
change. It moved the cost onto whoever runs an instance: you now need real accounts, with real
suspension risk attached to them, to serve strangers content for free.</p>
<p>"This does not affect users" is true and it's the point. The burden became invisible to the people
benefiting from it, which is exactly the kind of arrangement that erodes quietly.</p>
<h2>What's actually up, August 2026</h2>
<p>Nine public instances are listed on the project wiki. Checking them in a real browser — not curl,
which several of them refuse outright, and a 403 to curl proves nothing about whether a human can
load the page:</p>
<table>
<thead>
<tr>
<th>Instance</th>
<th>Result</th>
</tr>
</thead>
<tbody><tr>
<td><code>nitter.net</code></td>
<td>HTTP 200, empty body</td>
</tr>
<tr>
<td><code>nitter.privacyredirect.com</code></td>
<td>Working — 14 timeline items on a test profile</td>
</tr>
<tr>
<td><code>nitter.tiekoetter.com</code></td>
<td>Working — 20 timeline items</td>
</tr>
<tr>
<td><code>xcancel.com</code></td>
<td>Bot check ("Automated verification failed")</td>
</tr>
<tr>
<td><code>nitter.space</code></td>
<td>Security verification page</td>
</tr>
</tbody></table>
<p>So: not dead, not healthy. A couple of instances run well, several sit behind anti-bot services,
and the best-known address is a blank page. There's a community status tracker for exactly this
reason, which tells you something — you need a monitoring dashboard to find out whether the thing
works today.</p>
<p>For contrast, I measured what X itself gives a logged-out visitor the same day: a profile page
returns five to ten recent posts and then stops loading, a single post returns the post and three
replies, search and explore redirect to a login screen, and the media tab bounces you back to the
profile. The working Nitter instances returned more than that. A volunteer-run instance with an
uncertain lifespan currently shows you more of a public profile than the platform does.</p>
<h2>Why this category is hard</h2>
<p>Four reasons, and none of them are about writing better code.</p>
<p><strong>You're building on a door someone else controls.</strong> Any tool that depends on what a platform
leaves open to unauthenticated visitors has a lifespan equal to that decision, and you are not in
the room where it gets made. The 2024 change is the clean example: nothing about Nitter's code
broke. The thing underneath it was withdrawn.</p>
<p><strong>The platform has ordinary reasons to close it.</strong> Ad impressions and user identification need a
session. Data licensing has become real revenue, and free anonymous access dilutes what you're
selling. Scraping and abuse impose genuine costs. You don't need a conspiracy for any of this;
you just need a spreadsheet.</p>
<p><strong>Free, anonymous, unlimited endpoints get abused.</strong> Whatever you built it for, a public instance
will find other users, and their behaviour is what determines how quickly the whole category
becomes a target. Individual operators absorb the consequences of a crowd they never met.</p>
<p><strong>The maintainer is a volunteer.</strong> No revenue, but real abuse handling, real legal exposure, and
a codebase that breaks whenever the platform changes. In my experience this is what actually ends
most instances — not a dramatic shutdown, just someone deciding they'd like their weekends back.
The 2024 change made this worse in a specific way: it converted a technical problem into a
personal-risk problem, because now it's your accounts on the line.</p>
<h2>"So what do you use?"</h2>
<p>Fair question, and I'll answer the meta-question honestly rather than pretend it wasn't coming.</p>
<p>Disclosure first: I maintain <a href="https://twitterviewer.net/">a reader for public X content</a>. So I'm
a participant in this category, not a neutral surveyor of it.</p>
<p>I'm not going to write about how it works. Two reasons, both practical:</p>
<p>The first is that a detailed writeup of how any of these tools operate is a recipe. Recipes get
followed at volume, volume gets noticed, and the door closes faster for everyone still standing
in it — including the volunteer instances above, which are more fragile than I am. I'd rather not
speed that up in exchange for a more impressive blog post.</p>
<p>The second is that anything specific I wrote here would have a shelf life measured in weeks. This
whole area changes constantly. An article full of details that are wrong by October is worse than
one that doesn't have them.</p>
<p>What I will state plainly is where the boundaries are, because that's the part that should be
stable and that you're entitled to know:</p>
<ul>
<li>Public content only. Protected accounts stay unreadable — that's the account holder's call.</li>
<li>Read-only. No posting, replying, following, or acting on anyone's behalf.</li>
<li>No affiliation with X, in any direction.</li>
<li>When something can't be loaded, it says so instead of rendering an empty timeline.</li>
</ul>
<h2>If you're thinking of building one</h2>
<p>Some advice that isn't about implementation, because implementation is the easy part.</p>
<p>Decide in advance what you're willing to carry. Abuse will find a public endpoint and you'll be
the one handling it. Legal exposure is yours alone. Every platform change lands on your weekend,
and they don't announce them. If you're running it on accounts, understand that you're wagering
those accounts, not just some server time.</p>
<p>If you'd still do it knowing all that, go ahead — this category exists because people keep
deciding it's worth the trouble. Just make that decision before you write the code, rather than
discovering the terms eighteen months in when you're too invested to stop.</p>
<h2>Where that leaves things</h2>
<p>People will keep building these, and platforms will keep closing the doors they're built on, and
in between those two events the tools do genuinely useful work. Nitter has been going since 2019.
It was declared dead in 2024, by its own maintainer, and it is still being committed to and still
serving pages — on borrowed accounts and volunteer time, to users who mostly don't know either of
those things.</p>
<p>That's not a tragedy and it's not a triumph. It's just the actual shape of the thing, which
"Nitter is dead" doesn't capture.</p>
]]></content:encoded></item></channel></rss>