<?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: Ivan Yatsenko</title>
    <description>The latest articles on DEV Community by Ivan Yatsenko (@ivanyatsenko).</description>
    <link>https://dev.to/ivanyatsenko</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%2F4069563%2F0f7c0eb2-e43d-4042-b8a2-82b830191522.png</url>
      <title>DEV Community: Ivan Yatsenko</title>
      <link>https://dev.to/ivanyatsenko</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ivanyatsenko"/>
    <language>en</language>
    <item>
      <title>Crystal in 2026: a 7 MB binary, zero dependencies, and five traps</title>
      <dc:creator>Ivan Yatsenko</dc:creator>
      <pubDate>Wed, 12 Aug 2026 09:30:22 +0000</pubDate>
      <link>https://dev.to/ivanyatsenko/crystal-in-2026-a-7-mb-binary-zero-dependencies-and-five-traps-28bi</link>
      <guid>https://dev.to/ivanyatsenko/crystal-in-2026-a-7-mb-binary-zero-dependencies-and-five-traps-28bi</guid>
      <description>&lt;p&gt;I spent a few days writing a satellite ground station daemon in Crystal, with an empty dependency list and a hard rule against third-party code. It works, it ships as one file, and it sits at 1.9 MB of memory at rest.&lt;/p&gt;

&lt;p&gt;This is what the language was like to use, and what it cost.&lt;/p&gt;

&lt;p&gt;The project is &lt;a href="https://github.com/VanyaNeytrino/kozai" rel="noopener noreferrer"&gt;kozai&lt;/a&gt;: it reads orbital elements, propagates them with SGP4/SDP4, predicts passes over a ground station, serves a JSON API and an offline web interface, and drives a rotator and a radio through hamlib. About 9,000 lines of source and 6,400 lines of specs, on Crystal 1.21.0. None of that matters here except as the load under which the language was tested — this is a report on the tool, not on the satellites.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the language actually delivers
&lt;/h2&gt;

&lt;p&gt;The headline claim of a compiled language with a garbage collector is that you get Ruby's ergonomics and a binary at the end. In 2026 that claim holds, and the numbers are the part worth quoting:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Docker image, &lt;code&gt;FROM scratch&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;7.41 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Static binary, musl, arm64&lt;/td&gt;
&lt;td&gt;6.9 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dynamic binary, release&lt;/td&gt;
&lt;td&gt;1.9 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory at rest, 2 satellites&lt;/td&gt;
&lt;td&gt;1.9 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory at rest, 97 satellites&lt;/td&gt;
&lt;td&gt;4.3 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory after a day of serving, 97 satellites&lt;/td&gt;
&lt;td&gt;19.3 MB, flat&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build steps before &lt;code&gt;crystal build&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runtime files outside the binary&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last two rows are the ones that changed how the project was built. There is no Node in this repository, no bundler, no asset pipeline, and no &lt;code&gt;postinstall&lt;/code&gt;. The web interface — HTML, CSS, JavaScript, and a 66 KB SVG of the world's coastlines — is read at compile time by &lt;code&gt;{{ read_file(...) }}&lt;/code&gt; and lives inside the executable (&lt;code&gt;src/assets.cr&lt;/code&gt;). Deploying is &lt;code&gt;scp&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The standard library covered the whole surface of a network daemon with six imports: &lt;code&gt;http/server&lt;/code&gt;, &lt;code&gt;http/client&lt;/code&gt;, &lt;code&gt;json&lt;/code&gt;, &lt;code&gt;log&lt;/code&gt;, &lt;code&gt;socket&lt;/code&gt;, &lt;code&gt;option_parser&lt;/code&gt;. That list is not an aspiration; CI fails if a seventh appears.&lt;/p&gt;

&lt;p&gt;The type system earned its keep in the numerical core. Predicting a week of passes for a hundred satellites is on the order of ten million propagator calls, and the hot loop allocates nothing: positions and satellite state are structs, and propagation failures are reported through an enum instead of an exception, because &lt;code&gt;raise&lt;/code&gt; allocates. A spec propagates 200,000 steps on the near-earth branch and 50,000 through the deep-space integrator, and asserts that heap growth is zero (&lt;code&gt;spec/allocation_spec.cr&lt;/code&gt;). It passes. Getting that from a GC'd language, without writing anything that looks like C, is the reason to be here.&lt;/p&gt;

&lt;p&gt;So much for the brochure. Here are the five things that cost me time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 1: the inline &lt;code&gt;rescue&lt;/code&gt; does not filter by type
&lt;/h2&gt;

&lt;p&gt;This one is specific to Crystal, and it is the one I would warn a newcomer about first.&lt;/p&gt;

&lt;p&gt;Crystal has a suffix &lt;code&gt;rescue&lt;/code&gt;, inherited in spirit from Ruby:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight crystal"&gt;&lt;code&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;risky_call&lt;/span&gt; &lt;span class="k"&gt;rescue&lt;/span&gt; &lt;span class="n"&gt;fallback&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In a block form, &lt;code&gt;rescue IO::Error&lt;/code&gt; means "catch this class of error". In the suffix form it does not. The suffix form has no type filter at all: it catches &lt;em&gt;everything&lt;/em&gt;, and the thing on the right is the &lt;strong&gt;value returned&lt;/strong&gt; on failure.&lt;/p&gt;

&lt;p&gt;So this line, which closed a socket in a mock server without caring whether it was already closed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight crystal"&gt;&lt;code&gt;&lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt; &lt;span class="k"&gt;rescue&lt;/span&gt; &lt;span class="no"&gt;IO&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Error&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;does not mean "catch IO errors". It means "catch every exception, including the ones that indicate a bug, and on failure evaluate to the class object &lt;code&gt;IO::Error&lt;/code&gt;". The code reads as if it were correct. It compiles, it type-checks, and it will happily swallow the failure you needed to see. The fix is the block form, which does filter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight crystal"&gt;&lt;code&gt;&lt;span class="kp"&gt;private&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;close_quietly&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;socket&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="no"&gt;TCPSocket&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="no"&gt;Nil&lt;/span&gt;
  &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;
&lt;span class="k"&gt;rescue&lt;/span&gt; &lt;span class="no"&gt;IO&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Error&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I did not find this by reasoning about it. &lt;a href="https://github.com/crystal-ameba/ameba" rel="noopener noreferrer"&gt;Ameba&lt;/a&gt;, the linter, found it. That is the useful lesson: the trap is invisible during review precisely because it looks like the block form, so run the linter and believe it.&lt;/p&gt;

&lt;p&gt;Two smaller sharp edges live next door.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A macro cannot be expanded inside a &lt;code&gt;rescue&lt;/code&gt; clause.&lt;/strong&gt; The parser rejects it. I needed the rescue list to depend on a compile-time flag, because a build without OpenSSL has no &lt;code&gt;OpenSSL::Error&lt;/code&gt; type, and naming a type that does not exist will not compile. The way through is an alias, declared once (&lt;code&gt;src/catalog.cr&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight crystal"&gt;&lt;code&gt;&lt;span class="p"&gt;{%&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;flag?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:without_openssl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;%}&lt;/span&gt;
  &lt;span class="k"&gt;alias&lt;/span&gt; &lt;span class="no"&gt;TransportError&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;IO&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Error&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="no"&gt;Socket&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Error&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;alias&lt;/span&gt; &lt;span class="no"&gt;TransportError&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;IO&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Error&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="no"&gt;Socket&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Error&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="no"&gt;OpenSSL&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Error&lt;/span&gt;
&lt;span class="p"&gt;{%&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="p"&gt;%}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and then &lt;code&gt;rescue ex : Error | TransportError&lt;/code&gt; at the call site.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exceptions from the standard library are easy to under-catch.&lt;/strong&gt; The same loader missed &lt;code&gt;OpenSSL::SSL::Error&lt;/code&gt;, so a TLS failure killed the daemon instead of falling back to its cache — the exact opposite of the offline behaviour the project exists to guarantee. It surfaced only when the binary ran inside a &lt;code&gt;FROM scratch&lt;/code&gt; image, where OpenSSL could not find a CA bundle. A dependency this project deliberately has none of would not have helped; reading the error hierarchy would have.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 2: the fiber stack pool looks exactly like a memory leak
&lt;/h2&gt;

&lt;p&gt;This is the one that nearly went into a release note as a defect in Crystal's standard library. It would have been wrong.&lt;/p&gt;

&lt;p&gt;The daemon is meant to run for weeks unattended, so I put it under continuous request load and sampled memory. The live heap, measured after a forced &lt;code&gt;GC.collect&lt;/code&gt;, grew linearly: &lt;strong&gt;0.21 MiB per minute, about 300 MB per day.&lt;/strong&gt; That is a leak by any reasonable reading.&lt;/p&gt;

&lt;p&gt;I isolated it. Thirty lines, a bare &lt;code&gt;HTTP::Server&lt;/code&gt; with one &lt;code&gt;ErrorHandler&lt;/code&gt; and not a single line of my project, and the shape reproduced: roughly &lt;strong&gt;75 KiB retained per request&lt;/strong&gt; when each request arrived on a new TCP connection. At that point I had a clean reproduction against the standard library and a draft sentence about a leak in &lt;code&gt;HTTP::Server&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The sentence was wrong, and one more measurement showed why. Instead of extrapolating the line, I asked whether it saturates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;first 250 requests:  +13.4 MiB
250 → 500:            +2.4 MiB
500 → 750:            −6.8 MiB     ← memory comes back
beyond:               11–20 MiB, no trend

2000 requests on a single connection:  −0.07 MiB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is not a leak. Crystal pools the stacks of finished fibers, and this server runs one fiber per connection. A server that has handled a burst of concurrent connections holds more live data than one that just started, up to the high-water mark of concurrency it has ever seen — and then it stops. Thirteen minutes of a perfectly straight line in a container was the pool filling up slowly, because I was sampling once a minute.&lt;/p&gt;

&lt;p&gt;Two things follow, and both generalise beyond Crystal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RSS tells you nothing here.&lt;/strong&gt; Boehm does not return pages to the operating system unless it is built with &lt;code&gt;USE_MUNMAP&lt;/code&gt;, so resident memory cannot fall and its flatness is not evidence of anything. Measure the live heap after a forced collection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Zero growth" is the wrong acceptance criterion; "reaches a plateau" is the right one.&lt;/strong&gt; Restated that way, the soak is a clean pass: over 13.8 hours the live heap climbed from 4.9 MB to 19.2 MB during the first four hours, then held between 19.19 and 19.37 MB for the remaining 9.8 hours and 576 samples. The residual trend is 14 KB/hour — 250 times below the fill rate, and the same size as the scatter between consecutive samples. RSS over the same period sat at 12.3–14.2 MB.&lt;/p&gt;

&lt;p&gt;If you are writing a long-running Crystal service, budget an afternoon for this and do not report the first curve you see.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 3: the standard library links C you did not ask for
&lt;/h2&gt;

&lt;p&gt;"Zero dependencies" means an empty &lt;code&gt;dependencies:&lt;/code&gt; block in &lt;code&gt;shard.yml&lt;/code&gt;. It does not mean the binary contains no C. The runtime stands on Boehm, libc and libm — that is the language, not your supply chain. What surprised me is how much C arrives through ordinary &lt;code&gt;require&lt;/code&gt; lines.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;require "yaml"&lt;/code&gt; links libyaml.&lt;/strong&gt; For a config file of a few dozen keys that is a poor trade, so configuration is parsed by hand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Regular expressions link PCRE2.&lt;/strong&gt; TLE parsing is by fixed columns anyway — the format demands it — but the point is that one &lt;code&gt;=~&lt;/code&gt; in a cold path pulls a C library into a binary meant to be static.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;HTTP::Server&lt;/code&gt; links OpenSSL for its TLS support whether or not you use TLS.&lt;/strong&gt; This is the one you cannot deduce from the source you wrote. A &lt;code&gt;-Dno_network&lt;/code&gt; build removes the HTTPS &lt;em&gt;client&lt;/em&gt; and still links OpenSSL; you need &lt;code&gt;-Dwithout_openssl&lt;/code&gt; as well, and the only way to discover that the first time is to build the thing and run &lt;code&gt;ldd&lt;/code&gt;. The project now prints a compile-time notice if you pass one flag without the other.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same pressure shows up in small places. Static assets are served with an ETag derived from their bytes, and the obvious way to compute one is a digest from the standard library — which links a C library, for a checksum whose collisions do not matter. The next obvious thing is &lt;code&gt;String#hash&lt;/code&gt;, and that is a trap of its own: &lt;strong&gt;Crystal seeds it randomly per process&lt;/strong&gt;, so every restart would invalidate every browser cache. The ETag is therefore a hand-rolled 64-bit FNV-1a, eight lines in &lt;code&gt;src/assets.cr&lt;/code&gt;. Twice now, "use the standard library" has been the wrong answer for reasons that have nothing to do with quality.&lt;/p&gt;

&lt;p&gt;Because these are properties of the product rather than preferences, CI enforces them (the &lt;code&gt;purity&lt;/code&gt; job in &lt;code&gt;.github/workflows/ci.yml&lt;/code&gt;): &lt;code&gt;shard.yml&lt;/code&gt; must declare no runtime dependencies, &lt;code&gt;src/&lt;/code&gt; must contain no &lt;code&gt;lib&lt;/code&gt; blocks, no &lt;code&gt;require "yaml"&lt;/code&gt;, no regular expressions, and no import outside the allowed six.&lt;/p&gt;

&lt;p&gt;That job also taught me something about enforcement. Its first version grepped for &lt;code&gt;.scan(&lt;/code&gt; and &lt;code&gt;.match(&lt;/code&gt;, which flagged the project's own &lt;code&gt;Passes.scan&lt;/code&gt; — a false positive that would have had someone rename working code to satisfy a grep. It now matches on the constructs (&lt;code&gt;Regex&lt;/code&gt;, &lt;code&gt;=~&lt;/code&gt;, a slash immediately after the parenthesis) rather than on method names. A purity check that produces false positives does not get tightened; it gets ignored.&lt;/p&gt;

&lt;p&gt;And the honest footnote: &lt;code&gt;libpcre2&lt;/code&gt; is in the binary regardless, because &lt;code&gt;OptionParser&lt;/code&gt; uses regular expressions internally. The codebase contains none. The dependency is the standard library's, not mine, and I cannot remove it without giving up argument parsing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 4: HTTP/2 is not in the standard library, and for me that was the same as absent
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/crystal-lang/crystal/issues/2125" rel="noopener noreferrer"&gt;Issue #2125, "HTTP/2 support"&lt;/a&gt;, was opened on &lt;strong&gt;8 February 2016&lt;/strong&gt; and is still open. Ten years is long enough that most people read it as "Crystal has no HTTP/2", and that reading is now wrong — which is worth knowing before you rule the language out.&lt;/p&gt;

&lt;p&gt;The gap is filled outside the standard library, by &lt;a href="https://github.com/ysbaddaden/http2" rel="noopener noreferrer"&gt;&lt;code&gt;ysbaddaden/http2&lt;/code&gt;&lt;/a&gt; from Julien Portalier, a Crystal core contributor. Its status list has HPACK, frames and streams, flow control per stream and per connection, HTTP/1-to-HTTP/2 upgrades, server connections, integration into &lt;code&gt;HTTP::Server&lt;/code&gt;, and a green run against h2spec 2.6.0. Adding it to an existing server is one require:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight crystal"&gt;&lt;code&gt;&lt;span class="nb"&gt;require&lt;/span&gt; &lt;span class="s2"&gt;"http2/server"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The remaining unchecked box is &lt;code&gt;HTTP::Client&lt;/code&gt;; the author described in the issue what that would take. gRPC is in the same position — there is a pure-Crystal implementation, and it is somebody's shard rather than a stdlib module.&lt;/p&gt;

&lt;p&gt;So the honest form of this trap is narrower than "no HTTP/2", and it is the form that actually bit me: &lt;strong&gt;a shard is not the standard library.&lt;/strong&gt; This project's whole premise is an empty dependency list, so a solution distributed as a shard is a solution it cannot take, however good it is. For anyone without that rule the cost is one dependency. For anyone with it, protocol support that lives outside stdlib is support that does not exist.&lt;/p&gt;

&lt;p&gt;Worth knowing either way: browsers require TLS for HTTP/2 even on localhost, so the shard also means certificates and &lt;code&gt;bind_tls&lt;/code&gt;, not just a require.&lt;/p&gt;

&lt;p&gt;For this project it cost nothing: a ground station serves a handful of clients on a LAN, and HTTP/1.1 with a keep-alive is more than enough. Settle it up front anyway. Whether the protocols you need live in the standard library or in somebody's shard is a question for the week you pick the language, not for the month you discover the answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 5: the ecosystem lags the compiler
&lt;/h2&gt;

&lt;p&gt;Crystal releases move faster than the tools around them, and you will feel it at the edges rather than in the language.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ameba 1.6 does not build against Crystal 1.21&lt;/strong&gt; — the compiler's lexer API changed underneath it. The fix is to pin the development version by tag, which is what &lt;code&gt;shard.yml&lt;/code&gt; does:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;development_dependencies&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ameba&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;github&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;crystal-ameba/ameba&lt;/span&gt;
    &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1.7.0-dev&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pinned to a tag rather than a branch, so a checkout stays reproducible. Note the shape of the problem: the one linter everybody uses needed a pre-release to work with the current compiler.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cross-building on CI is where the days go.&lt;/strong&gt; The arm64 + musl jobs failed before they started: &lt;code&gt;actions/checkout&lt;/code&gt; is a JavaScript action, GitHub builds Node for Alpine only on x64, and the job died on an arm64 Alpine runner before reaching a single build step. The toolchain had to move inside &lt;code&gt;docker run&lt;/code&gt;. Four defects in the release pipeline in total, none of which could appear locally, and all of which appeared on the first push.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;libm differs between platforms, and your tests must know it.&lt;/strong&gt; The full SGP4 verification set passes on glibc and on musl, but not bit-for-bit: the worst position disagreement is 8.26 × 10⁻⁸ km on one and 8.29 × 10⁻⁸ km on the other, both 0.083 mm, because the two libms differ in the last place of their trigonometric functions. For scale, the two published reference implementations disagree with &lt;em&gt;each other&lt;/em&gt; by 7 × 10⁻⁸ km, so this is the noise floor and nothing else. State numerical tolerances physically; a bitwise comparison would fail a correct implementation built against the other libc.&lt;/p&gt;

&lt;h2&gt;
  
  
  Would I use it again
&lt;/h2&gt;

&lt;p&gt;For this shape of project, without hesitating. A daemon that has to be one file, start instantly, hold single-digit megabytes, run on a single-board computer with no network and no sysadmin, and still be readable a year later — Crystal is close to ideal, and I do not know a language that would have been meaningfully better. The zero-dependency rule was sustainable only because the standard library is good enough to make it sustainable.&lt;/p&gt;

&lt;p&gt;Against that: a standard library without HTTP/2 or gRPC, so anything modern on the wire means taking a dependency; a linter that needs a pre-release to match the compiler; cross-compilation that has to be learned the hard way; and a hiring pool of approximately nobody. If any of those are load-bearing for you, the decision is made.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The project: &lt;a href="https://github.com/VanyaNeytrino/kozai" rel="noopener noreferrer"&gt;github.com/VanyaNeytrino/kozai&lt;/a&gt;. Every number above is measured and reproducible from that repository — the memory figures in the README's "No dependencies" section, the allocation guarantee in &lt;code&gt;spec/allocation_spec.cr&lt;/code&gt;, the purity rules in &lt;code&gt;.github/workflows/ci.yml&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>crystal</category>
      <category>programming</category>
      <category>performance</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I blocked accessibility overlays on 56 Shopify stores and measured what changed</title>
      <dc:creator>Ivan Yatsenko</dc:creator>
      <pubDate>Sun, 09 Aug 2026 07:19:05 +0000</pubDate>
      <link>https://dev.to/ivanyatsenko/i-blocked-accessibility-overlays-on-56-shopify-stores-and-measured-what-changed-3il</link>
      <guid>https://dev.to/ivanyatsenko/i-blocked-accessibility-overlays-on-56-shopify-stores-and-measured-what-changed-3il</guid>
      <description>&lt;p&gt;&lt;strong&gt;Median change to the underlying markup: zero — in every one of six runs, across three samples drawn at different times.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That sentence needs about two thousand words of context before it means anything, including the three occasions where I nearly published a number that was wrong. Here they are.&lt;/p&gt;

&lt;p&gt;Start with what it does not measure: whether a widget helps anyone. It measures whether a page's markup changes when the widget runs. A tool with a genuinely useful toolbar — bigger text, a reading mode, a contrast switch — scores zero here and may still be worth having. The full limitations are below, and they are load-bearing.&lt;/p&gt;




&lt;h2&gt;
  
  
  The comparison that cannot work
&lt;/h2&gt;

&lt;p&gt;Accessibility overlay widgets attach a script to your site and are sold as a way to improve its accessibility. The obvious way to test that is to measure sites that have one against sites that don't.&lt;/p&gt;

&lt;p&gt;I did that first. It produced a clean, statistically significant result pointing in an interesting direction, and it is worthless.&lt;/p&gt;

&lt;p&gt;In a pool of 233 German Shopify storefronts, the 17 stores with an overlay installed had a median of 25 violation nodes; the 216 without had 14. The overlay group carried &lt;strong&gt;78.6% more&lt;/strong&gt;, and the difference clears the usual bar (Mann–Whitney, z = 2.48, p = 0.013). Read that as a fact about which stores buy a widget, not about what a widget does. Group A is 17 stores and a median over 17 is fragile; the international contour, with 65 stores against 71, shows no statistically detectable difference (5.7%, z = −0.90, p = 0.369). Nor can I say why the German groups differ. Overlay stores do carry heavier pages, a median of 3,068 DOM nodes against 2,714, but that is 13% more markup against 78.6% more violations, so page weight accounts for a small part of the gap at best. The conclusion of this article rests on the paired test in the next section, not on either of these numbers.&lt;/p&gt;

&lt;p&gt;The problem is structural. Nobody installs an accessibility widget at random. You go looking for one because you have a problem, or because a lawyer's letter arrived, or because your category is one where this gets noticed. The stores that bought a widget are not a control group for the stores that didn't — they are the stores that had a reason. That comparison can never separate the effect of the tool from the reason someone reached for it, no matter how many sites you throw at it or how small the p-value gets.&lt;/p&gt;

&lt;p&gt;I could have published the 78.6%. It is a real number, it is significant, and it flatters a thesis people enjoy. It is also uninterpretable, and dressing it up with a p-value would only have made it more convincing than it deserves.&lt;/p&gt;

&lt;h2&gt;
  
  
  The comparison that does work
&lt;/h2&gt;

&lt;p&gt;Measure the same page twice, a minute apart, and change exactly one thing: whether the browser is allowed to load the overlay's script.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ON   → load the page as served, wait, run axe-core
OFF  → load the page again, abort every request to the overlay's CDN, wait, run axe-core
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same URL. Same viewport, 1440×900. Same locale and &lt;code&gt;Accept-Language&lt;/code&gt;. Same settle delay. Same axe-core version and the same rule set. Same network position — a German exit node throughout — seconds apart, so the same CDN edge and the same storefront state. Fresh browser context each time, so nothing carries over.&lt;/p&gt;

&lt;p&gt;The difference between those two numbers is what the widget does to the page. There is no selection left to worry about, because the store is its own control.&lt;/p&gt;

&lt;p&gt;I ran this on &lt;strong&gt;56 distinct stores&lt;/strong&gt; — a German sample of 16, an international sample of 20, and a fresh sample of 20 that had never been measured, drawn deliberately &lt;em&gt;after&lt;/em&gt; the first results existed so that the finding had a chance to fail against data it wasn't derived from. Two of the three samples were measured twice, and one vendor had to be measured again after a bug described below, which is how 56 stores produce &lt;strong&gt;92 store-runs&lt;/strong&gt; and &lt;strong&gt;179 page pairs&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What came out
&lt;/h2&gt;

&lt;p&gt;Nodes removed by the widget, per run. Read "removed" as OFF minus ON: positive means the widget took violations away.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;run&lt;/th&gt;
&lt;th&gt;pairs&lt;/th&gt;
&lt;th&gt;stores&lt;/th&gt;
&lt;th&gt;nodes removed&lt;/th&gt;
&lt;th&gt;of&lt;/th&gt;
&lt;th&gt;share&lt;/th&gt;
&lt;th&gt;median per store&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;German&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;468&lt;/td&gt;
&lt;td&gt;2.6%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.0%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;English, run 1&lt;/td&gt;
&lt;td&gt;35&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;106&lt;/td&gt;
&lt;td&gt;1,920&lt;/td&gt;
&lt;td&gt;5.5%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.0%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;English, run 2&lt;/td&gt;
&lt;td&gt;35&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;99&lt;/td&gt;
&lt;td&gt;1,921&lt;/td&gt;
&lt;td&gt;5.2%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.0%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fresh, run 1&lt;/td&gt;
&lt;td&gt;37&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;td&gt;41&lt;/td&gt;
&lt;td&gt;1,343&lt;/td&gt;
&lt;td&gt;3.1%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.0%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fresh, run 2&lt;/td&gt;
&lt;td&gt;35&lt;/td&gt;
&lt;td&gt;18&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;1,215&lt;/td&gt;
&lt;td&gt;1.4%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.0%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Accessibly, re-measured&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;−3&lt;/td&gt;
&lt;td&gt;905&lt;/td&gt;
&lt;td&gt;−0.3%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.0%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;all six&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;179&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;56&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;272&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7,772&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.5%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.0%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The widgets in the sample were UserWay (23 stores), accessiBe (15), Accessibly (12), EqualWeb (4) and AudioEye (2). &lt;strong&gt;Results are not broken down by product&lt;/strong&gt;: the split is uneven and no per-vendor number here would survive its own sample size. Nothing in these figures should be attached to any one named product. The Accessibly row exists only because that vendor had to be re-measured, not to single it out.&lt;/p&gt;

&lt;p&gt;Two summary figures, and they disagree on purpose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3.5%&lt;/strong&gt; is node-weighted: every violation node counts equally, so one large storefront with hundreds of findings can carry a whole run. &lt;strong&gt;0.0%&lt;/strong&gt; is the median across stores: every store counts once, regardless of size. The median came out at zero in all six runs.&lt;/p&gt;

&lt;p&gt;The rest of the shape:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;56 of 92&lt;/strong&gt; store-run observations showed &lt;em&gt;no change whatsoever&lt;/em&gt; — 61%.&lt;/li&gt;
&lt;li&gt;Per-run share ranged from &lt;strong&gt;−0.3% to 5.5%&lt;/strong&gt;. That spread, on samples this size, is the honest measure of how much a single run can wander.&lt;/li&gt;
&lt;li&gt;In every run some stores measured &lt;strong&gt;worse&lt;/strong&gt; with the widget enabled than with it blocked — 1, 3, 2, 2, 4 and 2 stores respectively. That is mostly run-to-run noise rather than a widget effect. Across the two English runs, 3 different stores came out worse and only 2 did so both times; across the two fresh runs, 4 different stores and again only 2 both times.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In one sentence: on most of these storefronts, blocking the widget's script changed nothing an automated checker could see, and where something did change it was small and did not reproduce reliably between runs on the same stores.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three times I nearly published something false
&lt;/h2&gt;

&lt;p&gt;All three were caught by re-running measurements I had already written down as results. All three push the number in the flattering direction. If you repeat this work, you will hit them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lazy loading
&lt;/h3&gt;

&lt;p&gt;An early run showed one German storefront dropping from 46 violation nodes to 21 — a 54% improvement, exactly the kind of number that ends up on a slide.&lt;/p&gt;

&lt;p&gt;It was not the widget. The two measurements used different settle delays, 5 seconds and 20 seconds. Almost the entire difference is a single rule: &lt;code&gt;image-alt&lt;/code&gt; fell from 31 to 7, while the page kept growing between the two measurements. The storefront's own lazy-loading was still working at 5 seconds, and the placeholder images it had not yet swapped out were being counted as images with no alt text. What I had measured was how long I was willing to wait.&lt;/p&gt;

&lt;p&gt;Re-run as an actual pair — widget allowed and widget blocked, both at the same delay: &lt;strong&gt;46 and 46&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This is why the settle delay is not a detail you tune for convenience. Any asymmetry in how long you wait becomes a measurement of page weight wearing an accessibility result's clothes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pages that never rendered
&lt;/h3&gt;

&lt;p&gt;The fresh sample nearly ended the project. Run 1 gave −17 nodes; run 2, on the identical sample under identical settings, gave +116. A result that flips sign between consecutive runs is not a result.&lt;/p&gt;

&lt;p&gt;Per store, though — before any exclusion — 16 of 20 were stable within three nodes, and three stores produced 92% of the variance. One of them returned a single violation node on the widget-enabled side against 56 on the blocked side: the page had not rendered at all, and it had no &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt;. That one blank page contributed &lt;strong&gt;+98 of that run's +116&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So I wrote down a validity rule — before checking which stores it would remove:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A pair is discarded if one side has ≤ 3 violation nodes while the other has ≥ 15, or if the two sides disagree about whether the page had a &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It removes 4 pairs across the whole study. With it applied, the fresh sample gave 3.1% and 1.4% — inside the range the original samples had already produced. The finding survived contact with data it had not been built on.&lt;/p&gt;

&lt;p&gt;I mention the order — rule first, then look — because doing it the other way round is the whole game. A threshold chosen after you have seen which points it excludes is not a threshold, it is an opinion with arithmetic attached.&lt;/p&gt;

&lt;h3&gt;
  
  
  A vendor that was never actually switched off
&lt;/h3&gt;

&lt;p&gt;This is the one that should have killed the study, and the only reason it did not is that I checked a field I had been collecting and never using.&lt;/p&gt;

&lt;p&gt;The OFF side works by aborting every request whose URL contains one of a list of overlay hosts. My list contained &lt;code&gt;accessiblyapp.com&lt;/code&gt;. Accessibly serves its script from &lt;code&gt;cdn.accessibly.app&lt;/code&gt;. Those two strings do not match, so for &lt;strong&gt;every pair of every run&lt;/strong&gt;, that vendor's widget loaded normally on both sides. Thirty pairs that I was counting as ablations were the same page measured twice.&lt;/p&gt;

&lt;p&gt;Nothing looked wrong. Those pairs produced small differences clustered around zero — which is exactly what the study concluded — so the bug was &lt;strong&gt;holding the headline up rather than knocking it down&lt;/strong&gt;. A result that agrees with you is the hardest kind to audit.&lt;/p&gt;

&lt;p&gt;What exposed it: &lt;code&gt;ablation.mjs&lt;/code&gt; records &lt;code&gt;widgetInDom&lt;/code&gt; on the ON side, whether the widget's own button or panel is present after the delay. Comparing that field between ON and OFF, Accessibly never disappeared — 30 pairs out of 30. Then a second surprise: after fixing the host list, it still never disappeared, because &lt;code&gt;accessibly-trigger&lt;/code&gt; and &lt;code&gt;accessibly-config&lt;/code&gt; are rendered into the page by the Shopify app itself and sit there whether the script loads or not. &lt;strong&gt;&lt;code&gt;widgetInDom&lt;/code&gt; is a vendor-dependent proxy and cannot be used as a validity filter&lt;/strong&gt; — a conclusion I reached only after briefly believing the opposite. What settles it is the request log: with the corrected host list the script request is aborted, and that is the thing being manipulated.&lt;/p&gt;

&lt;p&gt;The 30 bad pairs are excluded and those 12 stores were measured again with the list fixed. Properly switched off, Accessibly removes &lt;strong&gt;−3 nodes across 23 pairs&lt;/strong&gt; — three nodes worse with the widget running. The median per store is 0.0%, like everything else.&lt;/p&gt;

&lt;p&gt;The full accounting, since a denominator you cannot reconstruct is not worth much:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;pairs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;attempted&lt;/td&gt;
&lt;td&gt;192&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;lost — one side hit the 40 s navigation timeout&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;discarded by the validity rule&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;excluded — the block never engaged for this vendor&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;30&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;re-measured with the host list corrected (24 attempted, 1 discarded)&lt;/td&gt;
&lt;td&gt;+23&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;analysed&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;179&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The lesson is not "check your regexes". It is that a manipulation has to be verified, not assumed. I had written in my own method notes that a pair where the widget never ran is not testing anything, and then never enforced it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Half the problem is one rule
&lt;/h2&gt;

&lt;p&gt;Across the wider survey — 729 pages, 367 distinct stores, 18,383 violation nodes — the findings sort like this. (233 of those stores sit in the German contour and 136 in the international one; that sums to 369 rather than 367 because two stores appear in both.) All of these counts are lower bounds: axe-core decides only the part of WCAG a machine can decide, and one node is one element failing one rule, not a measure of how much it hurts.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;bucket&lt;/th&gt;
&lt;th&gt;nodes&lt;/th&gt;
&lt;th&gt;share&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;deterministic fix, no meaning to invent&lt;/td&gt;
&lt;td&gt;3,037&lt;/td&gt;
&lt;td&gt;16.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;detection deterministic, fix is generated text (alt, link name)&lt;/td&gt;
&lt;td&gt;5,824&lt;/td&gt;
&lt;td&gt;31.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fixable but the visual design changes (contrast, target size)&lt;/td&gt;
&lt;td&gt;9,219&lt;/td&gt;
&lt;td&gt;50.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;needs a human&lt;/td&gt;
&lt;td&gt;303&lt;/td&gt;
&lt;td&gt;1.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One rule, &lt;code&gt;color-contrast&lt;/code&gt;, is &lt;strong&gt;50.1% of everything found&lt;/strong&gt;. Every confident claim of the form "N% of accessibility problems can be fixed automatically" is, underneath, a claim about how you treat contrast — and contrast cannot be fixed without changing what the site looks like, which is a decision belonging to whoever owns the brand.&lt;/p&gt;

&lt;p&gt;I ran a separate test on that: if a handful of colour pairs generate most of a store's contrast findings, contrast is fixable centrally in a theme rather than node by node. Across 990 contrast nodes in 30 stores, the median store needs &lt;strong&gt;2 colour pairs to cover 80%&lt;/strong&gt; of its contrast findings — promising. But the share of stores clearing the coverage bar I had set in advance came out at &lt;strong&gt;39.6% against a 40% threshold&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That is four nodes out of 990. I left the threshold where it was and recorded the result as undecided. A test that lands this close hasn't told you the answer is no; it has told you your sample is too small to ask.&lt;/p&gt;

&lt;p&gt;One more thing about those contrast numbers: &lt;strong&gt;132 of the 990 nodes (13.3%), across five stores, have a computed contrast ratio below 1.1&lt;/strong&gt; — text almost exactly the colour of its background. That is the signature of an element that is invisible for some unrelated reason, not of unreadable text. I did not inspect them visually, and they are included in the totals above.&lt;/p&gt;

&lt;h2&gt;
  
  
  For completeness: how common are these widgets
&lt;/h2&gt;

&lt;p&gt;Not very. In a straight scan of German Shopify storefronts, &lt;strong&gt;16 of 886&lt;/strong&gt; had one — &lt;strong&gt;1.8%&lt;/strong&gt;. In a global sample, &lt;strong&gt;370 of 5,326&lt;/strong&gt; — &lt;strong&gt;6.9%&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Which raises a fair objection to the numbers further up: 17 of the 233 measured German stores had an overlay, and that is 7.3%, not 1.8%. Both are correct, and the gap is deliberate. At 1.8% a random draw of 233 stores would contain about four overlay stores, which is not enough to compare anything. So overlay stores were deliberately over-sampled into the measured set — roughly four times their natural rate.&lt;/p&gt;

&lt;p&gt;That is fine for the ablation, where each store is its own control and the sample's composition is irrelevant. It is one more reason the store-against-store comparison in the first section should not be read as representative of anything.&lt;/p&gt;




&lt;h2&gt;
  
  
  Limitations
&lt;/h2&gt;

&lt;p&gt;Read this section before quoting any number above. Several of these are large enough to change what you should conclude.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This measures DOM repair and nothing else.&lt;/strong&gt; The ablation answers exactly one question: does the source markup contain fewer machine-detectable violations when the widget runs? It does &lt;strong&gt;not&lt;/strong&gt; answer whether a widget helps anyone. An overlay can provide a genuinely useful toolbar — bigger text, a reading mode, a contrast switch — and leave the underlying markup untouched. &lt;strong&gt;Such a product scores 0.0% here and may still be valuable to a real person.&lt;/strong&gt; Nothing in this article should be read as a claim that any of these tools does not work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Automated testing catches a minority of WCAG.&lt;/strong&gt; axe-core checks the success criteria a machine can decide. Whether a label actually describes its field, whether reading order makes sense, whether a custom widget is operable by keyboard — none of that is in these numbers. A page with zero violations can be unusable; a page with many can be fine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A node is not a severity.&lt;/strong&gt; A missing alt on a decorative icon and a missing name on the checkout button both count as one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nobody tried to buy anything with a screen reader.&lt;/strong&gt; These are scanner counts, not user research. That is a different study and a more important one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Checkout was never measured.&lt;/strong&gt; Two pages per store: homepage and one product page. Shopify hosts checkout, and testing someone's checkout without permission is not something I was willing to do — so the single most important page in a store is absent from every number here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The German contour is now very small.&lt;/strong&gt; Most of the German ablation sample ran Accessibly, so removing the unblocked pairs cut it from 16 stores to 7. Treat that row as an indication, not a country-level result.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One network position, one moment.&lt;/strong&gt; All measurements ran from a German exit node between 5 and 8 August 2026, in headless Chromium with no profile or prior consent state. Geo-routing, CDN edges and consent banners all change what a scanner sees. This matters for the survey figures. It matters much less for the ablation, because both sides of every pair were measured from the same position seconds apart — that is the point of pairing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Blocking a CDN is not uninstalling.&lt;/strong&gt; Aborting requests to the overlay's host is the closest thing to a controlled off-switch available from outside a site, but a page could in principle behave differently when a script fails to load than when it was never referenced. And, as the third episode above shows, the block only works if your host list is right — which is now something the code checks rather than assumes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Small samples.&lt;/strong&gt; 92 store-runs, 179 pairs, three samples. The spread between runs is the honest error bar, and it is why the headline is a median across stores rather than any single run's total.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;None of this is legal advice or a compliance assessment.&lt;/strong&gt; Nothing here establishes whether any site meets any legal requirement in any jurisdiction. Conformance is a judgement about a whole product, and a scanner is not competent to make it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is not published
&lt;/h2&gt;

&lt;p&gt;No store domains, no raw HTML, no screenshots. These stores did not agree to be measured, and a list of named sites with their violation counts is a list of targets, not a methods note.&lt;/p&gt;

&lt;p&gt;The cost is real and worth stating plainly: &lt;strong&gt;you cannot check my arithmetic against my inputs.&lt;/strong&gt; You can only re-run the method on a sample you draw yourself. The code, the exact versions, the delays, the sampling procedure and the validity rule are all published so that this is possible:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/VanyaNeytrino/overlay-ablation" rel="noopener noreferrer"&gt;https://github.com/VanyaNeytrino/overlay-ablation&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Run it twice on the same sample before you believe any number it gives you — and check that the widget was actually switched off before you believe it did nothing.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Measured with axe-core 4.12.1 via @axe-core/playwright, Playwright 1.62.1, Chromium 151.0.7922.34, tags &lt;code&gt;wcag2a wcag2aa wcag21a wcag21aa&lt;/code&gt;, viewport 1440×900, 12,000 ms settle for ablation and 5,000 ms for the survey.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>a11y</category>
      <category>shopify</category>
      <category>testing</category>
    </item>
  </channel>
</rss>
