<?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: Serhii Kalyna</title>
    <description>The latest articles on DEV Community by Serhii Kalyna (@serhii_kalyna_730b636889c).</description>
    <link>https://dev.to/serhii_kalyna_730b636889c</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%2F3820869%2Ff684a191-0ecb-4858-9f64-dd15a4ad9e07.png</url>
      <title>DEV Community: Serhii Kalyna</title>
      <link>https://dev.to/serhii_kalyna_730b636889c</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/serhii_kalyna_730b636889c"/>
    <language>en</language>
    <item>
      <title>How I recorded my first product demo in 54 seconds (OBS, ffmpeg, openai.fm)</title>
      <dc:creator>Serhii Kalyna</dc:creator>
      <pubDate>Sun, 23 Aug 2026 04:59:37 +0000</pubDate>
      <link>https://dev.to/serhii_kalyna_730b636889c/how-i-recorded-my-first-product-demo-in-54-seconds-obs-ffmpeg-openaifm-50eg</link>
      <guid>https://dev.to/serhii_kalyna_730b636889c/how-i-recorded-my-first-product-demo-in-54-seconds-obs-ffmpeg-openaifm-50eg</guid>
      <description>&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/A3j9vE0JLmU"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;I shipped my first product demo video this week for Convertify (my free image converter). 54 seconds, no team, no budget. Here is the exact toolchain and what I would change next time.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;OBS Studio&lt;/strong&gt; for screen recording at 1920x1080, 60fps, CRF 20.&lt;/p&gt;

&lt;p&gt;The 60fps is not vanity. Product demos live and die on smooth scroll and cursor motion. 30fps for a converter tool with drag-and-drop looks laggy even when the tool is fast. CRF 20 instead of the default 23 because text on white backgrounds compresses ugly at higher CRF: you get that faint mosaic around letter edges.&lt;/p&gt;

&lt;p&gt;Free, open source, deterministic. No mobile screen recording because I needed the desktop browser context (this is a "your iPhone photos on Windows" story).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ffmpeg&lt;/strong&gt; for trim and crop.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffmpeg &lt;span class="nt"&gt;-i&lt;/span&gt; raw.mkv &lt;span class="nt"&gt;-ss&lt;/span&gt; 3.2 &lt;span class="nt"&gt;-to&lt;/span&gt; 57.4 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-filter&lt;/span&gt;:v &lt;span class="s2"&gt;"crop=1920:1000:0:40"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt;:v libx264 &lt;span class="nt"&gt;-crf&lt;/span&gt; 20 &lt;span class="nt"&gt;-c&lt;/span&gt;:a copy trimmed.mp4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CLI over Kdenlive or DaVinci Resolve because I knew the exact cuts. Two ffmpeg commands beat 20 minutes of GUI timeline scrubbing. Crop removed the taskbar and top window chrome without re-encoding twice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;openai.fm&lt;/strong&gt; for voiceover. Ash voice.&lt;/p&gt;

&lt;p&gt;I tested Alloy, Fable, Nova, Ash. Ash landed clearest for technical explanation pacing. Alloy sounded like it was reading a bedtime story, Fable had a slight British lilt that felt wrong for the American tech audience I am targeting, Nova was too energetic.&lt;/p&gt;

&lt;p&gt;Hiring a voiceover artist starts at $50 for a one-off and rises fast if you iterate. openai.fm generates in 20 seconds per attempt. I regenerated the voiceover 6 times across script rewrites and it cost nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ffmpeg merge&lt;/strong&gt; as the last step.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ffmpeg &lt;span class="nt"&gt;-i&lt;/span&gt; trimmed.mp4 &lt;span class="nt"&gt;-i&lt;/span&gt; voiceover.mp3 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-c&lt;/span&gt;:v copy &lt;span class="nt"&gt;-c&lt;/span&gt;:a aac &lt;span class="nt"&gt;-shortest&lt;/span&gt; final.mp4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Separate audio and video passes. If voiceover timing slips, re-recording audio alone is 30 seconds. Re-recording the screen is 10 minutes plus 3 blown takes.&lt;/p&gt;

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

&lt;p&gt;v1 was 180 words. It opened with "Convertify is a free tool that converts HEIC files to JPG." I wrote it, recorded a test take, listened back, and it was plodding. It sounded like I was describing a product, not solving a problem.&lt;/p&gt;

&lt;p&gt;v4 is 118 words. It opens with "Your iPhone saves photos as HEIC format. Windows does not. Neither do most websites, Slack, or Discord."&lt;/p&gt;

&lt;p&gt;The pattern that unstuck me: &lt;strong&gt;start with the user's pain, not the product name&lt;/strong&gt;. The product name earns the right to appear once the pain is on screen. In 54 seconds you cannot afford to spend 5 seconds on brand introduction before the viewer knows why they should care.&lt;/p&gt;

&lt;p&gt;Four rewrites to arrive at something obvious. That is the honest part.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Script cursor movements ahead of time.&lt;/strong&gt; I improvised on takes 1 through 4 and each felt slightly different, which made editing harder. Take 5 with a mental map was noticeably tighter.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Test all voices first, not sequentially.&lt;/strong&gt; I burned an hour on Alloy before realizing I should have generated one line in each voice on day one.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Record the screen segment before writing the voiceover, not after.&lt;/strong&gt; I wrote v1 first, then discovered the cursor motion was faster than the words. Rewriting to fit visible pacing was easier than the reverse.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Convertify is at &lt;a href="https://convertifyapp.net" rel="noopener noreferrer"&gt;convertifyapp.net&lt;/a&gt;. Free, no signup, no watermarks, batch up to 20 files.&lt;/p&gt;

&lt;p&gt;Feedback on the video welcome, especially if you have shipped product demos as a solo dev and hit different tradeoffs.&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>showdev</category>
      <category>tutorial</category>
      <category>ffmpeg</category>
    </item>
    <item>
      <title>Week 20 building Convertify: the blog post I did not write</title>
      <dc:creator>Serhii Kalyna</dc:creator>
      <pubDate>Thu, 20 Aug 2026 18:59:08 +0000</pubDate>
      <link>https://dev.to/serhii_kalyna_730b636889c/week-20-building-convertify-the-blog-post-i-did-not-write-1d9m</link>
      <guid>https://dev.to/serhii_kalyna_730b636889c/week-20-building-convertify-the-blog-post-i-did-not-write-1d9m</guid>
      <description>&lt;h2&gt;
  
  
  The temptation
&lt;/h2&gt;

&lt;p&gt;Query cluster around "&lt;a href="https://convertifyapp.net/blogs/avif-vs-webp-vs-heic-2026" rel="noopener noreferrer"&gt;AVIF vs WebP vs HEIC 2026&lt;/a&gt;" is competitive and valuable. My blog id 1 covers it, published months ago, indexed, gets a small trickle of clicks. The obvious move on paper: write a fresh landing page for the head keyword, link internally, watch it rank.&lt;/p&gt;

&lt;p&gt;I did not do that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why extend instead
&lt;/h2&gt;

&lt;p&gt;Cannibalization. When two pages on the same domain target the same intent, Google splits the ranking signal. Both underperform. A new page also needs its own authority pool, its own indexing wait, its own crawl budget. On a 5-month-old domain still in sandbox, that trade never pays.&lt;/p&gt;

&lt;p&gt;The alternative: take the existing post from 8 to 12 sections, 8 to 12 FAQ items, add schema_faq, refresh updated_at, add 12 internal deep-links (10 to spoke pages, 2 cross-links to my benchmark post as data source). Same authority pool concentrated on one page. Fresh timestamp signal. New internal links flowing back to spokes.&lt;/p&gt;

&lt;p&gt;Rich Results Test came back clean: 3 valid items, 0 errors.&lt;/p&gt;

&lt;h2&gt;
  
  
  The safe SQL pattern
&lt;/h2&gt;

&lt;p&gt;Every extension goes through the same transactional shape:&lt;/p&gt;

&lt;p&gt;​&lt;code&gt;​&lt;/code&gt;​`sql&lt;br&gt;
BEGIN;&lt;br&gt;
UPDATE blogs&lt;br&gt;
SET sections = sections || jsonb_build_object(...),&lt;br&gt;
    faq = faq || jsonb_build_array(...),&lt;br&gt;
    schema_faq = ...,&lt;br&gt;
    read_time = 11,&lt;br&gt;
    updated_at = NOW()&lt;br&gt;
WHERE id = 1;&lt;/p&gt;

&lt;p&gt;SELECT id, jsonb_array_length(sections), jsonb_array_length(faq), updated_at&lt;br&gt;
FROM blogs WHERE id = 1;&lt;/p&gt;

&lt;p&gt;COMMIT;&lt;br&gt;
​&lt;code&gt;​&lt;/code&gt;​`&lt;/p&gt;

&lt;p&gt;Append-only via &lt;code&gt;||&lt;/code&gt;, zero DELETE statements, verify SELECT before COMMIT. If the row looks wrong I ROLLBACK. In 20 weeks of running this pattern I have not lost a section.&lt;/p&gt;

&lt;h2&gt;
  
  
  Also this week: closing the "30 MB PNG bug"
&lt;/h2&gt;

&lt;p&gt;Different topic, same theme. Every week for two months I logged "30 MB PNG output on backend" in the carried-bugs list. This week I sat down to fix it and realized there is no bug.&lt;/p&gt;

&lt;p&gt;A 24.5 megapixel iPhone Pro photo at full resolution is 4284 by 5712 pixels. Uncompressed RGBA that is 98 MB. PNG deflate on photo content with AI-generated mask edges compresses to about 25 to 35 percent. Output: 25 to 40 MB. That is the honest number. Anything smaller means downscaling the user's photo or silently switching to a lossy format.&lt;/p&gt;

&lt;p&gt;I shipped a WebP output toggle two weeks ago for users who want smaller files. That is the correct answer to "I want a smaller file." Silently downscaling PNG is the wrong answer.&lt;/p&gt;

&lt;p&gt;Closed the item in my strategy file as [CLOSED: not a bug]. Two months of guilt about a non-issue.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sandbox exit signals
&lt;/h2&gt;

&lt;p&gt;GSC 3-month rolling: avg pos 52. Last two days baseline: 787 impressions, pos 42. Impressions up 77 percent week over week.&lt;/p&gt;

&lt;p&gt;Google positions look worse on paper (52 vs 42) because impressions grew fastest on long-tail queries where I sit deeper. That is expected. The signal I care about is impressions trending up on the same content, meaning Google is testing me on more queries.&lt;/p&gt;

&lt;p&gt;Bing meanwhile: 942 impressions, CTR 1.49 percent. Bing has forgiven the young domain. Google is still auditing. The split between the two channels is normal for a 5-month-old site, not a bug in either.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Two decisions this week that looked like avoidance:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Not writing the new blog post&lt;/li&gt;
&lt;li&gt;Not "fixing" the 30 MB output&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Both were the right call. The scoreboard is starting to agree.&lt;/p&gt;

&lt;p&gt;Next week: YouTube demo #1 (&lt;a href="https://convertifyapp.net/heic-to-jpg" rel="noopener noreferrer"&gt;HEIC to JPG on iPhone&lt;/a&gt;), second Qwoted pitch using the benchmark AI-citation data as unique hook.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>buildinpublic</category>
      <category>indiehackers</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Week 19: Cloudflare Email Routing, Wikidata citations, and the benchmark that keeps citing itself</title>
      <dc:creator>Serhii Kalyna</dc:creator>
      <pubDate>Sun, 09 Aug 2026 08:24:59 +0000</pubDate>
      <link>https://dev.to/serhii_kalyna_730b636889c/week-19-cloudflare-email-routing-wikidata-citations-and-the-benchmark-that-keeps-citing-itself-k6d</link>
      <guid>https://dev.to/serhii_kalyna_730b636889c/week-19-cloudflare-email-routing-wikidata-citations-and-the-benchmark-that-keeps-citing-itself-k6d</guid>
      <description>&lt;h2&gt;
  
  
  Trust foundation continues. Real numbers from Bing AI, one 10-minute email setup, and a bug I keep punting.
&lt;/h2&gt;

&lt;h2&gt;
  
  
  The scoreboard
&lt;/h2&gt;

&lt;p&gt;Two projects continue in parallel. Convertify shipped an entity foundation over week 18: Wikidata item Q140754389 for the brand, centralized JSON-LD schema graph with &lt;a class="mentioned-user" href="https://dev.to/id"&gt;@id&lt;/a&gt; cross-references, IndexNow integrated into the sitemap generator, and an AI-crawler-explicit robots.txt. Week 19 was about seeing what those signals actually do.&lt;/p&gt;

&lt;p&gt;Bing Copilot AI Performance: &lt;strong&gt;up 3.1x quarter over quarter.&lt;/strong&gt; The top grounding query was "latest HEIC file format comparison," 43.24% share. The page Copilot cites is our own image format benchmark, published back in June with real measured numbers from a Rust + libvips pipeline.&lt;/p&gt;

&lt;p&gt;That last part is the interesting piece. The content didn't change between July and August. What changed was how readable and verifiable Convertify became as an entity, on paper.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cloudflare Email Routing was 10 minutes
&lt;/h2&gt;

&lt;p&gt;I needed a &lt;code&gt;serhii@convertifyapp.net&lt;/code&gt; for source verification on Qwoted, which is a HARO alternative for journalist requests. Cloudflare Email Routing does exactly this: enable it, add MX + SPF + DKIM records with one click, add destination address, add routing rule, done. Forwarding to my primary inbox in under 30 seconds.&lt;/p&gt;

&lt;p&gt;If you own a domain on Cloudflare and don't have a branded email yet, this is a genuinely free 10-minute upgrade. It also matters for pitch credibility. Journalists reading pitches from &lt;code&gt;founder@yoursaas.com&lt;/code&gt; respond at a different rate than pitches from &lt;code&gt;random@gmail.com&lt;/code&gt;. Whether or not that's fair is a different conversation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 30 MB PNG bug I keep punting
&lt;/h2&gt;

&lt;p&gt;Still not fixed. It's a 16-bit alpha channel that doesn't get cast to 8-bit after libvips bandjoin, so background-removal PNGs sometimes leave the server at 30 MB when they should be 2-3 MB. Symptom masked in the browser because the frontend canvas re-encodes to 8-bit as part of a compositing step that isn't even needed for the transparent-background case.&lt;/p&gt;

&lt;p&gt;I did close the canvas re-encode issue this week (early return when bg is transparent, skip the useless composite pass). The backend cast is next. It's a two-hour fix that keeps getting bumped by higher-leverage work. That's a reasonable priority call for one week; two weeks in a row it becomes a pattern I don't like.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned about AI search this week
&lt;/h2&gt;

&lt;p&gt;The clearest takeaway from Bing's AI Performance data: measured content beats theoretical content when AI grounding pipelines need a source to cite. Every benchmark I've published quotes real file sizes from actual conversions on the same production pipeline that handles user uploads. Every other image-format article on the internet is quoting codec spec figures, not measurements. Copilot seems to prefer the measurements.&lt;/p&gt;

&lt;p&gt;Entity signals amplify this. The Wikidata item and JSON-LD graph don't add new content, they add proof of who is making the claim. That's what changed between May and August, and citation counts moved with it.&lt;/p&gt;

&lt;p&gt;None of this generalizes cleanly. Bing is not Google is not ChatGPT is not Perplexity. But the principle probably does: give AI grounding pipelines something concrete and verifiable, tie your identity to it, and see what happens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next week
&lt;/h2&gt;

&lt;p&gt;Second benchmark asset covering AVIF vs WebP at real-world quality settings, more journalist pitches, and yes, actually fixing the 30 MB PNG bug.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>rust</category>
      <category>seo</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Building a 600-DPI PDF to JPG converter in Rust: the megapixel clamp that saved production</title>
      <dc:creator>Serhii Kalyna</dc:creator>
      <pubDate>Fri, 31 Jul 2026 17:49:02 +0000</pubDate>
      <link>https://dev.to/serhii_kalyna_730b636889c/building-a-600-dpi-pdf-to-jpg-converter-in-rust-the-megapixel-clamp-that-saved-production-47i9</link>
      <guid>https://dev.to/serhii_kalyna_730b636889c/building-a-600-dpi-pdf-to-jpg-converter-in-rust-the-megapixel-clamp-that-saved-production-47i9</guid>
      <description>&lt;p&gt;If you need to convert PDF to JPG at 600 DPI for print, archival, or high-res extraction, the short answer is: it works, but only if you clamp the output. A 600 DPI conversion on a large PDF page can produce a multi-gigabyte JPG per page. That crashes any server without a memory budget. I hit this in production on Convertify and had to build a fix that stayed close to the user's request while never letting the process explode.&lt;/p&gt;

&lt;p&gt;This post walks through the clamp function I wrote in Rust + libvips, the math behind it (spoiler: &lt;code&gt;sqrt&lt;/code&gt;), real numbers from production, and where the tradeoffs are. If you just want to use the tool, &lt;a href="https://convertifyapp.net/pdf-to-jpg" rel="noopener noreferrer"&gt;here's the 600 DPI PDF to JPG converter&lt;/a&gt;. Free, no signup, and the clamp described below runs on every request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why 600 DPI matters
&lt;/h2&gt;

&lt;p&gt;DPI in "PDF to image" isn't a physical property of the PDF. It's a scaling factor. When you tell libvips to load a PDF page at 600 DPI, you're saying: rasterize this page at 600 pixels per inch of &lt;em&gt;original document dimensions&lt;/em&gt;. So the output size depends entirely on how large the PDF thinks it is.&lt;/p&gt;

&lt;p&gt;For most cases, 300 DPI is enough. Print, most magazines, high-quality photo output, 300 DPI is the standard. 600 DPI shows up when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Archival scanning.&lt;/strong&gt; You want to preserve every detail from the original.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;High-quality print&lt;/strong&gt; on large formats: posters, technical drawings, engineering plans.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OCR on dense layouts.&lt;/strong&gt; Small fonts and unusual glyphs benefit from higher raster resolution.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Large-format documents.&lt;/strong&gt; Cartography, blueprints, wide-format schematics. This is where I first hit the problem. These PDFs often have huge native dimensions (24" × 36" or bigger), and at 600 DPI they explode.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If someone types &lt;code&gt;pdf to jpg 600 dpi&lt;/code&gt; into a search engine, they usually have one of these needs. And they don't want a tool that silently downgrades to 300 without telling them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The naive approach (and why it broke)
&lt;/h2&gt;

&lt;p&gt;Loading a PDF page at a given DPI in libvips is one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;img&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;VipsImage&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new_from_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{file_path}[dpi=600,page={page}]"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. libvips-poppler handles the rendering. On a typical A4 page, you get about a 35 megapixel image, around 8-15 MB as JPG. Fine.&lt;/p&gt;

&lt;p&gt;Now paste in a large-format PDF, say a 24" × 36" technical drawing. The same code produces a 300+ megapixel image. As raw pixel data in memory, that's over 1 GB. Encoded as JPG at quality 90, it can still land at 200-400 MB per page. Multi-page PDF at 600 DPI: you're looking at gigabytes of output, often before the user knows what's happening.&lt;/p&gt;

&lt;p&gt;Real symptoms I saw in production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Server OOM kills mid-conversion.&lt;/li&gt;
&lt;li&gt;Downloads that started and never finished because the file was too big for the browser to handle.&lt;/li&gt;
&lt;li&gt;Users complaining that a "small" PDF produced enormous outputs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The lazy fix is to cap DPI at 300 globally. But that punishes the honest use case: someone who legitimately wants 600 DPI on a normal-sized PDF gets stuck with a downgrade for the one weird case. So I needed something adaptive.&lt;/p&gt;

&lt;h2&gt;
  
  
  The megapixel budget
&lt;/h2&gt;

&lt;p&gt;The real constraint isn't DPI. It's &lt;strong&gt;output pixel count&lt;/strong&gt;. A 35 megapixel JPG and a 350 megapixel JPG are two very different animals, regardless of what DPI produced them.&lt;/p&gt;

&lt;p&gt;So instead of clamping DPI directly, I set a megapixel budget: "the output image must not exceed N megapixels." Then before every conversion, I probe the page dimensions at a low DPI, calculate what the requested DPI &lt;em&gt;would&lt;/em&gt; produce, and if it's over budget, back off the DPI just enough to fit.&lt;/p&gt;

&lt;p&gt;On Convertify, my budget is currently 100 MP. That's more than enough for 600 DPI on any normal document (a standard letter-size page at 600 DPI is only ~34 MP). It only kicks in on the pathological cases: huge PDFs, or edge geometry where the input pushes past what the server can honestly deliver.&lt;/p&gt;

&lt;h2&gt;
  
  
  The clamp function
&lt;/h2&gt;

&lt;p&gt;Here's the actual function that ships in production:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;clamp_dpi_to_megapixels&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;i32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;requested_dpi&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;i32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;max_megapixels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;f64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;i32&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;probe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;VipsImage&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new_from_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{file_path}[dpi=72,page={page}]"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;probe&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="nf"&gt;.get_width&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;f64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="nf"&gt;.get_height&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;f64&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;requested_dpi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;

    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requested_dpi&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;f64&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;72.0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;mp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;1_000_000.0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;mp&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;max_megapixels&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;requested_dpi&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;factor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_megapixels&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;mp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.sqrt&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;requested_dpi&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;f64&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;factor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.floor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;i32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;72&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let me walk through it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Probe at 72 DPI.&lt;/strong&gt; libvips-poppler treats 72 DPI as PDF's native "point" grid, so loading at 72 is cheap and gives us the true page dimensions in points. An 8.5" × 11" page comes back as 612 × 792 pixels.&lt;/p&gt;

&lt;p&gt;If the probe fails (page number out of range, malformed PDF, anything else), I return the requested DPI unchanged. The downstream conversion will fail with a proper error. This isn't the place to swallow it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Calculate what the requested DPI would produce.&lt;/strong&gt; Multiply the base dimensions by &lt;code&gt;requested_dpi / 72&lt;/code&gt;, then convert to megapixels. That same 8.5" × 11" page at 600 DPI: &lt;code&gt;(612 × 8.33) × (792 × 8.33) / 1_000_000 ≈ 33.6 MP&lt;/code&gt;. Well under budget.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Under budget, return unchanged.&lt;/strong&gt; If the naive DPI produces ≤ max MP, we're done. This is the happy path, probably 95%+ of user requests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Over budget, back off proportionally.&lt;/strong&gt; Here's where the math matters. To bring 350 MP down to 100 MP, we need to shrink by a factor of &lt;code&gt;100/350&lt;/code&gt;. But that ratio is &lt;em&gt;pixels&lt;/em&gt;. DPI shrinks pixels quadratically. Halve the DPI, quarter the pixels. So the DPI factor is &lt;code&gt;sqrt(100/350) ≈ 0.53&lt;/code&gt;. Applied to 600 DPI: &lt;code&gt;600 × 0.53 ≈ 318 DPI&lt;/code&gt;. Still well above the safe fallback of 300, but low enough to fit.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;.floor()&lt;/code&gt; and &lt;code&gt;.max(72)&lt;/code&gt; are safety rails. Floor prevents rounding up past the budget; 72 is the effective minimum, since you can't rasterize below the PDF's native grid without producing garbage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why sqrt (a short math digression)
&lt;/h2&gt;

&lt;p&gt;Nothing exotic here, but it's worth stating clearly because it's the whole insight.&lt;/p&gt;

&lt;p&gt;Pixel count scales with the &lt;em&gt;square&lt;/em&gt; of DPI, not with DPI itself. Double the DPI, both width and height double, so pixels quadruple. Triple the DPI, pixels grow 9×. The relationship is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pixels = base_pixels × (dpi / 72)²
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Solve for DPI when you know the target pixel count:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dpi = 72 × sqrt(target_pixels / base_pixels)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which is exactly what the clamp does, just factored through &lt;code&gt;requested_dpi&lt;/code&gt;. If you skip the sqrt and multiply DPI directly by the pixel ratio, you over-correct. You end up with a DPI far lower than the budget actually allows, and the user gets a needlessly low-resolution output.&lt;/p&gt;

&lt;p&gt;I've seen "clamp DPI by proportional scaling" written in a few places online. Their outputs come out at a fraction of what they should be. This is why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real numbers from production
&lt;/h2&gt;

&lt;p&gt;Here's what the clamp actually does at 100 MP budget:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Input document&lt;/th&gt;
&lt;th&gt;Base @ 72 DPI&lt;/th&gt;
&lt;th&gt;Requested&lt;/th&gt;
&lt;th&gt;MP at requested&lt;/th&gt;
&lt;th&gt;Clamped DPI&lt;/th&gt;
&lt;th&gt;Output MP&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Letter (8.5" × 11")&lt;/td&gt;
&lt;td&gt;612 × 792 (0.48 MP)&lt;/td&gt;
&lt;td&gt;600&lt;/td&gt;
&lt;td&gt;33.6 MP&lt;/td&gt;
&lt;td&gt;600&lt;/td&gt;
&lt;td&gt;33.6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A3 (11.7" × 16.5")&lt;/td&gt;
&lt;td&gt;842 × 1191 (1.0 MP)&lt;/td&gt;
&lt;td&gt;600&lt;/td&gt;
&lt;td&gt;69.6 MP&lt;/td&gt;
&lt;td&gt;600&lt;/td&gt;
&lt;td&gt;69.6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Large blueprint (24" × 36")&lt;/td&gt;
&lt;td&gt;1728 × 2592 (4.5 MP)&lt;/td&gt;
&lt;td&gt;600&lt;/td&gt;
&lt;td&gt;311 MP&lt;/td&gt;
&lt;td&gt;340&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wide schematic (36" × 60")&lt;/td&gt;
&lt;td&gt;2592 × 4320 (11.2 MP)&lt;/td&gt;
&lt;td&gt;600&lt;/td&gt;
&lt;td&gt;778 MP&lt;/td&gt;
&lt;td&gt;215&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pattern: on normal documents, 600 DPI passes through untouched. On the edge cases where the naive approach would produce hundreds of megapixels, DPI is scaled back to whatever fits the budget, and the user still gets an output much closer to their intent than a global 300 DPI fallback would give.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to use TIFF instead
&lt;/h2&gt;

&lt;p&gt;Worth flagging: if you're doing archival work at 600 DPI, JPG is often a poor fit. JPG is lossy, and at high DPI you're usually preserving fine detail specifically because you want it lossless: text edges, thin lines, small annotations.&lt;/p&gt;

&lt;p&gt;For those cases, TIFF is the right format. LZW-compressed TIFF is lossless at reasonable file sizes, and every archival system on earth reads it. libvips handles it natively.&lt;/p&gt;

&lt;p&gt;I ship the same clamp behind a &lt;a href="https://convertifyapp.net/pdf-to-tiff" rel="noopener noreferrer"&gt;PDF to TIFF converter&lt;/a&gt;, just with a different encoder. If you're building something similar, don't force JPG for 600 DPI archival work. Support TIFF too. Your users will thank you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd change if I did it again
&lt;/h2&gt;

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

&lt;p&gt;&lt;strong&gt;Per-page feedback.&lt;/strong&gt; Right now the clamp runs silently. A 200-page document where one map spread got scaled from 600 to 340 DPI: the user doesn't see it. Silent is friendly for most cases, but for archival workflows people want to know exactly which pages hit the budget. I'd surface it in the response.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Adaptive budget.&lt;/strong&gt; 100 MP is a hardcoded constant. On a bigger server it could be 250. For a mobile-only session, 40 makes more sense. Deriving the budget from request context (or letting users opt into a higher tier) would be a small config change with real impact.&lt;/p&gt;

&lt;p&gt;Neither is urgent, but both are on the list.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;If you need 600 DPI PDF to JPG conversion right now: &lt;a href="https://convertifyapp.net/pdf-to-jpg" rel="noopener noreferrer"&gt;Convertify does it&lt;/a&gt;. Free, no signup, batch up to 10 files, and the clamp function above runs on every request. Upload something huge and you'll get the highest DPI that fits a sane memory budget. Not a crash, not a silent global downgrade.&lt;/p&gt;

&lt;p&gt;If you're building your own PDF-to-image tool and hit similar problems, the function above is production-tested and short enough to drop in as-is. Adjust &lt;code&gt;max_megapixels&lt;/code&gt; to fit your infrastructure and move on.&lt;/p&gt;

&lt;p&gt;If you want to see more of Convertify's engineering, I write about it weekly in &lt;a href="https://dev.to/serhii_kalyna_730b636889c"&gt;Building Convertify in Public&lt;/a&gt; on this account. libvips tricks, Rust patterns, real production bugs. No filler.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>performance</category>
      <category>production</category>
      <category>rust</category>
    </item>
    <item>
      <title>Building in public, week 17: turning one feature into a page cluster (and the internal-linking layer nobody sees)</title>
      <dc:creator>Serhii Kalyna</dc:creator>
      <pubDate>Sun, 05 Jul 2026 18:44:45 +0000</pubDate>
      <link>https://dev.to/serhii_kalyna_730b636889c/building-in-public-week-17-turning-one-feature-into-a-page-cluster-and-the-internal-linking-2nap</link>
      <guid>https://dev.to/serhii_kalyna_730b636889c/building-in-public-week-17-turning-one-feature-into-a-page-cluster-and-the-internal-linking-2nap</guid>
      <description>&lt;p&gt;Week 16 shipped the AI background remover: Rust-native, &lt;code&gt;ort&lt;/code&gt; + ISNet + libvips, no Python. That was the feature. Week 17 was not about writing more of it. It was about the boring, high-leverage part that most side projects skip: turning one working feature into pages that can actually rank, and wiring those pages together so search engines can find them.&lt;/p&gt;

&lt;p&gt;No new engine code this week. Just leverage on what already existed. Here is what that actually looked like.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: a hub with nothing pointing at it
&lt;/h2&gt;

&lt;p&gt;The background remover lives at &lt;code&gt;/remove-background&lt;/code&gt;. That is the hub. The plan was classic hub-and-spoke: one general tool page, then use-case spokes that each target a specific intent (removing a signature background, prepping an Amazon product photo, and so on).&lt;/p&gt;

&lt;p&gt;I built two spokes this week. But halfway through, I looked at how internal links actually worked on the site and found the real problem: &lt;strong&gt;nothing linked from the hub to the spokes.&lt;/strong&gt; The spokes linked back to the hub in their body text, but the hub had no idea they existed. Neither did the ~180 converter pages.&lt;/p&gt;

&lt;p&gt;Tool links on the site were hardcoded in a frontend constant, roughly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;IMAGE_TOOLS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Compress JPG&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/compress/jpg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;compress&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Resize Image&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/resize-image&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;resize&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Crop Image&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/crop-image&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;crop&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Images to PDF&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/images-to-pdf&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;convert&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That list covered the converter tools. It did not include the background remover or its spokes at all. So the new pages were orphans: reachable only through the sitemap, with no internal links carrying any signal to them. For a domain that is still young and still earning Google's trust, orphan pages get discovered slowly and rank even slower.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: one constant as the source of truth
&lt;/h2&gt;

&lt;p&gt;Instead of hardcoding links in three different places, I made a single constant describe the whole cluster:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BACKGROUND_TOOLS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Background Remover&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/background-remover&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Signature Background&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/remove-signature-background&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Amazon Product Photo&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/amazon-product-photo-background&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then a small server component renders it, with an &lt;code&gt;exclude&lt;/code&gt; prop so each page drops itself from the list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;BackgroundTools&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;exclude&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;exclude&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tools&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;BACKGROUND_TOOLS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;exclude&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;section&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h2&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Background Removal&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h2&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(({&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;href&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Link&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Link&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;section&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The nice part is that the same constant became the source of truth for &lt;em&gt;detecting&lt;/em&gt; a background-removal page too. The main layout used to repeat the same array-of-slugs check three separate times. That collapsed into one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isBgRemoval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;BACKGROUND_TOOLS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s2"&gt;`/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So now: the hub links to both spokes, each spoke links to the hub and its sibling, and the whole thing renders only on background-removal pages. Adding a fourth spoke later is one line in the constant and it is cross-linked everywhere automatically. I also added the same list as a footer column, which puts the links on every page of the site, not just the cluster.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that only shows up in production
&lt;/h2&gt;

&lt;p&gt;The two spokes are stored as structured content in the database, and each section body is rendered with &lt;code&gt;dangerouslySetInnerHTML&lt;/code&gt; inside a &lt;code&gt;&amp;lt;p&amp;gt;&lt;/code&gt;. On one spoke I added a small "before you upload" checklist as a &lt;code&gt;&amp;lt;ul&amp;gt;&lt;/code&gt;. It looked fine locally. In production, React threw a hydration mismatch.&lt;/p&gt;

&lt;p&gt;The reason is plain once you see it: a &lt;code&gt;&amp;lt;ul&amp;gt;&lt;/code&gt; is not valid inside a &lt;code&gt;&amp;lt;p&amp;gt;&lt;/code&gt;. The browser silently auto-closes the paragraph before the list, so the DOM the browser builds does not match the HTML React rendered on the server, and hydration fails. The fix was to keep the section body to inline content only, so the checklist became &lt;code&gt;&amp;lt;br&amp;gt;&lt;/code&gt;-separated lines instead of a real list.&lt;/p&gt;

&lt;p&gt;The lesson I actually kept: I added a build-time guard that scans every section body for block-level tags (&lt;code&gt;ul&lt;/code&gt;, &lt;code&gt;ol&lt;/code&gt;, &lt;code&gt;li&lt;/code&gt;, &lt;code&gt;div&lt;/code&gt;, &lt;code&gt;table&lt;/code&gt;, headings) and fails the build if it finds one. The class of bug is now impossible to ship again, which matters more than the one fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest numbers
&lt;/h2&gt;

&lt;p&gt;Building in public means posting the numbers even when they are boring or bad.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Background pages live:&lt;/strong&gt; 3 (hub + 2 spokes). The plan asked for 1 or 2, so this part went well.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Benchmark on the real VPS:&lt;/strong&gt; the background remover runs at about p50 2.4s, p95 9.6s per image on a 4-core box. &lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI citations:&lt;/strong&gt; the site is cited 75 times across Microsoft Copilot, mostly on HEIC content. That is visibility that never shows up in a rank tracker, and I only found it by looking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google:&lt;/strong&gt; still sandboxed. Impressions exist, positions are deep (average around the mid-40s), clicks are a trickle. Bing, meanwhile, ranks the same pages on page one. Same content, different trust curve. This is normal for a young domain and I am not fighting it, just waiting it out while the content compounds.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What week 18 looks like
&lt;/h2&gt;

&lt;p&gt;More spokes, but slowly. Google's spam updates punish thin template-swap pages, so the rule is one or two good pages with genuine unique data, not ten shells. On the list: a couple more use-case spokes, a low-competition &lt;code&gt;webp remove background&lt;/code&gt; page I found in my keyword data, and continuing the slow grind of backlinks to lift domain authority, which is the real ceiling right now.&lt;/p&gt;

&lt;p&gt;The feature was week 16. The leverage was week 17. If you are building something similar, the thing I would repeat is this: shipping the feature is the easy half. Making it findable, and keeping the pages honestly connected, is the half that actually decides whether anyone ever sees it.&lt;/p&gt;

&lt;p&gt;You can try the background remover here: &lt;a href="https://convertifyapp.net/remove-background" rel="noopener noreferrer"&gt;https://convertifyapp.net/remove-background&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>seo</category>
      <category>nextjs</category>
      <category>rust</category>
    </item>
    <item>
      <title>I added AI background removal to my image converter in a week, in Rust, no Python</title>
      <dc:creator>Serhii Kalyna</dc:creator>
      <pubDate>Mon, 29 Jun 2026 08:10:04 +0000</pubDate>
      <link>https://dev.to/serhii_kalyna_730b636889c/i-added-ai-background-removal-to-my-image-converter-in-a-week-in-rust-no-python-2d75</link>
      <guid>https://dev.to/serhii_kalyna_730b636889c/i-added-ai-background-removal-to-my-image-converter-in-a-week-in-rust-no-python-2d75</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Part of an ongoing build-in-public series on Convertify, a free image/file converter I build solo. This week: background removal. The honest version, with the walls I hit.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Most "remove image background" tutorials end with &lt;code&gt;pip install rembg&lt;/code&gt; and a happy screenshot. Mine started with a constraint: &lt;strong&gt;my whole backend is Rust, and I did not want to bolt a Python process onto it just to run one model.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here is how the week went. The good parts, and the three or four times I stared at a compiler error wondering if the constraint was worth it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The starting point
&lt;/h2&gt;

&lt;p&gt;Convertify is a free image converter. The backend is &lt;strong&gt;Rust + Axum + libvips&lt;/strong&gt;, the model has to run &lt;strong&gt;CPU-only on a modest VPS&lt;/strong&gt;, and there is no GPU anywhere in the budget. The obvious path for background removal is &lt;code&gt;rembg&lt;/code&gt;, which is excellent, but it is Python and ships as a separate server process. Adding it would mean a second runtime, a second thing to deploy, a second thing to crash at 3am.&lt;/p&gt;

&lt;p&gt;So the question for the week was simple: &lt;strong&gt;can I run the same models rembg uses, but natively in Rust?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Short answer: yes. &lt;code&gt;rembg&lt;/code&gt; is, under the hood, just ONNX models plus some image pre and post processing. The models (u2net, isnet, silueta) are all &lt;code&gt;.onnx&lt;/code&gt; files. If I can run ONNX in Rust and do the image work in libvips (which I already have), there is no Python in the picture at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The plan
&lt;/h2&gt;

&lt;p&gt;The pipeline for background removal is not magic, it is five boring steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Decode the image, resize a copy to the model input size&lt;/li&gt;
&lt;li&gt;Normalize the pixels into a tensor&lt;/li&gt;
&lt;li&gt;Run inference, get a mask (one value per pixel: subject or background)&lt;/li&gt;
&lt;li&gt;Normalize the mask, resize it back to the original size&lt;/li&gt;
&lt;li&gt;Composite the mask onto the original as an alpha channel, export a transparent PNG&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Steps 1, 4, 5 are libvips, which I already use everywhere. Step 3 is ONNX Runtime via the &lt;a href="https://github.com/pykeio/ort" rel="noopener noreferrer"&gt;&lt;code&gt;ort&lt;/code&gt;&lt;/a&gt; crate. Step 2 is a tight Rust loop. No Python anywhere.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[dependencies]&lt;/span&gt;
&lt;span class="py"&gt;ort&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="py"&gt;"&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="err"&gt;-rc.&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="s"&gt;", features = ["&lt;/span&gt;&lt;span class="err"&gt;download-binaries&lt;/span&gt;&lt;span class="s"&gt;"] }&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;download-binaries&lt;/code&gt; feature pulls a CPU build of ONNX Runtime at build time, so there is nothing to install on the box. That alone deleted half the "deploy a Python service" anxiety.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wall #1: the model name lied about its size
&lt;/h2&gt;

&lt;p&gt;I grabbed &lt;code&gt;isnet-general-use.onnx&lt;/code&gt; from the rembg releases, expecting ~44 MB. What landed was &lt;strong&gt;171 MB&lt;/strong&gt;. My first thought was a broken download or an HTML error page renamed to &lt;code&gt;.onnx&lt;/code&gt;. Quick check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;file models/isnet-general-use.onnx
&lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; 200 models/isnet-general-use.onnx | xxd | &lt;span class="nb"&gt;head&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The header showed a real &lt;code&gt;pytorch 1.13.1&lt;/code&gt; signature and tensor names like &lt;code&gt;input_image&lt;/code&gt; and &lt;code&gt;conv_in.weight&lt;/code&gt;. So it was a valid ONNX model, just heavier than the name suggested. Lesson: verify the file is actually what you think before you spend an hour debugging "why is RAM so high."&lt;/p&gt;

&lt;h2&gt;
  
  
  Wall #2: &lt;code&gt;ort&lt;/code&gt; errors are not &lt;code&gt;Send + Sync&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;First compile against &lt;code&gt;anyhow&lt;/code&gt; and I get hit with this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;the trait `Sync` is not implemented for `NonNull&amp;lt;OrtSessionOptions&amp;gt;`
required for `anyhow::Error` to implement `From&amp;lt;ort::Error&amp;lt;SessionBuilder&amp;gt;&amp;gt;`
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;anyhow::Error&lt;/code&gt; wants &lt;code&gt;Send + Sync&lt;/code&gt;. The &lt;code&gt;ort&lt;/code&gt; error type holds raw pointers into the ONNX Runtime C++ session, which are not &lt;code&gt;Sync&lt;/code&gt;. So &lt;code&gt;?&lt;/code&gt; straight into &lt;code&gt;anyhow&lt;/code&gt; does not compile.&lt;/p&gt;

&lt;p&gt;The fix is to stringify the error at the boundary. &lt;code&gt;Display&lt;/code&gt; gives you a &lt;code&gt;String&lt;/code&gt;, and &lt;code&gt;String&lt;/code&gt; is &lt;code&gt;Send + Sync&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;intra_threads&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;.map_err&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="nd"&gt;anyhow!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ort session init: {e}"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pointer never leaves, only the message does. Once I understood &lt;em&gt;why&lt;/em&gt;, the pattern was mechanical: every &lt;code&gt;ort&lt;/code&gt; &lt;code&gt;?&lt;/code&gt; that crosses into &lt;code&gt;anyhow&lt;/code&gt; gets a &lt;code&gt;.map_err(|e| anyhow!("...: {e}"))?&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wall #3: &lt;code&gt;run&lt;/code&gt; takes &lt;code&gt;&amp;amp;mut self&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;This one actually changed my architecture. In this &lt;code&gt;ort&lt;/code&gt; version, &lt;code&gt;Session::run&lt;/code&gt; takes &lt;code&gt;&amp;amp;mut self&lt;/code&gt;. I had the session behind an &lt;code&gt;Arc&lt;/code&gt; in my Axum app state so it could be shared. You cannot get &lt;code&gt;&amp;amp;mut&lt;/code&gt; through an &lt;code&gt;Arc&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cannot borrow `self.session` as mutable, as it is behind a `&amp;amp;` reference
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Options were a session pool, or a &lt;code&gt;Mutex&lt;/code&gt;. Since my traffic is low and I gate inference to one at a time anyway, I wrapped the session in a &lt;code&gt;Mutex&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;pub&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="n"&gt;BgRemover&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Mutex&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;Session&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;remove(&amp;amp;self)&lt;/code&gt; stays &lt;code&gt;&amp;amp;self&lt;/code&gt;, so &lt;code&gt;Arc&amp;lt;BgRemover&amp;gt;&lt;/code&gt; still works in app state. The &lt;code&gt;Mutex&lt;/code&gt; hands out the &lt;code&gt;&amp;amp;mut&lt;/code&gt; for the single inference call. With a one-permit semaphore in front, the mutex never even contends. When traffic grows, the upgrade path is a pool of sessions, but that is a future-me problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wall #4: &lt;code&gt;*mut VipsImage&lt;/code&gt; is not &lt;code&gt;Send&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;libvips image pointers are not &lt;code&gt;Send&lt;/code&gt;, which means they cannot be held across an &lt;code&gt;.await&lt;/code&gt;. If I ran inference directly in the async handler, the borrow checker would stop me, and even if it did not, a multi-second CPU inference on an async worker thread would freeze the whole runtime.&lt;/p&gt;

&lt;p&gt;The answer is &lt;code&gt;spawn_blocking&lt;/code&gt;. The entire libvips + inference chain runs on a dedicated blocking thread and returns finished PNG bytes (which &lt;em&gt;are&lt;/em&gt; &lt;code&gt;Send&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;png&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;tokio&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;task&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;spawn_blocking&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;move&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;_permit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;permit&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;       &lt;span class="c1"&gt;// hold the semaphore for the whole job&lt;/span&gt;
    &lt;span class="n"&gt;remover&lt;/span&gt;&lt;span class="nf"&gt;.remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="k"&gt;.await&lt;/span&gt;&lt;span class="o"&gt;??&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every &lt;code&gt;VipsImage&lt;/code&gt; is created and dropped inside that closure, never crossing an await point. The async runtime stays free to serve everything else while one image is being cut out.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that surprised me: privacy came for free
&lt;/h2&gt;

&lt;p&gt;Because the handler returns the PNG straight in the HTTP response, &lt;strong&gt;the image is never written to disk&lt;/strong&gt;. It comes in as multipart bytes, gets processed in memory, and the result streams back. Nothing is stored, nothing is queued, nothing to clean up.&lt;/p&gt;

&lt;p&gt;I did not plan that as a feature, it fell out of the architecture. But "your photo is processed in memory and never saved" is a genuinely strong thing to be able to say, and it is true, not marketing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does it actually work?
&lt;/h2&gt;

&lt;p&gt;Yes. First real test through Postman with a HEIC photo: &lt;strong&gt;200 OK, transparent PNG out.&lt;/strong&gt; The model is ISNet (the IS-Net dichotomous segmentation architecture), and on clean subjects, products, people, logos, the cutout is sharp.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell past-me
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;rembg&lt;/code&gt; is "just" ONNX + image ops. If you already have an image library, you can skip the Python entirely with &lt;code&gt;ort&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;ort&lt;/code&gt; 2.0 API churns between rc versions. Pin the exact version and expect to fix one or two method names.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;spawn_blocking&lt;/code&gt; is not optional for CPU-heavy, non-&lt;code&gt;Send&lt;/code&gt; work. It is the whole reason the server stays responsive.&lt;/li&gt;
&lt;li&gt;Constraints ("no Python") are annoying in the moment and clarifying in hindsight. The Rust-native version is one binary, one deploy, nothing to babysit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to see the result, background removal is live and free (no signup, no watermark) on &lt;a href="https://convertifyapp.net/remove-background" rel="noopener noreferrer"&gt;Convertify&lt;/a&gt;. Upload a photo, get a transparent PNG. It runs the exact pipeline above.&lt;/p&gt;

&lt;p&gt;Next week: turning one tool into a set of use-case pages (passport photos, product shots) without drowning in duplicate content. That one is more SEO than Rust, but the build-in-public log continues.&lt;/p&gt;

&lt;p&gt;What would you have done differently on the &lt;code&gt;&amp;amp;mut self&lt;/code&gt; session problem? A pool, a mutex, something smarter? Curious how others handle shared ONNX sessions under load.&lt;/p&gt;

</description>
      <category>rust</category>
      <category>machinelearning</category>
      <category>webdev</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>I Benchmarked 17 Image Conversions on My Production Server. Some Results Were Not What I Expected.</title>
      <dc:creator>Serhii Kalyna</dc:creator>
      <pubDate>Sat, 20 Jun 2026 06:01:01 +0000</pubDate>
      <link>https://dev.to/serhii_kalyna_730b636889c/i-benchmarked-17-image-conversions-on-my-production-server-some-results-were-not-what-i-expected-1j4f</link>
      <guid>https://dev.to/serhii_kalyna_730b636889c/i-benchmarked-17-image-conversions-on-my-production-server-some-results-were-not-what-i-expected-1j4f</guid>
      <description>&lt;p&gt;I run &lt;a href="https://convertifyapp.net" rel="noopener noreferrer"&gt;Convertify&lt;/a&gt;, a free image converter built on Rust and libvips. Last week I decided to stop guessing about format performance and actually measure it. I took 50 real images (26 PNGs, 24 iPhone HEIC photos), ran 17 conversions through the production pipeline, and recorded every file size and encode time.&lt;/p&gt;

&lt;p&gt;Some results confirmed what everyone says. Others did not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three results that surprised me
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Converting HEIC to JPG makes files 14% &lt;em&gt;bigger&lt;/em&gt;, not smaller.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This one hurt. "Convert iPhone photos to JPG" is probably the most common advice on the internet. But HEIC wraps the HEVC codec, which compresses roughly 2x better than JPEG. Going from a better codec to a worse one means the file grows. Every time.&lt;/p&gt;

&lt;p&gt;If you actually want smaller iPhone photos: HEIC to WebP saves 43%, HEIC to AVIF saves 57%.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. AVIF encodes 7x slower than WebP for 10% more compression.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;AVIF Q63: 55 KB, 1.30s per image.&lt;br&gt;
WebP Q80: 61 KB, 0.19s per image.&lt;/p&gt;

&lt;p&gt;That is a 10% size difference for a 7x speed penalty. For a single hero image, nobody cares. For a batch pipeline processing thousands of product photos, that is the difference between 3 minutes and 21 minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. PNG at 600 DPI is &lt;em&gt;smaller&lt;/em&gt; than PNG at 300 DPI when rasterizing PDFs.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This was the weirdest one. I was benchmarking PDF-to-image and noticed PNG output &lt;em&gt;shrank&lt;/em&gt; from 2,221 KB at 300 DPI to 1,660 KB at 600 DPI. I spent an hour convinced I had a bug.&lt;/p&gt;

&lt;p&gt;Turns out it is a real property of PNG encoding. Higher DPI renders smoother gradients between adjacent pixels, and PNG's prediction filters (Paeth, sub, up) compress smooth gradients dramatically better than the sharp edges you get at lower resolutions. Not a bug. Just PNG being PNG.&lt;/p&gt;

&lt;h2&gt;
  
  
  The quick reference table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Conversion&lt;/th&gt;
&lt;th&gt;Size change&lt;/th&gt;
&lt;th&gt;Speed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;JPG to WebP Q80&lt;/td&gt;
&lt;td&gt;-64%&lt;/td&gt;
&lt;td&gt;0.19s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JPG to AVIF Q63&lt;/td&gt;
&lt;td&gt;-68%&lt;/td&gt;
&lt;td&gt;1.30s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PNG to WebP Q80&lt;/td&gt;
&lt;td&gt;-92%&lt;/td&gt;
&lt;td&gt;0.21s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PNG to JPG Q85&lt;/td&gt;
&lt;td&gt;-86%&lt;/td&gt;
&lt;td&gt;0.07s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HEIC to JPG Q85&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+14%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1.90s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HEIC to WebP Q80&lt;/td&gt;
&lt;td&gt;-43%&lt;/td&gt;
&lt;td&gt;5.64s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HEIC to AVIF Q63&lt;/td&gt;
&lt;td&gt;-57%&lt;/td&gt;
&lt;td&gt;14.52s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WebP to JPG Q85&lt;/td&gt;
&lt;td&gt;+60%&lt;/td&gt;
&lt;td&gt;0.09s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AVIF to JPG Q85&lt;/td&gt;
&lt;td&gt;+80%&lt;/td&gt;
&lt;td&gt;0.15s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What I actually recommend
&lt;/h2&gt;

&lt;p&gt;For most websites: &lt;strong&gt;WebP Q80&lt;/strong&gt;. 64% smaller than JPG, fast to encode, universal browser support since 2020.&lt;/p&gt;

&lt;p&gt;For maximum compression with no time pressure: &lt;strong&gt;AVIF Q63&lt;/strong&gt;. 68% smaller than JPG but 7x slower to encode.&lt;/p&gt;

&lt;p&gt;For iPhone photos: &lt;strong&gt;Do not convert to JPG.&lt;/strong&gt; Go straight to WebP or AVIF. JPG is a downgrade in both efficiency and file size.&lt;/p&gt;

&lt;h2&gt;
  
  
  The full benchmark
&lt;/h2&gt;

&lt;p&gt;The complete data with PDF rasterization benchmarks, DPI comparisons, and methodology is in the &lt;a href="https://convertifyapp.net/blogs/image-format-benchmark-2026" rel="noopener noreferrer"&gt;full benchmark post on Convertify&lt;/a&gt;. Every number was measured on June 18, 2026 on the production Rust + libvips server. No theoretical figures, no borrowed data.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Week 14 of building Convertify in public. Previous posts in the series cover the &lt;a href="https://convertifyapp.net/blogs/image-format-benchmark-2026" rel="noopener noreferrer"&gt;SSG migration&lt;/a&gt;, libvips segfaults, and why I stopped trusting my own landing page copy.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>img</category>
      <category>performance</category>
      <category>webperf</category>
    </item>
    <item>
      <title>Building in public, week 13: the debt week nobody posts about</title>
      <dc:creator>Serhii Kalyna</dc:creator>
      <pubDate>Sun, 14 Jun 2026 07:16:50 +0000</pubDate>
      <link>https://dev.to/serhii_kalyna_730b636889c/building-in-public-week-13-the-debt-week-nobody-posts-about-2j4a</link>
      <guid>https://dev.to/serhii_kalyna_730b636889c/building-in-public-week-13-the-debt-week-nobody-posts-about-2j4a</guid>
      <description>&lt;p&gt;I'm building Convertify, a free image converter, in public over 52 weeks. It's a solo project on Rust + Axum + libvips with a Next.js frontend. This is week 13, and I want to write about it honestly because it was a week with no shiny new feature, and those weeks rarely get posts.&lt;/p&gt;

&lt;p&gt;Here's what actually happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  I chased a double-free instead of shipping
&lt;/h2&gt;

&lt;p&gt;My "images to PDF" path had been crashing in production, intermittently, with a GObject assertion failure. Convert a few files, fine. Convert a few more, segfault. The worst kind of bug.&lt;/p&gt;

&lt;p&gt;Root cause: a derived &lt;code&gt;Clone&lt;/code&gt; on the libvips image type was doing a bitwise pointer copy of a C object without taking a reference. Two Rust values ended up owning one underlying GObject, and when both dropped, &lt;code&gt;g_object_unref&lt;/code&gt; ran twice on the same thing. A textbook double-free, wearing a &lt;code&gt;.clone()&lt;/code&gt; as a disguise.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// the bug&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;copy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// copies the pointer, no g_object_ref&lt;/span&gt;

&lt;span class="c1"&gt;// the fix&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;copy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;ops&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// real new GObject with its own refcount&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One line. A couple of evenings to be sure it was that line. I wrote the full story up as a separate dev.to article this week.&lt;/p&gt;

&lt;h2&gt;
  
  
  I fixed a UX dead-end I'd been ignoring
&lt;/h2&gt;

&lt;p&gt;If you went over the 20 MB upload limit, the convert button disabled itself, and there was no obvious way out. You couldn't remove a single file from the list. People were quietly getting stuck.&lt;/p&gt;

&lt;p&gt;So I rebuilt the file list: each file is a card with a remove button, there's a size-limit toast that fires when you cross the limit, and the dropzone goes red.&lt;/p&gt;

&lt;p&gt;While refactoring the upload handler I also collapsed three separate file-handling paths (choose, drop, remove) into one gate. That killed a whole class of bugs where the three paths had drifted apart (drop wasn't updating the size map, re-selecting the same file silently did nothing, etc).&lt;/p&gt;

&lt;h2&gt;
  
  
  I audited my own content for honesty
&lt;/h2&gt;

&lt;p&gt;This is the unglamorous one. I went through landing pages and removed claims that didn't match what the backend actually does, checking each against the real Rust code (metadata handling, compression details). I'd rather say less and be correct than pad pages with things that aren't true.&lt;/p&gt;

&lt;p&gt;Funny side effect: an SEO audit I ran this week pointed at exactly this kind of honest, specific content as the thing that sets the site apart from templated competitors. The discipline turned out to be the moat.&lt;/p&gt;

&lt;h2&gt;
  
  
  The most useful thing I did wasn't code
&lt;/h2&gt;

&lt;p&gt;I finally set up Bing Webmaster Tools. Took five minutes (you can import straight from Google Search Console).&lt;/p&gt;

&lt;p&gt;It immediately showed me search intent Google had been hiding: around 25 different queries for "pdf to jpg at N dpi" (600, 300, 150), in multiple languages, where I'm already ranking on page one. People who need a specific print resolution. I had no idea that demand existed because Google's console never surfaced it. That's now the top candidate for next week's backend work.&lt;/p&gt;

&lt;p&gt;Lesson: if you only watch Google, you're watching most of the market through one keyhole. Other search consoles are free and show you different things.&lt;/p&gt;

&lt;h2&gt;
  
  
  Numbers, honestly
&lt;/h2&gt;

&lt;p&gt;Impressions and indexing are both at period highs (impressions peaked around 225/day this week). Clicks are still small and spiky, which is normal for a 3-month-old domain with almost no backlinks. Nothing regressed, despite a scary-looking week-over-week dip that turned out to be me comparing a 7-day peak against a 3-month total.&lt;/p&gt;

&lt;p&gt;Threads is quietly my best referral source (real sessions every week), and ChatGPT keeps sending people too, which means I'm getting cited in LLM answers even though I haven't seen the AI crawlers hit me directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next week is a real technical week
&lt;/h2&gt;

&lt;p&gt;Backend debt: selectable TIFF compression, multipage TIFF, a CMYK path, and that DPI feature the Bing data justified. Plus refactoring my upload handler properly before I pile more on it, and turning my real benchmark data into a public study people can actually cite (which an audit suggested is a stronger link lever than guest posts).&lt;/p&gt;

&lt;p&gt;No new converter pages. The lever this stretch is depth and correctness, not surface area.&lt;/p&gt;

&lt;p&gt;If you're building something image-heavy, it's at convertifyapp.net. No account, files deleted 6 hours after conversion. Week 13 of 52 done.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>buildinpublic</category>
      <category>seo</category>
      <category>rust</category>
    </item>
    <item>
      <title>The Rust binding compiled fine. then it started segfaulting in prod</title>
      <dc:creator>Serhii Kalyna</dc:creator>
      <pubDate>Thu, 11 Jun 2026 07:59:39 +0000</pubDate>
      <link>https://dev.to/serhii_kalyna_730b636889c/the-rust-binding-compiled-fine-then-it-started-segfaulting-in-prod-1o94</link>
      <guid>https://dev.to/serhii_kalyna_730b636889c/the-rust-binding-compiled-fine-then-it-started-segfaulting-in-prod-1o94</guid>
      <description>&lt;p&gt;I run a free image converter, its built on rust + axum + libvips. it had been happily running for months. then one feature, images-to-pdf, started falling over in production with this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;g_object_unref: assertion 'G_IS_OBJECT (object)' failed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and the fun part: only sometimes. convert a couple images, fine. convert a few more, boom. the non-deterministic kind of crash, which is the worst kind, because it makes you sit there wondering if you even understand the language you've been writing for years.&lt;/p&gt;

&lt;p&gt;Turns out the bug was mine. the trigger was one tiny innocent &lt;code&gt;.clone()&lt;/code&gt;. heres the whole thing because i'm pretty sure other people doing rust over a C lib will eventually step on the same rake.&lt;/p&gt;

&lt;h2&gt;
  
  
  "But rust is memory safe"
&lt;/h2&gt;

&lt;p&gt;This is what messed with my head for a while. rust is supposed to stop exactly this. no double frees, no use after free, borrow checker has your back. so how am i double freeing anything at all?&lt;/p&gt;

&lt;p&gt;One word makes all those guarantees quietly disappear: &lt;strong&gt;FFI&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Libvips is a C library built on GObject, and GObject does its own manual reference counting. the rust crate wraps those C objects in rust types. and the second a rust type is holding a raw pointer into C land, the borrow checker just... cant help you anymore. it sees a struct. it does not see that the struct is actually a handle to a refcounted thing living on the C heap. safety stops right at that line and nobody tells you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual cause: a Clone that doesnt clone
&lt;/h2&gt;

&lt;p&gt;Heres roughly what was blowing up. i was building a multi-page pdf and somewhere in the loop i cloned the image:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// the crash version&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;copy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="nf"&gt;.clone&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// ... use copy ...&lt;/span&gt;
&lt;span class="c1"&gt;// both `image` and `copy` drop here&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;VipsImage&lt;/code&gt; in the crate derives &lt;code&gt;Clone&lt;/code&gt;. and a derived clone on a thing that holds a pointer does the obvious dumb thing: it copies the pointer. bit for bit. so now the same C object has two rust owners, and here is the kicker, &lt;strong&gt;nobody incremented the GObject refcount&lt;/strong&gt;. libvips still thinks there is exactly one reference to that object.&lt;/p&gt;

&lt;p&gt;Then rust does its job a little too well. both values go out of scope, &lt;code&gt;Drop&lt;/code&gt; runs twice, and each drop calls &lt;code&gt;g_object_unref&lt;/code&gt; on the same object. first unref takes the count to zero and frees it. second unref runs on already-freed memory, libvips checks &lt;code&gt;G_IS_OBJECT&lt;/code&gt;, the assertion blows, and depending on timing you either get a glib critical or a clean segfault.&lt;/p&gt;

&lt;p&gt;So. a textbook double free. wearing a &lt;code&gt;.clone()&lt;/code&gt; as a disguise. the "random" part was just the allocator deciding whether the freed slot had been reused yet or not.&lt;/p&gt;

&lt;p&gt;A correct clone here would have to call &lt;code&gt;g_object_ref&lt;/code&gt; so each owner actually accounts for its own reference. the derived one doesnt. so calling it &lt;code&gt;Clone&lt;/code&gt; is generous. its an aliased pointer with two destructors aimed at it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Stopped cloning the handle, started asking libvips for a real new object instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="c1"&gt;// the fixed version&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;copy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;ops&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ops::copy&lt;/code&gt; runs an actual libvips copy and gives you back a fresh GObject with its own refcount. now each rust value owns its own distinct C object, each drop unrefs its own thing, nothing gets freed twice. crash gone.&lt;/p&gt;

&lt;p&gt;one line. took me a couple evenings to be sure it was &lt;em&gt;that&lt;/em&gt; line, which is always how these go.&lt;/p&gt;

&lt;h2&gt;
  
  
  a few more libvips + rust landmines from the same evening
&lt;/h2&gt;

&lt;p&gt;since i was already in there bleeding, a few neighbours showed up:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;JpegsaveBufferOptions::default()&lt;/code&gt; hands you a busted &lt;code&gt;keep&lt;/code&gt; value.&lt;/strong&gt; the default came out as something like &lt;code&gt;keep | 32&lt;/code&gt;, which libvips just rejects with a glib critical. fix was to stop trusting the default and set it myself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="n"&gt;keep&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nn"&gt;ops&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;ForeignKeep&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;the operation cache was a second way to die.&lt;/strong&gt; libvips caches operations by default, and under load that cache plus the lifecycle mess above gave me another crash, this time inside cache trimming. i turned the operation cache off entirely while getting things stable, which killed that path. just know it exists and that its global state.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;VipsApp::new(name, false)&lt;/code&gt; is not what it looks like.&lt;/strong&gt; that second bool is &lt;code&gt;detect_leak&lt;/code&gt;, not some concurrency switch, which is exactly what i had lazily assumed it was. read the signature, dont pattern-match on vibes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;bumping the crate did not save me.&lt;/strong&gt; i tried going from &lt;code&gt;1.7.x&lt;/code&gt; up to &lt;code&gt;2.1.0&lt;/code&gt; hoping the lifecycle stuff was just fixed upstream. nope, changed nothing for this. reverted to &lt;code&gt;1.7.6&lt;/code&gt; and fixed it properly on my end.&lt;/p&gt;

&lt;h2&gt;
  
  
  what i actually took from this
&lt;/h2&gt;

&lt;p&gt;a derived &lt;code&gt;Clone&lt;/code&gt; on a type that wraps a foreign, refcounted resource is a trap. &lt;code&gt;#[derive(Clone)]&lt;/code&gt; assumes a clone is a cheap copy-the-fields job. for a GObject handle thats the exact wrong assumption: copy the pointer without taking a ref and you've got two owners, one refcount, and rust's own &lt;code&gt;Drop&lt;/code&gt; happily freeing it twice.&lt;/p&gt;

&lt;p&gt;so the rule i walked out with: when a rust type owns a C resource that has its own lifecycle, treat &lt;code&gt;Clone&lt;/code&gt; and &lt;code&gt;Drop&lt;/code&gt; as suspects, not freebies. need another handle? reach for the library's own copy/ref function before you derive &lt;code&gt;Clone&lt;/code&gt; and hope.&lt;/p&gt;

&lt;p&gt;if you've hit something like this with libvips bindings or any GObject FFI, i'd genuinely love to hear how you dealt with it, the comments are the fun part. the tool this was all holding up is at &lt;a href="//convertifyapp.net"&gt;convertifyapp.net&lt;/a&gt; if you want a look. no account, files get deleted after.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>programming</category>
      <category>rust</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>Week 12 building Convertify in public (free image converter, Rust + libvips + Next.js SSG)</title>
      <dc:creator>Serhii Kalyna</dc:creator>
      <pubDate>Sun, 07 Jun 2026 16:47:13 +0000</pubDate>
      <link>https://dev.to/serhii_kalyna_730b636889c/week-12-building-convertify-in-public-free-image-converter-rust-libvips-nextjs-ssg-1g84</link>
      <guid>https://dev.to/serhii_kalyna_730b636889c/week-12-building-convertify-in-public-free-image-converter-rust-libvips-nextjs-ssg-1g84</guid>
      <description>&lt;p&gt;What I shipped:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Two new tools live: Resize Image and Crop Image, both on libvips natively&lt;/li&gt;
&lt;li&gt;A long-form guide, "How to Compress Images Without Losing Quality," written to feed my compress JPG/PNG/WebP pages with real internal links and real benchmarks from 24 of my own iPhone photos&lt;/li&gt;
&lt;li&gt;A site-wide "Image Tools" block (inline section + footer column) so every page now cross-links to the new tools&lt;/li&gt;
&lt;li&gt;Squashed a nasty non-deterministic segfault in the libvips Rust bindings. It was a double-free: the binding derives Clone over the raw image pointer, so cloning copied the pointer without bumping the refcount and Drop freed the same object twice. Replaced the clone with an explicit copy, disabled the op cache, and the crashes plus the G_IS_OBJECT warnings are gone. Happy to write up the war story if anyone wants it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The numbers (last 7 days):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Clicks doubled, up 100% week over week. Still small in absolute terms, but real and trending the right way for the first time in weeks.&lt;/li&gt;
&lt;li&gt;Impressions also up 100%, as the new pages finished getting indexed.&lt;/li&gt;
&lt;li&gt;Average position is creeping up, into the mid-40s on the most recent day, from around 51 the week before.&lt;/li&gt;
&lt;li&gt;A brand-new long-tail query, "sony hif file to jpg converter," is already sitting at position 6. That is exactly the kind of low-competition query I am betting the whole strategy on.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The honest read:&lt;br&gt;
Clicks and impressions both doubled, which is great, but the absolute numbers are still small and capped by domain authority. I have around 15 referring domains; the big converters have thousands. On competitive terms like "avif to png" (KD 42) I cannot rank without backlinks, and that is a months-long game. So the play stays low-KD long-tail: pages that can reach the top 10 even at low DR. That position-6 result on a niche query is the early proof it works.&lt;/p&gt;

&lt;p&gt;Next week:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Concentrate internal links on the low-KD pages that already have impressions&lt;/li&gt;
&lt;li&gt;Start background removal (rembg sidecar) research&lt;/li&gt;
&lt;li&gt;Keep backlink outreach slow and quality-first&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;convertifyapp.net&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>seo</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Building in public, week 11: 143 pages indexed, a TinyPNG alternative in Rust</title>
      <dc:creator>Serhii Kalyna</dc:creator>
      <pubDate>Sun, 31 May 2026 11:15:26 +0000</pubDate>
      <link>https://dev.to/serhii_kalyna_730b636889c/building-in-public-week-11-143-pages-indexed-a-tinypng-alternative-in-rust-4n35</link>
      <guid>https://dev.to/serhii_kalyna_730b636889c/building-in-public-week-11-143-pages-indexed-a-tinypng-alternative-in-rust-4n35</guid>
      <description>&lt;p&gt;Week 11 of building &lt;a href="https://convertifyapp.net/compress/png" rel="noopener noreferrer"&gt;Convertify&lt;/a&gt; a free image converter (Rust + Axum + libvips, Next.js SSG frontend) in public. Solo, no funding, 52-week run.&lt;/p&gt;

&lt;p&gt;Here's the honest headline: **indexing jumped from 100 to 143 pages, I shipped image compression in Rust, and my clicks are still stuck.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  1. Internal linking between blog and converter (the debt I kept dodging)
&lt;/h3&gt;

&lt;p&gt;I launched a blog in week 10 (3 posts, full schema, the works) and immediately got 18 views/week on one post. Great except that traffic had nowhere to go. The blog talked &lt;em&gt;about&lt;/em&gt; HEIC; it never pointed at the page that actually &lt;em&gt;converts&lt;/em&gt; HEIC.&lt;/p&gt;

&lt;p&gt;So week 11 I built a &lt;code&gt;RelatedArticle&lt;/code&gt; component and wired it both ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Blog -&amp;gt; converter:&lt;/strong&gt; inline contextual links in the body + a CTA block at the end.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Converter -&amp;gt; blog:&lt;/strong&gt; a "Learn more" card driven by a &lt;code&gt;related_blog_slug&lt;/code&gt; column on each page.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The component went through a small evolution. First version showed one article. Then I made it multi-article it pulls 2–3 related posts, matches them by topic via slug, caps at 3, and (the part that bit me) guards against the literal string &lt;code&gt;"NULL"&lt;/code&gt; sneaking in from a missing DB value. Nothing fancy, but it closes the loop: informational traffic can finally flow to the transactional pages.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Image compression a free TinyPNG alternative, in Rust.
&lt;/h3&gt;

&lt;p&gt;This was the fun one. TinyPNG does ~3M visits/month. That demand is enormous and I was leaving it on the table. libvips already gives me everything I need, so the backend was fast to stand up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;JPEG -&amp;gt; mozjpeg.&lt;/strong&gt; libvips can hand JPEG encoding to mozjpeg, which does trellis quantization and smarter Huffman tables. Same visual quality, meaningfully smaller files than baseline libjpeg.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PNG -&amp;gt; imagequant.&lt;/strong&gt; This is the same lossy-PNG approach pngquant (and TinyPNG) use: quantize a 24-bit PNG down to an optimized palette. Huge size drops on PNGs with limited color, transparency preserved.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The UX is a quality slider (1-100%) and a before/after size readout, so you actually &lt;em&gt;see&lt;/em&gt; the reduction. I shipped three landing pages &lt;code&gt;/compress-jpg&lt;/code&gt;, &lt;code&gt;/compress-png&lt;/code&gt;, &lt;code&gt;/compress-webp&lt;/code&gt; positioned as the free alternative.&lt;/p&gt;

&lt;p&gt;The interesting signal: &lt;code&gt;Compress JPG Free&lt;/code&gt; pulled &lt;strong&gt;24 views in its first week&lt;/strong&gt;, landing straight in my top pages. Different user intent than conversion (people &lt;em&gt;converting&lt;/em&gt; a format vs people &lt;em&gt;shrinking&lt;/em&gt; a file), and it showed up immediately. That alone made the cluster worth it.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Blog post #4
&lt;/h3&gt;

&lt;p&gt;"Why HEIC Files Won't Open" published, 8 FAQs, FAQ schema embedded in the &lt;code&gt;BlogPosting&lt;/code&gt;. Continuing the content momentum and feeding the new internal-linking machine.&lt;/p&gt;

&lt;p&gt;Also did a cannibalization audit across blog + landing pages. Came back clean: intent is cleanly split (blog = informational, landing = transactional), so they're not fighting each other in search.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Google Search Console (3 months):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Indexed: 100 -&amp;gt; &lt;strong&gt;143&lt;/strong&gt; (+43, second-biggest jump ever)&lt;/li&gt;
&lt;li&gt;Not indexed: 65 -&amp;gt; &lt;strong&gt;39&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Avg position: 40 -&amp;gt; &lt;strong&gt;39.5&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Google Analytics (7 days):&lt;/strong&gt; 31 active users (+29%), 24 new (+33%), 52 sessions (+11%).&lt;/p&gt;

&lt;p&gt;Traffic sources had a surprise: &lt;code&gt;l.threads.com&lt;/code&gt; sent 13 sessions in a week Threads quietly became a real referrer. &lt;code&gt;chatgpt.com&lt;/code&gt; has been a steady trickle for over a month now too (LLMs citing the blog posts).&lt;/p&gt;

&lt;h2&gt;
  
  
  Housekeeping wins
&lt;/h2&gt;

&lt;p&gt;Cleared a stack of small debts that were quietly rotting: fixed the 404 source (broken internal links), cleared the redirect errors flagged in GSC, and consolidated my project docs into a single source-of-truth file instead of three drifting documents.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next week (12)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Two new tools that are low-effort / high-ROI: &lt;strong&gt;resize + crop&lt;/strong&gt; and &lt;strong&gt;background removal&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;A compression-focused blog post to feed the new cluster.&lt;/li&gt;
&lt;li&gt;The real work: on-page pushes on my top-impression pages to &lt;em&gt;finally&lt;/em&gt; break something into the top 20.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've shipped a programmatic-SEO site and watched impressions climb while clicks flatlined I'd genuinely like to hear how (or whether) you broke out of it. That's the wall I'm at.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Convertify is built with Rust + libvips + Next.js. Following along week by week.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>rust</category>
      <category>seo</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Building Image Compression in Rust with libvips Real Benchmarks, Real Tradeoffs</title>
      <dc:creator>Serhii Kalyna</dc:creator>
      <pubDate>Fri, 29 May 2026 13:00:32 +0000</pubDate>
      <link>https://dev.to/serhii_kalyna_730b636889c/building-image-compression-in-rust-with-libvips-real-benchmarks-real-tradeoffs-4j2</link>
      <guid>https://dev.to/serhii_kalyna_730b636889c/building-image-compression-in-rust-with-libvips-real-benchmarks-real-tradeoffs-4j2</guid>
      <description>&lt;p&gt;I've been building &lt;a href="https://convertifyapp.net/compress/jpg" rel="noopener noreferrer"&gt;Convertify&lt;/a&gt; a free image converter for 11 weeks now. Last week I added compression support (JPG, PNG, WebP) using Rust + libvips. Here's what I learned, with actual numbers.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Backend&lt;/strong&gt;: Rust + Axum&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image processing&lt;/strong&gt;: libvips 8.15.1 via the &lt;code&gt;libvips&lt;/code&gt; crate&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JPEG encoder&lt;/strong&gt;: libjpeg (bundled with libvips)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PNG quantization&lt;/strong&gt;: imagequant (same algorithm as pngquant / TinyPNG)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PNG parser&lt;/strong&gt;: libspng (faster than libpng)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One thing worth knowing upfront: libvips does &lt;strong&gt;not&lt;/strong&gt; include mozjpeg by default. You get standard libjpeg. I'll come back to why this matters.&lt;/p&gt;




&lt;h2&gt;
  
  
  How the API Works
&lt;/h2&gt;

&lt;p&gt;The compression endpoint is the same &lt;code&gt;/api/upload&lt;/code&gt; used for conversion — just pass &lt;code&gt;format_to=jpg&lt;/code&gt; (or &lt;code&gt;png&lt;/code&gt;, &lt;code&gt;webp&lt;/code&gt;) with a &lt;code&gt;quality&lt;/code&gt; parameter (1–100):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://convertifyapp.net/api/upload &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"files=@photo.jpg"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"format_to=jpg"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"quality=80"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the Rust side, &lt;code&gt;quality&lt;/code&gt; flows into libvips save options via &lt;code&gt;build_output_path&lt;/code&gt;, which appends &lt;code&gt;[Q=N]&lt;/code&gt; to the output filename — libvips picks it up automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;build_output_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Option&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;u32&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;format&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"jpg"&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="s"&gt;"jpeg"&lt;/span&gt; &lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="s"&gt;"webp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{}[Q={}]"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"png"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"{}[Q={}]"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="nf"&gt;.to_string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clean. No separate code path for compression vs conversion.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Benchmarks
&lt;/h2&gt;

&lt;p&gt;Test image: 1920×1080 synthetic photo, &lt;strong&gt;1.5 MB&lt;/strong&gt; original JPG (Q=95 source).&lt;/p&gt;

&lt;h3&gt;
  
  
  JPG → JPG (re-compression)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Quality&lt;/th&gt;
&lt;th&gt;Output size&lt;/th&gt;
&lt;th&gt;Saved&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Q=90&lt;/td&gt;
&lt;td&gt;1,172 KB&lt;/td&gt;
&lt;td&gt;22.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Q=80&lt;/td&gt;
&lt;td&gt;890 KB&lt;/td&gt;
&lt;td&gt;41.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Q=70&lt;/td&gt;
&lt;td&gt;737 KB&lt;/td&gt;
&lt;td&gt;51.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Q=60&lt;/td&gt;
&lt;td&gt;627 KB&lt;/td&gt;
&lt;td&gt;58.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Q=50&lt;/td&gt;
&lt;td&gt;542 KB&lt;/td&gt;
&lt;td&gt;64.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  PNG → PNG (imagequant lossy)
&lt;/h3&gt;

&lt;p&gt;Test image: 1024×768, &lt;strong&gt;678 KB&lt;/strong&gt; original PNG.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Quality&lt;/th&gt;
&lt;th&gt;Output size&lt;/th&gt;
&lt;th&gt;Saved&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Q=90&lt;/td&gt;
&lt;td&gt;486 KB&lt;/td&gt;
&lt;td&gt;29.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Q=80&lt;/td&gt;
&lt;td&gt;441 KB&lt;/td&gt;
&lt;td&gt;36.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Q=70&lt;/td&gt;
&lt;td&gt;396 KB&lt;/td&gt;
&lt;td&gt;42.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Q=60&lt;/td&gt;
&lt;td&gt;377 KB&lt;/td&gt;
&lt;td&gt;45.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Q=50&lt;/td&gt;
&lt;td&gt;348 KB&lt;/td&gt;
&lt;td&gt;49.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  JPG → WebP (format switch as compression)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Quality&lt;/th&gt;
&lt;th&gt;Output size&lt;/th&gt;
&lt;th&gt;Saved vs original JPG&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Q=90&lt;/td&gt;
&lt;td&gt;1,120 KB&lt;/td&gt;
&lt;td&gt;26.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Q=80&lt;/td&gt;
&lt;td&gt;872 KB&lt;/td&gt;
&lt;td&gt;42.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Q=70&lt;/td&gt;
&lt;td&gt;760 KB&lt;/td&gt;
&lt;td&gt;49.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  The Surprising Part
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;WebP at Q=80 saves almost the same as JPG at Q=80&lt;/strong&gt; — 42.5% vs 41.3%. Not the dramatic 2× improvement you'd expect from the marketing.&lt;/p&gt;

&lt;p&gt;Why? On already-complex photographic content (lots of high-frequency detail), the gap between WebP and JPEG narrows significantly. WebP's advantage is most visible on graphics, flat colors, and images with transparency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The other surprise&lt;/strong&gt;: if the source JPG is already compressed at Q=70–75 (common for web photos), re-compressing to the same format gives you almost nothing. You're fighting the JPEG DCT artifacts that are already baked in. This is where converting to WebP actually helps different codec, fresh encode.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Not mozjpeg?
&lt;/h2&gt;

&lt;p&gt;mozjpeg typically gives 10–15% better compression than libjpeg at the same quality level. To use it with libvips you need to compile libvips from source with &lt;code&gt;--with-mozjpeg&lt;/code&gt;. On Ubuntu:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Not trivial requires building mozjpeg first&lt;/span&gt;
git clone https://github.com/mozilla/mozjpeg.git
&lt;span class="nb"&gt;cd &lt;/span&gt;mozjpeg &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; cmake &lt;span class="nt"&gt;-G&lt;/span&gt;&lt;span class="s2"&gt;"Unix Makefiles"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; make &lt;span class="nb"&gt;install&lt;/span&gt;

&lt;span class="c"&gt;# Then rebuild libvips pointing at mozjpeg&lt;/span&gt;
./configure &lt;span class="nt"&gt;--with-jpeg-includes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/opt/mozjpeg/include &lt;span class="se"&gt;\&lt;/span&gt;
            &lt;span class="nt"&gt;--with-jpeg-libraries&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/opt/mozjpeg/lib
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a production VPS running PM2 + Caddy with system libvips, the operational complexity wasn't worth it. libjpeg at Q=80 already delivers 40%+ savings which covers 90% of use cases.&lt;/p&gt;




&lt;h2&gt;
  
  
  PNG: imagequant is the real hero
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;selected quantisation package: imagequant&lt;/code&gt; line in &lt;code&gt;vips --vips-config&lt;/code&gt; is significant. This is the same engine behind pngquant and TinyPNG's PNG compression. It works by reducing the color palette to 256 colors (lossy) while preserving perceptual quality.&lt;/p&gt;

&lt;p&gt;The Q parameter maps to imagequant's quality range. Q=80 gives ~36% savings with barely visible quality loss on photographic PNGs. For logos and flat graphics the savings are even better since there are fewer unique colors to begin with.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Sweet Spot
&lt;/h2&gt;

&lt;p&gt;After running these benchmarks, the defaults I settled on for the UI:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;JPG&lt;/strong&gt;: Q=82 (hits the knee of the quality/size curve, ~40% savings)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PNG&lt;/strong&gt;: Q=80 (36% savings, imagequant artifacts not visible at normal viewing)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WebP&lt;/strong&gt;: Q=80 (consistent with JPG behavior)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are close to what Squoosh and Lighthouse recommend. The difference is users can move the slider themselves the before/after size display makes the tradeoff tangible.&lt;/p&gt;




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

&lt;p&gt;The compression cluster is live at &lt;a href="https://convertifyapp.net/compress/jpg" rel="noopener noreferrer"&gt;convertifyapp.net/compress/jpg&lt;/a&gt;. Next up: RAW format support (CR2/NEF) for photographers, and eventually mozjpeg as an opt-in for maximum compression.&lt;/p&gt;

&lt;p&gt;If you're building something similar libvips in Rust is genuinely great. The &lt;code&gt;libvips&lt;/code&gt; crate is well-maintained, the C library is fast (faster than ImageMagick by a wide margin), and imagequant bundled for PNG is a nice bonus.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Building in public week 11 of 52. Follow along if you're into indie dev + Rust + SEO experiments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>buildinpublic</category>
      <category>seo</category>
      <category>programming</category>
    </item>
  </channel>
</rss>
