<?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: David</title>
    <description>The latest articles on DEV Community by David (@david1390).</description>
    <link>https://dev.to/david1390</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%2F4130289%2F1289ebb6-ab4d-4dd7-9330-721eafc82e52.png</url>
      <title>DEV Community: David</title>
      <link>https://dev.to/david1390</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/david1390"/>
    <language>en</language>
    <item>
      <title>Building Instagram Downloader: Turning a Simple Downloader Into a Real Product</title>
      <dc:creator>David</dc:creator>
      <pubDate>Thu, 17 Sep 2026 18:05:57 +0000</pubDate>
      <link>https://dev.to/david1390/building-instagram-downloader-turning-a-simple-downloader-into-a-real-product-3dbo</link>
      <guid>https://dev.to/david1390/building-instagram-downloader-turning-a-simple-downloader-into-a-real-product-3dbo</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo5s3ok6nzel4lefnyw9n.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo5s3ok6nzel4lefnyw9n.png" alt=" " width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;At first, Instagram Downloader sounded like a small project.&lt;/p&gt;

&lt;p&gt;A user pastes a public Instagram link, the service finds the media, and the browser downloads it. Simple.&lt;/p&gt;

&lt;p&gt;But “simple” products are often the ones that require the most care. The user sees one input field and one button. Behind that interaction are dozens of decisions about usability, different media types, mobile behavior, accessibility, localization, reliability, and privacy.&lt;/p&gt;

&lt;p&gt;This is the story of how FromInsta.net grew from a basic downloader into a focused web product.&lt;/p&gt;

&lt;h2&gt;
  
  
  Starting with one clear promise
&lt;/h2&gt;

&lt;p&gt;The first version was built around a single workflow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Copy a link to a public Instagram post.&lt;/li&gt;
&lt;li&gt;Paste it into the service.&lt;/li&gt;
&lt;li&gt;Choose the media you want to save.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There was no reason to make the interface more complicated than that. Users arrive with a very specific goal, so the page needs to help them complete it without registration, unnecessary settings, or a long explanation.&lt;/p&gt;

&lt;p&gt;That principle remained important as the product expanded: new features should support the original workflow instead of competing with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A download button was not enough
&lt;/h2&gt;

&lt;p&gt;The earliest product question was not simply whether a file could be downloaded. It was whether users could understand what they were about to download.&lt;/p&gt;

&lt;p&gt;Instagram posts can contain a single photo, a video, a Reel, or a carousel with multiple items. A generic button does not communicate much when a post contains several files.&lt;/p&gt;

&lt;p&gt;Media previews became an essential part of the experience. Users could inspect photos, play videos, move between carousel items, and then save exactly what they needed.&lt;/p&gt;

&lt;p&gt;This changed the service from a link-processing utility into a visual workflow:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Paste, preview, choose, download.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Small interaction details also mattered. The interface needed clear loading states, useful error messages, predictable mobile controls, and an obvious way to recover when a request failed.&lt;/p&gt;

&lt;p&gt;None of these features sounds remarkable on its own. Together, they determine whether a service feels dependable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Audio became a product of its own
&lt;/h2&gt;

&lt;p&gt;Downloading photos and videos was only part of the use case. Sometimes the valuable part of a Reel or video is its audio.&lt;/p&gt;

&lt;p&gt;Adding audio support required a different user experience. Audio may need time to be prepared, so an immediate download is not always possible. Users need to know whether their request is waiting, being processed, ready, or no longer available.&lt;/p&gt;

&lt;p&gt;The final experience lets users preview the audio and choose between the original available format and MP3. For carousel posts, the service processes the video items that contain audio and skips photos.&lt;/p&gt;

&lt;p&gt;The important lesson was that audio could not simply be added as another button. It had its own states, expectations, and failure cases, so it deserved a dedicated flow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Carousels exposed another kind of friction
&lt;/h2&gt;

&lt;p&gt;A carousel may contain photos, videos, or a mixture of both. Downloading every slide individually works, but it becomes repetitive for larger posts.&lt;/p&gt;

&lt;p&gt;That led to an option to save a complete carousel as a single ZIP archive.&lt;/p&gt;

&lt;p&gt;Again, the main challenge was not presenting another feature. It was making the feature understandable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Individual files should still be available.&lt;/li&gt;
&lt;li&gt;Users should be able to preview each item.&lt;/li&gt;
&lt;li&gt;The complete archive should be optional.&lt;/li&gt;
&lt;li&gt;Temporary files should not appear permanent.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result gives users both levels of control: save one specific item or download the full collection.&lt;/p&gt;

&lt;h2&gt;
  
  
  Localization changed more than the text
&lt;/h2&gt;

&lt;p&gt;FromInsta.net is now available in 24 languages.&lt;/p&gt;

&lt;p&gt;Localization initially looked like a content task, but it quickly became a design task. Labels become longer. Headings wrap differently. Some scripts require more space. Arabic and Persian introduce right-to-left layouts. Technical Instagram terminology does not always have a natural word-for-word translation.&lt;/p&gt;

&lt;p&gt;Each language also needs more than a translated homepage. Navigation, instructions, errors, download states, accessibility labels, legal pages, and metadata must all remain consistent.&lt;/p&gt;

&lt;p&gt;This taught us to treat localization as part of the product rather than as a final layer added after development.&lt;/p&gt;

&lt;p&gt;A multilingual interface is not complete when every string has been translated. It is complete when every supported language can go through the entire workflow comfortably.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mobile usability had to be treated as the default
&lt;/h2&gt;

&lt;p&gt;Copying an Instagram link and opening a downloader is a naturally mobile workflow. That means mobile behavior cannot be a reduced version of the desktop experience.&lt;/p&gt;

&lt;p&gt;Several details became especially important:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The link field must remain easy to use on a narrow screen.&lt;/li&gt;
&lt;li&gt;Paste and clear actions must be easy to reach.&lt;/li&gt;
&lt;li&gt;Security checks must fit without breaking the layout.&lt;/li&gt;
&lt;li&gt;Preview controls must not cover the media.&lt;/li&gt;
&lt;li&gt;Download buttons need clear pressed and disabled states.&lt;/li&gt;
&lt;li&gt;Results should come into view when they are ready.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These improvements are easy to overlook when testing on a large screen. On a phone, they define the entire experience.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reliability is a user-facing feature
&lt;/h2&gt;

&lt;p&gt;Services that depend on another platform must expect change.&lt;/p&gt;

&lt;p&gt;A link that works today may behave differently tomorrow. A post may be private, removed, restricted, or temporarily unavailable. A video may not contain an audio track. A request may take longer than expected.&lt;/p&gt;

&lt;p&gt;Trying to hide this uncertainty would only create confusing failures. The better approach was to communicate it clearly.&lt;/p&gt;

&lt;p&gt;FromInsta.net distinguishes between invalid links, unavailable posts, unsupported media, timeouts, processing problems, and temporary service errors. Users should know whether they can correct the problem, retry later, or whether the source itself is unavailable.&lt;/p&gt;

&lt;p&gt;Monitoring and routine health checks also became part of the product’s development. They are invisible during normal use, but they reduce the time between a platform change and a fix.&lt;/p&gt;

&lt;p&gt;Reliability is not only about preventing errors. It is also about making errors understandable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Privacy boundaries should be visible
&lt;/h2&gt;

&lt;p&gt;From the beginning, the service was designed for publicly available Instagram content.&lt;/p&gt;

&lt;p&gt;It does not ask for an Instagram username, password, verification code, or access to a private account. Private, removed, and login-only posts are outside the supported use case.&lt;/p&gt;

&lt;p&gt;Making these limits visible is important. A product should not imply that it can bypass access controls, and users should understand that downloading publicly accessible media does not transfer copyright or permission to reuse it.&lt;/p&gt;

&lt;p&gt;FromInsta.net is an independent service and is not affiliated with Instagram or Meta.&lt;/p&gt;

&lt;h2&gt;
  
  
  Becoming an installable web app
&lt;/h2&gt;

&lt;p&gt;As the interface matured, FromInsta.net also became installable as a Progressive Web App.&lt;/p&gt;

&lt;p&gt;This was a natural extension of the mobile-first workflow. People who use the service regularly can open it from their home screen without treating it like a traditional application download.&lt;/p&gt;

&lt;p&gt;The goal was not to imitate a native app for its own sake. It was to make a frequently repeated web task quicker to access.&lt;/p&gt;

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

&lt;p&gt;The biggest lesson from building FromInsta.net is that a narrow product can still have considerable depth.&lt;/p&gt;

&lt;p&gt;A service with one main input field may need to handle multiple content types, asynchronous operations, multilingual layouts, mobile constraints, accessibility, external platform changes, and clear privacy boundaries.&lt;/p&gt;

&lt;p&gt;A few principles guided the project:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Keep the primary workflow obvious.&lt;/li&gt;
&lt;li&gt;Let users preview results before downloading.&lt;/li&gt;
&lt;li&gt;Treat loading and error states as part of the product.&lt;/li&gt;
&lt;li&gt;Design localization into the interface.&lt;/li&gt;
&lt;li&gt;Be explicit about limitations.&lt;/li&gt;
&lt;li&gt;Add features only when they reduce real user friction.&lt;/li&gt;
&lt;li&gt;Remember that reliability work is product work.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;FromInsta.net still begins with the same simple action: paste a public Instagram link.&lt;/p&gt;

&lt;p&gt;Everything added since then has been built around making the next step clearer, safer, and more useful.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://frominsta.net/en" rel="noopener noreferrer"&gt;Try FromInsta.net&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>product</category>
      <category>ux</category>
    </item>
    <item>
      <title>How I Built a Broken Link Scanner for Large Websites</title>
      <dc:creator>David</dc:creator>
      <pubDate>Thu, 17 Sep 2026 17:25:54 +0000</pubDate>
      <link>https://dev.to/david1390/how-i-built-a-broken-link-scanner-for-large-websites-3622</link>
      <guid>https://dev.to/david1390/how-i-built-a-broken-link-scanner-for-large-websites-3622</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2pedcv8bqja9912sngts.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2pedcv8bqja9912sngts.png" alt=" " width="799" height="333"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A broken link checker sounds like a weekend project.&lt;/p&gt;

&lt;p&gt;Fetch a page, collect every &lt;code&gt;&amp;lt;a href&amp;gt;&lt;/code&gt;, request each URL, and report anything that returns &lt;code&gt;404&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That approach works surprisingly well—until you point it at a real website.&lt;/p&gt;

&lt;p&gt;Large websites introduce duplicate URLs, redirects, external domains, missing assets, rate limits, malformed HTML, CSS imports, network failures, and thousands of pages competing for the same resources. At that point, a link checker stops being a script and starts becoming a distributed crawling system.&lt;/p&gt;

&lt;p&gt;This is how I built one using Laravel as the control plane and Go as the scanning engine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;

&lt;p&gt;I wanted Laravel to handle the application side of the product:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Users and authentication&lt;/li&gt;
&lt;li&gt;Projects and scan settings&lt;/li&gt;
&lt;li&gt;Usage limits&lt;/li&gt;
&lt;li&gt;Scheduled scans&lt;/li&gt;
&lt;li&gt;Result storage&lt;/li&gt;
&lt;li&gt;Progress updates&lt;/li&gt;
&lt;li&gt;Reports and exports&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Go was a better fit for the network-heavy part: fetching many URLs concurrently while keeping memory usage and concurrency under control.&lt;/p&gt;

&lt;p&gt;The final flow looks roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User starts a scan
        ↓
Laravel creates a queued scan
        ↓
A Go worker leases the scan
        ↓
The worker crawls and checks URLs concurrently
        ↓
Results and progress are sent back in batches
        ↓
Laravel stores the data and builds the report
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Go workers are independent processes. They register with the application, send heartbeats, and ask Laravel for available jobs.&lt;/p&gt;

&lt;p&gt;Laravel does not need to start a new operating-system process for every scan. A worker leases a queued scan through an API, processes it, and periodically reports its progress.&lt;/p&gt;

&lt;p&gt;This made it possible to add more scanner servers without changing the web application.&lt;/p&gt;

&lt;h2&gt;
  
  
  A queue is more than a list of URLs
&lt;/h2&gt;

&lt;p&gt;At first, my crawler had a basic queue:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Add the starting URL.&lt;/li&gt;
&lt;li&gt;Download it.&lt;/li&gt;
&lt;li&gt;extract its links.&lt;/li&gt;
&lt;li&gt;Add new internal URLs to the queue.&lt;/li&gt;
&lt;li&gt;Repeat until the queue is empty.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The difficult part was defining what “new” meant.&lt;/p&gt;

&lt;p&gt;These URLs might all point to the same resource:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://example.com/about
https://example.com/about#team
https://EXAMPLE.com/about
https://example.com:443/about
/about
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without normalization, the crawler may request the same page several times or even enter a loop.&lt;/p&gt;

&lt;p&gt;Every discovered URL therefore passes through a normalization step. Relative URLs are resolved against the current document, fragments are removed, hosts are normalized, and default ports are handled consistently.&lt;/p&gt;

&lt;p&gt;I still keep the original value found in the document. The normalized URL is useful for deduplication, but the original value is much more useful when someone needs to fix the link.&lt;/p&gt;

&lt;p&gt;The crawler also separates discovery from traversal.&lt;/p&gt;

&lt;p&gt;Internal pages can be added to the crawl frontier. External links are checked, but their pages are not recursively crawled. Otherwise, scanning one website could slowly turn into scanning the entire web.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why parallel scanning needs limits
&lt;/h2&gt;

&lt;p&gt;Checking URLs sequentially is polite but painfully slow. On a large website, network latency dominates the scan time.&lt;/p&gt;

&lt;p&gt;Go makes it easy to create a goroutine for every URL, but unlimited concurrency is not a real solution. It can overwhelm both the scanner and the website being scanned.&lt;/p&gt;

&lt;p&gt;I ended up using bounded concurrency at several levels:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A limit on simultaneous scans per worker&lt;/li&gt;
&lt;li&gt;A worker pool inside each scan&lt;/li&gt;
&lt;li&gt;A global limit on active HTTP requests&lt;/li&gt;
&lt;li&gt;Per-host concurrency limits&lt;/li&gt;
&lt;li&gt;Optional delays between requests to the same host&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Per-host limits are especially important.&lt;/p&gt;

&lt;p&gt;A page may contain resources from the main domain, a CDN, an analytics provider, and several external websites. The scanner can process different hosts in parallel without sending an unreasonable number of requests to any single server.&lt;/p&gt;

&lt;p&gt;The crawler also reacts to temporary failures. Responses such as &lt;code&gt;429 Too Many Requests&lt;/code&gt; and some &lt;code&gt;5xx&lt;/code&gt; errors should not immediately become permanent broken-link reports. They may require a retry, a delay, or temporary backoff for that host.&lt;/p&gt;

&lt;p&gt;Concurrency is not only a performance setting. It is part of the crawler’s behavior toward other websites.&lt;/p&gt;

&lt;h2&gt;
  
  
  HTTP status codes are not binary
&lt;/h2&gt;

&lt;p&gt;One of the earliest mistakes was treating every response outside the &lt;code&gt;2xx&lt;/code&gt; range as broken.&lt;/p&gt;

&lt;p&gt;In practice, the result is more nuanced:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;2xx&lt;/code&gt; usually means the resource is available.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;404&lt;/code&gt; and &lt;code&gt;410&lt;/code&gt; are strong broken-link signals.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;3xx&lt;/code&gt; requires inspecting the complete redirect chain.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;401&lt;/code&gt; and &lt;code&gt;403&lt;/code&gt; may mean the resource exists but rejects the scanner.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;429&lt;/code&gt; often means the scanner should slow down.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;5xx&lt;/code&gt; may be a temporary server failure.&lt;/li&gt;
&lt;li&gt;DNS, TLS, connection, and timeout errors need separate classifications.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A useful report should distinguish a confirmed broken URL from an inconclusive check.&lt;/p&gt;

&lt;p&gt;For example, an external website may return &lt;code&gt;403&lt;/code&gt; to automated clients while loading normally in a browser. Marking that URL as definitely broken would create a false positive and reduce trust in the entire report.&lt;/p&gt;

&lt;p&gt;Instead of storing only &lt;code&gt;is_broken&lt;/code&gt;, I found it more useful to preserve context:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;HTTP status&lt;/li&gt;
&lt;li&gt;Error category&lt;/li&gt;
&lt;li&gt;Content type&lt;/li&gt;
&lt;li&gt;Request duration&lt;/li&gt;
&lt;li&gt;Whether the link is internal or external&lt;/li&gt;
&lt;li&gt;Verification state&lt;/li&gt;
&lt;li&gt;Final URL&lt;/li&gt;
&lt;li&gt;Redirect history&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The report can then prioritize clear failures while still showing ambiguous cases separately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Redirects deserve first-class treatment
&lt;/h2&gt;

&lt;p&gt;Redirects are not just an implementation detail.&lt;/p&gt;

&lt;p&gt;A URL may return &lt;code&gt;301&lt;/code&gt;, lead to another redirect, cross to a different hostname, and finally return &lt;code&gt;200&lt;/code&gt;. Technically it works, but it may still be worth updating the original link.&lt;/p&gt;

&lt;p&gt;There are also redirect loops, excessively long chains, malformed &lt;code&gt;Location&lt;/code&gt; headers, and redirects to unsupported URL schemes.&lt;/p&gt;

&lt;p&gt;For every redirect, the scanner records the status and destination. That makes it possible to show a chain such as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/old-page [301]
→ /new-page [302]
→ https://www.example.com/new-page [200]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is much more actionable than reporting only the final status.&lt;/p&gt;

&lt;p&gt;Redirect targets also have to go through the same safety and scope checks as directly discovered URLs. Validating only the first URL is not enough because a public URL can redirect somewhere the scanner should never access.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links are not limited to &lt;code&gt;&amp;lt;a href&amp;gt;&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;A website can have no broken navigation links and still load incorrectly because an image, stylesheet, font, or script is missing.&lt;/p&gt;

&lt;p&gt;The scanner therefore looks at several kinds of references in HTML, including:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Navigation links&lt;/li&gt;
&lt;li&gt;Images and responsive &lt;code&gt;srcset&lt;/code&gt; values&lt;/li&gt;
&lt;li&gt;Stylesheets&lt;/li&gt;
&lt;li&gt;Script sources&lt;/li&gt;
&lt;li&gt;Iframes&lt;/li&gt;
&lt;li&gt;Audio and video resources&lt;/li&gt;
&lt;li&gt;Inline style URLs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;CSS needs its own extraction step. A stylesheet may contain more resources through:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@import&lt;/span&gt; &lt;span class="sx"&gt;url("/theme.css")&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;.hero&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;background-image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sx"&gt;url("/images/hero.webp")&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;@font-face&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;src&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sx"&gt;url("/fonts/site.woff2")&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;Those resources are easy to miss if the scanner only parses HTML.&lt;/p&gt;

&lt;p&gt;JavaScript requires a careful boundary. The scanner checks referenced JavaScript files as resources, but it does not try to execute arbitrary application code or statically understand every URL constructed at runtime. Doing that reliably would require a browser-based rendering layer and would significantly change the cost and behavior of a scan.&lt;/p&gt;

&lt;p&gt;Being explicit about that limitation is better than pretending a traditional crawler can see everything a browser can.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping track of where a link came from
&lt;/h2&gt;

&lt;p&gt;Knowing that a URL is broken is only half of the answer.&lt;/p&gt;

&lt;p&gt;The next question is always:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Where do I need to fix it?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The same missing image or outdated URL may appear on hundreds of pages. Storing a separate complete result for every occurrence creates a lot of repetitive data, while keeping only the target URL loses the source information.&lt;/p&gt;

&lt;p&gt;My solution was to treat the checked URL and its occurrences separately.&lt;/p&gt;

&lt;p&gt;A result represents the target that was checked. It can also contain source information such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The page where it was discovered&lt;/li&gt;
&lt;li&gt;The original URL value&lt;/li&gt;
&lt;li&gt;The HTML element and attribute&lt;/li&gt;
&lt;li&gt;The link text&lt;/li&gt;
&lt;li&gt;The CSS discovery method&lt;/li&gt;
&lt;li&gt;The number of occurrences&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This allows the report to say that one missing asset affects 150 pages instead of displaying 150 nearly identical errors.&lt;/p&gt;

&lt;p&gt;It also helps prioritize fixes. Repairing a broken global stylesheet is more important than fixing an unused link on an old article.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sending results in batches
&lt;/h2&gt;

&lt;p&gt;Large scans can produce a lot of data. Sending one API request per checked URL creates unnecessary overhead, while waiting until the entire crawl finishes risks losing everything if the worker stops.&lt;/p&gt;

&lt;p&gt;The scanner sends progress and results in batches.&lt;/p&gt;

&lt;p&gt;Each progress update can include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Newly completed results&lt;/li&gt;
&lt;li&gt;Current URL&lt;/li&gt;
&lt;li&gt;Number of checked and queued URLs&lt;/li&gt;
&lt;li&gt;Skipped URLs&lt;/li&gt;
&lt;li&gt;Crawl metrics&lt;/li&gt;
&lt;li&gt;A checkpoint of the remaining frontier&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Laravel validates and stores the batch, updates the scan counters, and broadcasts the latest progress to the interface.&lt;/p&gt;

&lt;p&gt;Batching reduced API overhead and made realtime progress inexpensive enough to be useful.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making scans resumable
&lt;/h2&gt;

&lt;p&gt;Long-running scans will eventually be interrupted.&lt;/p&gt;

&lt;p&gt;A worker can restart, a deployment can happen, a network connection can fail, or a scan can exceed its allowed runtime. Starting again from the first page wastes time and generates duplicate traffic.&lt;/p&gt;

&lt;p&gt;The crawler periodically saves enough state to continue:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;URLs waiting to be processed&lt;/li&gt;
&lt;li&gt;URLs already visited&lt;/li&gt;
&lt;li&gt;Pending tasks&lt;/li&gt;
&lt;li&gt;Crawl counters&lt;/li&gt;
&lt;li&gt;Per-host backoff state&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because the job is leased rather than permanently assigned, Laravel can recover an interrupted scan and make it available to a worker again.&lt;/p&gt;

&lt;p&gt;Checkpointing turned out not to be an optional reliability feature. For large websites, it is part of the normal execution model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning crawl data into a useful report
&lt;/h2&gt;

&lt;p&gt;Raw crawler output is useful for debugging, but it is not yet a product.&lt;/p&gt;

&lt;p&gt;The report needs to answer practical questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which links are definitely broken?&lt;/li&gt;
&lt;li&gt;Which checks were inconclusive?&lt;/li&gt;
&lt;li&gt;Which problems affect the most pages?&lt;/li&gt;
&lt;li&gt;Which URLs redirect?&lt;/li&gt;
&lt;li&gt;Where was each link found?&lt;/li&gt;
&lt;li&gt;Is the target a page, image, stylesheet, script, or another resource?&lt;/li&gt;
&lt;li&gt;Is the problem new, persistent, or resolved since the previous scan?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Laravel handles this part well. It stores the normalized results, calculates summaries, applies filters, generates CSV exports, and provides realtime progress while the Go scanner remains focused on crawling.&lt;/p&gt;

&lt;p&gt;This separation also keeps presentation decisions out of the scanner. I can change how an issue is grouped or displayed without modifying the networking engine.&lt;/p&gt;

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

&lt;p&gt;The hardest part of building a broken link scanner was not making HTTP requests. It was deciding what every response meant and preserving enough context to make the result actionable.&lt;/p&gt;

&lt;p&gt;A few lessons stood out:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Normalize URLs early, but keep the original values.&lt;/li&gt;
&lt;li&gt;Bound concurrency at both the scan and host levels.&lt;/li&gt;
&lt;li&gt;Treat redirects as data, not just transport behavior.&lt;/li&gt;
&lt;li&gt;Separate confirmed failures from uncertain responses.&lt;/li&gt;
&lt;li&gt;Record where every link was discovered.&lt;/li&gt;
&lt;li&gt;Send incremental batches instead of waiting for completion.&lt;/li&gt;
&lt;li&gt;Design for interrupted and resumed scans from the beginning.&lt;/li&gt;
&lt;li&gt;Keep crawling and reporting as separate responsibilities.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A small link-checking script can be written in an afternoon. A scanner that works reliably across large, unpredictable websites needs queueing, backpressure, recovery, careful classification, and a reporting layer.&lt;/p&gt;

&lt;p&gt;That engineering journey eventually became more than an internal experiment.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I ended up turning the scanner into &lt;a href="https://brokenlinks.pro/en" rel="noopener noreferrer"&gt;BrokenLinks.pro&lt;/a&gt; — a tool for scanning websites, following redirects, checking assets, and turning crawl results into a practical report.&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>webdev</category>
      <category>laravel</category>
      <category>go</category>
    </item>
  </channel>
</rss>
