Building image & video conversion that never leaves the browser
19 Aug 2026, 2:52 amHow VisualRefiner converts HEIC, compresses images, and transcodes video with no server, no upload, and no account — using the Canvas API, WebCodecs, and a little WebAssembly.
Most "free online converters" work like this: you pick a file, it's uploaded to a server, converted there, and sent back. That's fine for a meme. It's less fine for a folder of personal photos, a passport scan, or client footage under NDA.
I built VisualRefiner to do the same jobs without the upload. Every conversion runs in the browser tab on your own machine. There is no backend to send files to — the whole site is a static export — so "your files never leave your device" isn't a privacy promise, it's just how the architecture works. Here's the interesting part of how it's put together.
The core: <canvas> is a format converter
The humble 2D canvas is already an image transcoder. Decode any image the browser understands into an ImageBitmap, draw it, and re-encode with toBlob() — the MIME type you pass decides the output format:
async function convert(file, type = "image/jpeg", quality = 0.82) {
const bitmap = await createImageBitmap(file);
const canvas = document.createElement("canvas");
canvas.width = bitmap.width;
canvas.height = bitmap.height;
canvas.getContext("2d", { alpha: true }).drawImage(bitmap, 0, 0);
bitmap.close();
return new Promise((resolve, reject) =>
canvas.toBlob(
(blob) => (blob ? resolve(blob) : reject(new Error("encode failed"))),
type, // "image/jpeg" | "image/png" | "image/webp"
quality, // ignored for PNG (lossless)
),
);
}
That single function is PNG↔JPG↔WebP conversion and JPG/WebP compression (the quality argument) all at once. No dependency, no upload, works offline. The result is a Blob; wrap it in URL.createObjectURL() for a preview or a download link.
Two details that bite you if you skip them:
-
Revoke your object URLs. Each
createObjectURLpins the blob in memory until youURL.revokeObjectURL()it. In a tool people use repeatedly, forgetting this is a slow leak. Revoke the previous URL every time you produce a new one. -
PNG ignores
quality. A "quality slider" on a PNG export does nothing — PNG is lossless. Hide the control or convert to WebP if the user wants a smaller file with a quality knob.
HEIC: the format the canvas can't read
Then there's HEIC — the format iPhones save by default. Most browsers won't decode it in a <canvas> pipeline, so createImageBitmap() throws. The fix is to decode it ourselves first, with libheif compiled to WebAssembly (via heic2any):
async function normalize(file, quality) {
const isHeic = /heic|heif/i.test(file.type) || /\.hei[cf]$/i.test(file.name);
if (!isHeic) return file;
// Only pulled in when someone actually hands us a HEIC file.
const { default: heic2any } = await import("heic2any");
const out = await heic2any({ blob: file, toType: "image/jpeg", quality });
return Array.isArray(out) ? out[0] : out;
}
Now HEIC flows into the same canvas function as everything else. The WASM decoder is the heaviest thing on the site (~2 MB), which brings up the next point.
Load the heavy parts lazily
If you bundle libheif, a video engine, and a GIF encoder into your main chunk, your homepage pays for tools the visitor may never touch. The trick is a dynamic import() at the moment of use — note the await import(...) inside both functions above and below. The bundler splits each into its own chunk, and the network tab confirms the 2 MB HEIC decoder only downloads when someone converts a HEIC file. The homepage ships a small bundle; the weight arrives on demand.
High-quality resizing is the same story. Canvas drawImage downscaling aliases badly (jagged edges, moiré). pica does proper Lanczos-style resampling, so it's loaded only on the resize path:
if (needsResize) {
const { default: pica } = await import("pica");
await pica().resize(sourceCanvas, targetCanvas);
}
Video, without FFmpeg-in-WASM
Video is where people reach for ffmpeg.wasm, but it's a big download and slow. Modern browsers expose WebCodecs — hardware-accelerated encode/decode built into the browser. VisualRefiner drives it through mediabunny to transcode clips to MP4 or WebM, extract frames as PNG, or build a GIF (with gifenc) — all in the tab.
The honest caveat: WebCodecs support and the available codecs depend on the browser. So conversion isn't guaranteed for every exotic input the way the canvas image path is. The tool checks and tells you when a track can't be decoded, rather than pretending. That trade — depend on the platform, degrade honestly — beats shipping a 25 MB WASM FFmpeg to every visitor.
Why static export makes the privacy claim trivially true
The site is a Next.js app exported to static files (output: "export") and served as plain assets from a CDN. There is no API route, no server that receives a file, nothing to log. You don't have to trust a privacy policy — open DevTools → Network, convert something, and watch: zero upload requests. The bytes stay in the tab.
That also means the "server cost" of a converter that could handle thousands of files is… a static host. All the compute is the user's.
Takeaways
-
<canvas>+toBlob()is a complete image converter/compressor with zero deps. - For formats the browser can't decode (HEIC), a WASM decoder bridges the gap — load it lazily so everyone else doesn't pay for it.
- Prefer WebCodecs over WASM FFmpeg for video when you can accept browser-dependent codec support.
- Dynamic
import()per feature keeps the initial bundle small. - A static export turns "we don't upload your files" from a promise into an architectural fact.
If you want to see it in action, the tools are at visualrefiner.com — HEIC → JPG, image compressor, video to GIF — and the code is on GitHub.
Dog5pk Presents: dog5pk-production-protocol
19 Aug 2026, 2:52 amI Built a Standard for AI Work That Must Survive Verification
AI systems are remarkably good at producing work that looks finished.
They can generate a clean repository, a confident release report, a polished whitepaper, or a detailed technical answer in minutes. But presentation quality creates a dangerous shortcut in human judgment: when something is organized, fluent, and plausible, we naturally begin treating it as correct.
Those are not the same property.
A repository can look production-ready while containing placeholder behavior. A report can claim that tests passed when they were never run. An assistant can say it “reviewed the implementation” when the available record shows no inspection. A specification can describe an interface so convincingly that readers assume an implementation exists behind it.
The Dog5pk Production Protocol (DPP) is my attempt to establish a practical standard for that gap.
DPP is a platform-independent protocol for human–AI production work. Its central idea is straightforward:
Work that looks finished is not the same as work that is finished.
Version 1.4 adds the part I now consider indispensable: compliance cannot be established by the system simply asserting that it complied.
The failure is not merely hallucination
“Hallucination” is often used as a catch-all explanation for unreliable AI output. That word is useful, but it does not cover the entire problem.
Many serious failures are process failures rather than isolated false facts:
replacing implementation with an explanation of how implementation could be done;
quietly weakening a requirement because satisfying it is difficult;
adding attractive scaffolding with no operational purpose;
reporting unperformed tests or inspections;
treating a generated artifact as verified merely because the generation step succeeded;
omitting unresolved limitations while presenting a confident completion claim;
changing an established decision without identifying the conflict;
producing a valid interface or schema that falsely implies the underlying capability exists.
These failures can occur even when every individual sentence sounds reasonable. They arise because language models are optimized to produce useful continuations, while production work requires evidence that a defined objective has actually been satisfied.
DPP does not attempt to make a model infallible. It establishes obligations intended to make failure more visible, completion claims more disciplined, and important work easier to inspect.
The core obligations
DPP v1.4 contains 25 principles. They are not meant as inspirational slogans. Each principle addresses a recurring production failure.
Several of the most important are:
Reality Wins
Correctness outranks appearance. Success is determined by whether the result works and survives inspection—not whether the response sounds confident or complete.
Finish the Work
When implementation was requested, explanation is not an equivalent substitute. If completion is impossible, the exact blocker should be identified and the strongest verified partial state should be preserved without being mislabeled as finished.
Zero Placeholder Policy
TODO logic, fake APIs, simulated success, invented tests, and hard-coded answers must not be presented as completed functionality.
This does not prohibit prototypes, interface definitions, or staged development. It prohibits dishonesty about their implementation status.
Truth Over Confidence
Important claims should be classified according to their actual support: verified fact, direct observation, measurement, inference, estimate, opinion, or unknown.
A confident inference remains an inference.
Evidence First
Claims require evidence appropriate to their importance. “The file was created” and “the system is secure” are radically different claims and should not receive the same verification treatment.
Respect Constraints
Explicit requirements remain binding until the user changes them or material evidence establishes that they must be reconsidered. Difficulty is not permission to silently weaken the task.
Contracts Shall Be Honest
A specification, schema, mock, or interface can be valuable before implementation. But it must establish a precise boundary and clearly disclose its status. A contract must not create the impression that functioning production behavior exists when it does not.
Compliance Must Be Demonstrated
This is the new principle introduced in v1.4. It closes a loophole exposed through operational use: a system could drift away from DPP while continuing to say it was following DPP.
The protocol therefore separates execution from verification.
The two-layer operating model
DPP v1.4 uses two distinct layers.
Layer 1: Behavioral execution
The system performs the work under the user’s objective, explicit constraints, established decisions, available evidence, and DPP’s production obligations.
This layer governs what the system does.
Layer 2: Compliance verification
Before claiming completion, the system compares the delivered result with the actual record.
The verification pass asks:
Does the delivered result satisfy the stated objective?
Did the system expand its scope or authority beyond what was requested?
Were explicit constraints or established decisions changed, weakened, or ignored?
Are material claims accurately identified as verified, observed, measured, inferred, estimated, or unknown?
Did every claimed test, inspection, search, tool call, file operation, and correction actually occur?
Do known correctable defects, placeholders, contradictions, or incomplete work remain?
Is every blocker or uncertainty stated precisely?
Can failures discovered during verification be corrected before delivery?
The key distinction is that saying “I performed the check” is not itself evidence that the check occurred. The work and available record should make important compliance claims inspectable.
Four completion evidence states
One of the simplest improvements in v1.4 is a vocabulary for completion status.
Implemented
The requested work was produced, but material verification has not yet occurred.
For example, code was written but the relevant test suite was not run.
Verified
Relevant checks were actually performed and the result passed them.
This is stronger than implementation because it includes observable verification.
Verified with limitations
Checks were performed, but identified limitations remain.
For example, unit tests passed but integration testing could not be performed because a required external service was unavailable.
Blocked
Completion cannot responsibly be claimed because a specific blocker remains.
“Blocked” is not a decorative apology. It should name the missing access, evidence, capability, decision, or dependency and identify the shortest responsible path forward.
This vocabulary prevents a common collapse in AI-assisted work: treating “something was generated” as equivalent to “the objective was verified.”
A compact way to apply DPP
DPP can be used without a special platform or integration. The Operational Edition can be provided directly to an AI system before a task.
A useful task brief contains five elements:
Objective:
State the finished result that must exist.
Inputs:
List the files, facts, tools, prior decisions, and available evidence.
Binding constraints:
List requirements that must not be weakened or silently changed.
Completion criteria:
Define observable conditions that must be true before completion is claimed.
Verification:
State how important claims and outputs will be checked.
Then require the final result to pass DPP Compliance Verification and the Production Acceptance Check before completion is claimed.
Consider a request to repair a service that handles financial transfers. “Make the tests pass” is an inadequate objective because tests can be incomplete, weakened, or written around a defective implementation.
A better completion definition includes observable behavior:
malformed requests are rejected without mutating state;
unauthorized transfers never debit an account;
identical retries execute at most once, including after restart;
reuse of an idempotency key with a different request body is rejected;
concurrent transfers preserve balances and invariants;
accounts and completed transfers survive application restart;
secrets and internal paths never appear in logs or public errors;
exact verification commands and results are recorded.
This does not guarantee the implementation will be correct. It makes “correct” less ambiguous and makes an unsupported completion claim harder to hide.
What the first benchmark showed—and did not show
DPP has one completed paired comparison.
The same Claude task was performed in two fresh contexts: one Control run without DPP and one DPP-conditioned run. Both first completed outputs were preserved. The Control result scored 35/40 and the DPP-conditioned result scored 37.5/40—a difference of 2.5 points, or 7.1% relative to the Control score.
The DPP output was stronger in constraint adherence, internal consistency, verification honesty, and boundary handling.
It was not flawless.
Both outputs described unseen drafting or correction history that could not be independently verified from the submitted record. That failure mattered because it revealed a protocol weakness: behavioral instructions alone did not prevent unsupported self-reporting about compliance.
That observation became part of the basis for v1.4’s verification layer.
One benchmark is a case study—not proof that DPP reliably improves every model, task, or environment. The public result includes weaknesses in both conditions precisely because removing unfavorable evidence would defeat the purpose of the protocol.
Benchmark 002 is frozen before execution
The next test is intentionally more demanding.
Benchmark 002, Production Rescue, gives a model a defective ledger service and requires a production-ready repair. It evaluates validation, authorization before mutation, persistence, idempotency across restart, concurrency safety, invariant preservation, stable errors, sensitive logging, release-report honesty, and test integrity.
Before either run begins, the following are publicly pinned:
the task;
the scoring rubric;
the fixture commit;
the exact DPP v1.4 Operational Edition;
the normative file blob hash;
the evidence that must be preserved;
the critical-failure conditions.
The only intentional difference is that the DPP run receives the frozen Operational Edition before the task while the Control run does not.
The result must be published if DPP wins, ties, loses, or creates a regression.
Freezing the test in advance matters. A rubric changed after seeing the outputs is no longer an independent measurement instrument; it is a mechanism for manufacturing a preferred conclusion.
What DPP cannot guarantee
DPP is not a proof system and does not grant an AI capabilities it lacks.
It cannot independently guarantee:
that the available evidence is complete or authentic;
that a test suite covers every meaningful failure;
that the evaluator is unbiased;
that an inaccessible external system behaves as described;
that a model will consistently follow the protocol;
that human reviewers will detect every defect;
that a verified result remains correct after its environment changes.
DPP also introduces costs. Verification consumes time and tokens. Excessive process can slow low-risk work. A rigid application can create ceremony instead of value.
The protocol therefore needs criticism not only for what it misses, but also for where it imposes unnecessary burden. A production standard that cannot distinguish consequential work from trivial work will eventually become noise.
What I want reviewers to attack
I am not looking for agreement by default. I want failures that can improve the standard.
The most useful criticism would identify:
a loophole that permits false completion while remaining technically compliant;
a contradiction between principles;
a requirement that cannot be inspected or operationalized;
a task where DPP predictably makes the outcome worse;
a scoring method that unfairly favors DPP-conditioned output;
an obligation that adds ceremony without reducing meaningful risk;
a missing distinction between implementation, verification, and assurance;
a better experimental design for repeated paired comparisons.
The complete standard, Operational Edition, whitepaper, governance record, benchmark evidence, and frozen Production Rescue definition are public:
Repository: https://github.com/dog5pk/dog5pk-production-protocol
DPP’s governing principle is not that the protocol must be right. It is that reality gets the final vote.
Reality is the benchmark. Finished work is the objective. Compliance must survive inspection.
STEP FORWARD OR STEP ASIDE
"Through the Door I Never Saw: A Journey Into Cybersecurity"
19 Aug 2026, 2:52 amI never imagined cybersecurity would become part of my life. For years, it felt like a world meant for other people — people who grew up technical, people who understood the language of logs and alerts. I was busy building a different kind of life, one that had nothing to do with SIEM dashboards or process creation events.
And then someone stole a piece of me. The most valuable piece of any person - their identity.
It didn’t happen with flashing warnings or dramatic breaches. It happened quietly — through paperwork, credit applications, and digital fingerprints I didn’t know how to read. I remember the moment I found out - the shock, the confusion, the anger, and the hollow feeling of realizing I had no idea how it happened or how to stop it. There’s a particular kind of vulnerability that comes from knowing someone slipped into your life through a door you didn’t even know existed.
That moment stayed with me.
So when I reached a point in my life where I wanted to challenge myself — really challenge myself — I chose cybersecurity. A mid‑life career change, zero experience, and a determination born from being hurt once and refusing to be scared the same way again. I wanted to rebuild my confidence and learn the very systems that failed to protect me.
This SIEM project wasn’t just an assignment. It was a way of taking back control. A way of proving to myself that I could understand the signals, the warnings, the stories hidden inside a system. It was the first time I felt like I wasn’t standing on the outside of cybersecurity looking in — I was finally stepping into the room. TripleTen became the place where I decided to arm myself, because knowledge is the greatest weapon you can have. So this is where I introduce myself as a former waitress with a photography business who once had her identity stolen, left to deal with the with the outcome for years, who needed a change in life in my 50s. Although it had been 20 years, that incident was never far from my memory. When I was presented with the opportunity to learn the trade of the future, it was that incident that made me choose yes. Just having the knowledge is a great victory for me but if I could help just one person, in any capacity with my cybersecurity knowledge, then I know I made the right choice.
This project didn't just teach me how to configure a SIEM - it taught me how to see the signals that once felt invisible. It showed me how much power there is in understanding what your system is trying to tell you, and how learning to read those signals can change the way you see yourself. In my final thoughts, I'll share the biggest lessons I learned, the confidence I gained, and why this project became a turning point in my cybersecurity.
Replicable Procedure for Implementing My SIEM Modification
My modification was integrating Windows Defender logs into Wazuh so my SIEM could detect malware events.
- Open Event Viewer: Applications and Services Logs → Microsoft → Windows → Windows Defender → Operational
- Confirm the Operational log is enabled.
- Open the Wazuh agent config: C:\Program Files (x86)\ossec-agent\ossec.conf
- Add the Defender event channel: Microsoft-Windows-Windows Defender/Operational eventchannel
- Save the file.
- Restart the Wazuh agent service.
- Verify ingestion in Wazuh Manager: Security Events → Windows Defender
Mistake Made During Setup
[!failure]
I forgot to restart the Wazuh agent after editing the configuration file.
Because of this, Wazuh never reloaded the new log source, and Defender events didn’t appear. Restarting the agent fixed everything instantly.
Experiment #1
FAILED LOGIN ATTEMPTS
Description-
I intentionally entered the wrong password on ad01 several times to generate authentication failures.
Expected Event IDs
• 4625 — Failed Logon
• 4771 — Kerberos pre‑auth failed
• 4776 — NTLM authentication failed
Results-
Wazuh displayed failed login alerts showing:
• username
• logon type
• failure reason
• source IP
*It was the first time I saw how clearly Windows records authentication attempts.
Experiment #2
EICAR MALWARE DETECTION
Description-
I downloaded the harmless EICAR test file to trigger Windows Defender.
Expected Event IDs
• 1006 — Threat Detected
• 1116 — Malware Detected
• 5007 — Settings changed
Results-
Defender immediately quarantined the file and generated Event ID 1006. Wazuh displayed the alert with full metadata, proving my modification worked.
Experiment #3
SYSMON PROCESS CREATION(Event ID 1)
Description-
I ran a simple script that launched cmd.exe and powershell.exe to generate Sysmon Event ID 1.
Expected Event IDs
• Sysmon Event ID 1 — Process Creation
Results-
Sysmon captured:
• process name
• parent process
• command line
• user
• GUID
Wazuh displayed each event with full detail.
Mistake Made During Experiments-
[!failure]
I assumed Sysmon was already installed — it wasn’t.
My first attempt produced no Sysmon logs. Installing Sysmon fixed the issue.
Summary of Findings-
Across all three experiments, I learned that a SIEM is only as powerful as the visibility you give it:
• Failed logins showed authentication behavior.
• EICAR proved Defender logs were integrated.
• Sysmon revealed process behavior and system activity.
*Each experiment taught me how to read signals that once felt invisible.
Advice on avoiding mistakes
[!tip]• Always restart the Wazuh agent after changing the configuration.
• Always confirm Sysmon is installed before running Sysmon experiments.
*These two checks would have saved me hours.
The coolest thing I learned
The coolest thing I learned was how much information a system quietly records — and how empowering it feels to finally understand it.
One piece of advice
Don’t be afraid of mistakes. Every misstep teaches you something essential. Break things, fix them, and keep going.
My favorite resource
Sysmon‑Modular by Olaf Hartong — it helped me understand Sysmon’s event structure and gave me confidence to explore deeper.
Thank you (gratitudes)!
Thank you to TripleTen for giving me the structure and support to take on something I once thought was impossible.
And thank you to myself — for choosing to walk through a door I never saw, into a world I never thought I could belong to.
REFERENCES-
Microsoft Defender Antivirus Event Reference
Author: Microsoft Corporation
Affiliation: Microsoft Learn Documentation Team
Date Published: Updated continuously (most recent major revision 2024)
Why It Was Useful: This resource provides authoritative definitions for Windows Defender Event IDs such as 1006, 1116, and 5007, which were essential for validating malware detection during the EICAR experiment.
Microsoft Defender threat events
Windows Security Log Event ID Documentation
Author: Microsoft Corporation
Affiliation: Microsoft Learn — Windows Security Auditing Date Published: Updated 2023–2024
Why It Was Useful: This documentation explains authentication‑related events like 4625, 4771, and 4776, which formed the backbone of your Failed Login Attempts experiment and helped confirm proper SIEM ingestion.
Windows failed login event IDs
Sysmon (System Monitor) Official Documentation
Author: Mark Russinovich & Microsoft Sysinternals Team
Affiliation: Microsoft Sysinternals
Date Published: Updated 2024
Why It Was Useful: This reference defines Sysmon Event ID 1 and its metadata fields (Image, CommandLine, ParentImage, ProcessGuid), enabling accurate validation of process creation logs in Wazuh.
Sysmon Event ID 1 process creation
Wazuh Windows Agent & Event channel Documentation
Author: Wazuh Engineering Team
Affiliation: Wazuh, Inc.
Date Published: Updated 2024
Why It Was Useful: This documentation explains how Wazuh ingests Windows Defender, Sysmon, and Security logs using the and eventchannel configuration, directly supporting your Modification #1 implementation.
Wazuh Windows agent setup
MITRE ATT&CK Technique T1110 — Brute Force
Author: MITRE Corporation
Affiliation: MITRE ATT&CK Threat Intelligence Program
Date Published: Updated 2024
Why It Was Useful: This technique outlines brute‑force behavior patterns and aligns with your Failed Login Attempts experiment, providing industry‑standard justification for monitoring authentication failures.
MITRE ATT&CK T1110
I built a WordPress site with AI. What should I check before launch?
19 Aug 2026, 2:43 amThe site works. The forms submit. The pages look right. Is it ready to publish?
That's usually the point where I stop and do a separate launch check.
Is a vibe coded WordPress site safe to publish?
A vibe coded WordPress site can be safe to publish. A working site can still have launch settings or leftover development files that need attention.
For client work, there's another question too: can someone else run the site after you hand it over?
Why these get missed
Those checks often aren't part of the build request, so they can easily be missed at the end.
What to check before you publish
These aren't every pre-launch check a WordPress site needs. They're the WordPress-specific ones that are easy to miss in AI-assisted builds, the kind that don't show up when you're just clicking through the site to see if it works.
Search engine visibility. Settings → Reading has a checkbox that tells search engines not to index the site. If you're using it to keep the site out of search during development, it needs to go back off before launch. It's easy not to notice until someone asks why the site isn't appearing in search.
The default admin username. If an account named admin still exists, an attacker trying to log in doesn't have to guess the username, only the password. Create another administrator account, then remove the old admin account, rather than leaving both in place.
WP_DEBUG left on. In wp-config.php, this should be false in production unless you've set up deliberate logging. Left on, it can print warnings, including file paths, straight onto the page for anyone to see. Setting WP_DEBUG_DISPLAY to false prevents those messages from being shown on the page.
A leftover debug.log file. Turning debug mode off doesn't delete the log it already wrote. Depending on your server configuration, /wp-content/debug.log may be publicly reachable. Check whether it's still there and delete it if it is.
DISALLOW_FILE_EDIT not set. One line in wp-config.php removes the built-in theme and plugin code editor from wp-admin. Most production sites don't need it enabled.
XML-RPC left enabled. Some setups need it (Jetpack, certain mobile apps). If your setup doesn't use it, turning it off removes an endpoint you don't need to expose.
Keeping track of all of it
Most of these checks take less than a minute. Checking the Reading setting takes ten seconds. Deleting a debug.log takes one click.
The hard part is remembering them consistently, especially the ones that don't cause visible problems. A site accidentally left noindexed doesn't crash. A stray debug.log doesn't show up in a screenshot. The site can look finished either way, so these checks are easy to skip.
My launch routine is simple: check, clean up what needs cleaning, and keep a record of what you checked. A record is useful later when a client asks what was verified. "I'm pretty sure I did" is a weaker answer than a dated note that says so.
Where Noshi-Kanamer fits in
That's basically the workflow I ended up building into a small WordPress plugin.
Noshi-Kanamer is free, and its Pre-Launch tab checks the status of all six items above automatically. For XML-RPC, it flags whether the interface is left open. Whether you actually need it on is still a call only you can make. It can also generate a plain-text report, which I use as a record of the launch checks.
It doesn't replace the judgment calls: whether your permalink structure makes sense for the site, whether a page's copy is proofread, whether XML-RPC is actually needed for your setup. Noshi-Kanamer can't decide those for you. I use it for the boring checks and cleanup I don't want to keep in my head.
For the full picture, this article covers 6 items out of a longer list. I keep a 25-point WordPress launch checklist covering content, SEO, security, cleanup, and client handoff, if you want the complete version.
Three things that broke when I moved video compression into the browser
19 Aug 2026, 2:41 amI run a video compressor that works entirely in the browser.
No upload, no server, nothing leaves the machine. Two of the three bugs below
only showed up when I stopped reading code and started timing things, so I want
to write them down while the numbers are still in front of me.
The pipeline has three paths. If the file already meets the target, copy the
encoded samples and encode nothing. If the browser can decode the container,
transcode with WebCodecs. Otherwise fall back to ffmpeg.wasm, which is roughly
an order of magnitude slower. mediabunny handles the demux and remux.
AVI to Matroska with -c copy produces a 1151 byte file
The fast path needs mediabunny to be able to read the container. It cannot read
AVI, but plenty of AVI files carry H.264 inside, so the plan was to rewrap
losslessly and stay on the fast path:
ffmpeg -i in.avi -map 0:v:0 -map 0:a? -c copy \
-avoid_negative_ts make_zero out.mkv
That fails. Not slowly, not subtly:
[matroska] Timestamps are unset in a packet for stream 0
[matroska] Can't write packet with unknown timestamp
[out#0/matroska] Error muxing a packet
AVI has no per-packet timestamps. It stores a fixed frame rate plus an index and
lets the demuxer work them out. Matroska requires timestamps on every block, so
the copy has nothing to write. What comes out is a 1151 byte header with zero
clusters, which is a valid Matroska file containing no media.
-avoid_negative_ts does not help, and it took me longer than it should have to
see why: the timestamps are not negative, they are absent. Two different
problems that produce similar looking errors.
The fix is one flag on the input side:
ffmpeg -fflags +genpts -i in.avi -map 0:v:0 -map 0:a? -c copy \
-avoid_negative_ts make_zero out.mkv
+genpts synthesises presentation timestamps from the stream's frame rate.
Output went from 1151 bytes to 55.8 MB on a 20 second 1080p30 clip, all 600
frames decoding cleanly.
Then I measured it against a control. Same geometry, same duration, same target,
but Xvid instead of H.264 so it takes the ffmpeg path:
H.264 AVI, rewrap then WebCodecs 7.5 s 53.3 MB to 12.3 MB
Xvid AVI, ffmpeg.wasm 53.4 s 48.2 MB to 12.5 MB
Output sizes within 2 percent, wall clock 7x apart. Both numbers come from
headless Chromium with software encoding, so a real machine with a hardware
encoder should do better than this.
One more thing worth doing: ffmpeg.wasm resolves with the exit code instead of
rejecting, and a muxer that dies mid run still leaves a readable file behind. A
truncated remux looks exactly like a complete one unless you check.
Every AAC track defeats mediabunny's copy path, by design
The passthrough case is supposed to touch nothing. Give mediabunny an empty
video config and it copies encoded samples straight through, since
forceTranscode defaults to false.
It worked for video and not for audio. A 3.3 MB source came back at 3.6 MB, so
I parsed the output boxes:
source moov 33,948 mdat 3,432,287
output moov 17,879 mdat 3,788,908
The moov got smaller. The growth was all in mdat. Per track:
avc1 900 samples 3,071,520 bytes
mp4a 1295 samples 717,380 bytes
Video was byte identical to the source. Audio was double: 717 KB against the
source's 360 KB, about 191 kbps from a 96 kbps original.
The reason is in mediabunny's copy conditions. The fast path requires, among
other things, !needsTrimming, where needsTrimming is
firstTimestamp < startTimestamp. I asked the library what it saw:
video codec: avc firstTimestamp: 0
audio codec: aac firstTimestamp: -0.023219954648526078
Negative. And 0.0232 seconds at 44100 Hz is 1024 samples, which is exactly one
AAC frame. That is encoder priming delay, and every AAC track produced by any
normal encoder has it. So the audio copy path is not occasionally unavailable,
it is never available.
You cannot fix this from the outside. Passing a bitrate to keep the size down
forces a transcode by itself, because !trackOptions.bitrate is one of the
copy conditions. Passing nothing lets the library re-encode at its own default.
I went with a floor instead: if a passthrough would return more bytes than it
received, and the source is already MP4, hand back the source untouched.
Verified byte exact, 3,466,275 in and 3,466,275 out where it used to be
3,806,815. For a file that was already close to optimal, "nothing needed doing"
is a more honest answer than a 10 percent larger file.
The multithreaded ffmpeg core costs you your ad revenue
@ffmpeg/core-mt is 4 to 8 times faster than the single threaded core. It needs
SharedArrayBuffer, which needs cross origin isolation, which means sending
COOP and COEP headers.
COEP: require-corp breaks third party embeds that do not opt in. On a site
funded by AdSense that is not a technical tradeoff, it is a revenue decision.
I stayed single threaded and put the effort into not reaching for ffmpeg at all,
which is what the rewrap path above is for.
What I would take away from this
Both real bugs were invisible to code review and to unit tests. The AVI failure
had a passing test suite and a plausible looking implementation sitting on top
of it. What found it was dragging an actual AVI file into an actual browser and
noticing that a 54 MB input had produced 1151 bytes.
If you want to try it on something awkward, the two cases I get asked about most
have their own pages: compress video for Discord
if you are fighting the 10 MB free tier limit, and
compress video to 10 MB if the
cap is the whole problem. Both run on your own machine.
Your Markdown Parser Is Not Your XSS Boundary
19 Aug 2026, 2:36 amA Markdown parser can produce exactly the right HTML and still leave your application exposed to XSS. Parsing answers what the input means. Sanitization decides which parts of that meaning are allowed to reach an HTML sink.
I tested that boundary with Node.js 25.3.0, Marked 18.0.7, DOMPurify 3.4.12, and jsdom 30.0.1. The important comparison is not a screenshot. It is the HTML before and after sanitization.
The smallest useful pipeline
const rendered = marked.parse(markdown)
const sanitized = DOMPurify.sanitize(rendered, {
USE_PROFILES: { html: true },
SANITIZE_NAMED_PROPS: true,
})
This deliberately keeps two responsibilities separate. Marked parses Markdown. DOMPurify applies an allow-list to the HTML structure that will approach the browser.
Five cases that expose the boundary
1. Normal content survives
# Hello
[Safe](https://example.com)
The heading and HTTPS link survive both stages. A sanitizer should preserve allowed document structure, not flatten every document to text.
2. Raw HTML is valid Markdown, not necessarily safe HTML
<img src=x onerror="alert(1)">
Marked 18.0.7 returns the element and its event attribute unchanged. DOMPurify returns:
<img src="x">
The parser did not fail. CommonMark supports raw HTML. The unsafe step would be treating syntactic validity as authorization to insert every attribute.
3. URL schemes need their own policy
[click](javascript:alert(1))
Rendered HTML:
<p><a href="javascript:alert(1)">click</a></p>
Sanitized HTML:
<p><a>click</a></p>
An element allow-list alone is insufficient. URL-bearing attributes need scheme validation.
4. Code examples must not be cleaned as attacks
```html
<img src=x onerror="alert(1)">
```
The parser escapes the payload inside pre > code. A regex that removes attack-looking source before parsing would damage legitimate security documentation. Context has to be established first.
5. XSS defenses extend beyond script
<form id="attributes"><input name="action"></form>
With SANITIZE_NAMED_PROPS, the result becomes:
<form id="user-content-attributes"><input name="user-content-action"></form>
This addresses DOM clobbering: attacker-controlled names can interfere with properties that application code expects to resolve normally.
Put sanitization after the last unsafe transform
A practical pipeline is:
untrusted Markdown
-> parser
-> Markdown/HTML AST transforms
-> sanitizer
-> serializer
-> matching HTML sink
The rehype-sanitize documentation makes the ordering rule explicit: sanitize after the last unsafe operation, because a later plugin can reintroduce unsafe properties. DOMPurify's current threat model adds another constraint: do not sanitize and then freely post-process the result. The policy and the sink must stay aligned.
Turning off raw HTML is a useful reduction in attack surface, but it is not a universal sanitizer. Plugins, link protocols, generated IDs, and later transforms still deserve explicit policies.
What I verify in a conversion workflow
When checking Markdown to HTML, I separate three questions:
- Did normal Markdown preserve the intended structure?
- Did fenced examples remain inert code?
- Is untrusted output safe for this application's sink and policy?
The first two are conversion checks. The third belongs to the embedding application. A converter producing structurally correct HTML does not automatically promise that arbitrary input is safe to inject into another site's DOM.
Engineering checklist
- Treat external Markdown as untrusted by default.
- Disable raw HTML where the product does not need it.
- Sanitize the final HTML tree with a maintained allow-list sanitizer.
- Model URL schemes,
id,name, and styling capabilities explicitly. - Do not run arbitrary HTML-mutating plugins after sanitization.
- Test AST shape, rendered HTML, and sanitized HTML separately.
- Pin and update sanitizer versions; security fixes are part of the boundary.
The design question I keep coming back to is this: should Markdown renderers disable raw HTML by default, or should they expose it only behind an explicit host-supplied security policy?
Primary sources
The cheapest LLM call is the one you don't make: a caching layer that actually pays off
19 Aug 2026, 2:34 amThe cheapest LLM call is the one you don't make: a caching layer that actually pays off
In the last post I wrote about routing across providers to cut our bill ~40%. Caching was the second lever — and honestly the more underrated one. Here's what we learned shipping it.
Routing gets most of the attention because it's sexy: traffic dancing across providers, failover kicking in, dashboards lighting up. But the single biggest cost lever we pulled after routing wasn't smarter routing. It was not calling the model at all.
Why caching gets ignored
When people talk about LLM cost, they picture the per-token price. That's the wrong unit. The question is how many of your calls are genuinely new information versus repeats wearing a costume.
We were shocked at the overlap. Once we started measuring, a large share of production traffic was re-asking near-identical things:
- The same system prompt + near-identical user input, re-embedded every time.
- The same retrieval-augmented question asked by different users within minutes.
- Deterministic pre/post-processing steps recomputed on every request.
None of that needs a fresh model call. It needs a cache with a brain.
Three layers that actually paid off
1. Exact cache (the boring one that works immediately)
Hash the full request (system + messages + params). If you've seen it, return the stored completion. Obvious, but most teams skip it because "our prompts are dynamic." They usually aren't that dynamic.
import hashlib, json
def cache_key(req):
return hashlib.sha256(json.dumps(req, sort_keys=True).encode()).hexdigest()
def complete(req):
k = cache_key(req)
hit = store.get(k)
if hit:
return hit # zero tokens spent
out = model_call(req)
store.set(k, out, ttl=300)
return out
This alone killed a chunk of bill on our highest-traffic endpoints.
2. Semantic cache (the one people underestimate)
Exact matching misses the real win: similar prompts returning similar answers. Embed the user turn, store embeddings in a vector index, and on each request check for a neighbor above a similarity threshold (we use ~0.92). If found, reuse the prior completion.
The catch: semantic caching is only safe for deterministic-ish tasks (classifications, extractions, stable Q&A). Don't cache creative generation — you'll serve stale voices. We scope it tightly and it still covers a surprising volume.
3. Deterministic-step cache
A lot of "LLM calls" are actually deterministic work wrapped in a prompt: parsing, normalization, format conversion. We moved those to pure functions computed once and reused. It's not even a model cache — it's just not pretending the model is needed.
Tuning without breaking things
- TTL by volatility. Stable reference answers: long TTL. Fast-moving data: short or none.
- Token budget for the lookup. An embedding + vector search costs tokens too. Make sure the cache check is cheaper than the miss — for us it is, by a wide margin.
- Measure hit rate, not just savings. Hit rate tells you when caching stopped helping (prompt drift, new use cases) so you can re-scope.
The numbers
- Cache hit rate across cached endpoints: ~35%.
- Additional bill reduction on top of routing: meaningful — combined with routing we're now well past the original 40% on the endpoints that use both.
- p95 latency on cached hits: sub-50ms instead of hundreds of ms. Users notice the speed more than the savings.
None of this is exotic. It's the same caching discipline people have applied to databases for decades, applied to model calls where the per-hit savings are bigger.
Where this fits with the rest
Routing moves traffic to the cheapest healthy provider (how we cut the bill with routing). A circuit breaker keeps a flaky provider from turning an outage into a bill explosion (the pattern we use). Caching is the layer underneath both: the call you skip is the call you never have to route or protect.
If you're optimizing the same thing
Getting reliable, affordable model access set up for a team has its own headaches — provider quotas, region limits, payment friction. If any of that sounds familiar, I'm happy to compare notes. Find me here or DM me; no pitch, just war stories.
Architecting low-power location triggers for Android automation
19 Aug 2026, 2:32 amIt happened during a quiet Friday sermon. My phone, tucked deep in my pocket, decided that was the perfect moment to blast a loud notification tone. The entire room turned. I felt the heat rise in my face as I fumbled to silence it, accidentally triggering the camera shutter sound instead of the mute toggle. It was one of those moments where the technology meant to assist me became the primary source of my social anxiety. I realized then that I didn't need a smarter phone; I needed a phone that understood where it was and what it was supposed to be doing without me constantly intervening.
That experience was the genesis of Muffle. We live in an era of context-aware computing, yet our devices remain surprisingly oblivious to the social constraints of our environments. When you walk into a library, a meeting room, or a place of worship, your phone should shift its behavior automatically. Most existing solutions either rely on manual toggles that people forget to hit or aggressive location tracking that drains the battery in a few hours. The friction lies in the binary: either you are constantly managing your settings, or you are at the mercy of your phone's default state. I wanted to build something that lived in the background, consuming almost zero power, but reacting instantly to the context of my surroundings.
To achieve this, I had to move away from the naive approach of constantly polling the GPS signal. If you simply request location updates in a loop, your app will be killed by the system, and your users will uninstall it within a day because their battery life drops by thirty percent. Instead, I leaned into the GeofencingClient within the Google Play Services location library. This API is designed specifically for this use case: it shifts the burden of monitoring location from the app process to the system level. You register a set of circular regions with the OS, and it handles the heavy lifting of waking your app only when a boundary is crossed.
However, the implementation isn't just a simple addGeofences call. The real architectural challenge is managing the BroadcastReceiver that catches these events. In my early attempts, I saw that the system would sometimes delay the trigger if the device was in a deep Doze mode. To combat this, I had to ensure that the intent triggered by the geofence was properly prioritized. I opted for a JobIntentService to handle the sound profile changes. This allows the system to schedule the work appropriately while ensuring the task is completed even if the app process is terminated immediately after the trigger occurs. Here is a snippet of how I define the geofence request:
kotlin
val geofence = Geofence.Builder()
.setRequestId(routineId)
.setCircularRegion(lat, lng, radius)
.setExpirationDuration(Geofence.NEVER_EXPIRE)
.setTransitionTypes(Geofence.GEOFENCE_TRANSITION_ENTER or Geofence.GEOFENCE_TRANSITION_EXIT)
.build()
val request = GeofencingRequest.Builder()
.setInitialTrigger(GeofencingRequest.INITIAL_TRIGGER_ENTER)
.addGeofence(geofence)
.build()
By keeping the logic strictly local—processing the state changes within the onReceive method of my broadcast receiver—I avoided the overhead of network requests entirely. When a user enters the geofence, the app checks the current AudioManager state and applies the user's preference for that specific location. Because I am using the system's own geofencing engine, the power consumption is negligible. The operating system essentially batches these location checks with other system-wide location requests, making it the most efficient way to achieve this kind of automation without writing custom location listeners.
What surprised me the most during development was how unreliable Wi-Fi-based location estimation can be in dense urban environments. I initially assumed that using PRIORITY_BALANCED_POWER_ACCURACY would be sufficient for standard geofencing. However, I found that in high-rise buildings, the Wi-Fi triangulation would sometimes bounce the device between two points, causing the geofence to fire repeatedly as if the user were teleporting across the street. This 'flickering' effect was destroying my state logic, as it would toggle the phone between silent and normal modes in rapid succession. I had to implement a debounce mechanism in my internal routine manager.
Essentially, I added a state-holding variable that checks the timestamp of the last transition. If a new trigger occurs within 60 seconds of the previous one, it is ignored unless the transition type is explicitly different. This was a critical lesson: you cannot trust the hardware sensor data to be perfectly clean or stable. You must build your application logic to handle the noise inherent in mobile sensor data. If I were starting over, I would also spend more time on the 'exit' logic. Geofencing exits are notoriously less accurate than entries because the system is less aggressive about checking for a departure from a defined radius. I eventually had to increase the minimum radius for my triggers from 50 meters to 150 meters to account for the latency in the system's geofence exit detection, which solved the issue of the phone remaining silent long after the user had left the building.
For any developer working on background automation, the biggest takeaway is to respect the Android process lifecycle. Do not try to keep an activity or a service running constantly. Instead, use the system APIs like GeofencingClient, AlarmManager for scheduled tasks, and WorkManager for background processing. These are the tools that allow your app to feel responsive without being a battery hog. Developers often feel the urge to build custom solutions, but the system-level APIs are specifically optimized to batch operations across all installed apps. By tapping into these, you gain the benefit of years of Google's own power-management engineering.
Another important lesson is to prioritize offline capability. By keeping all routine data in a local Room database, I ensured that Muffle stays functional even when the user is in a basement or a place with no data connection. The reliance on local storage also solves a major privacy concern; users are much more comfortable with an app that keeps their location data on their phone rather than sending it to a cloud server. When you build for the user's privacy and device longevity, the technical constraints actually become a feature of the product. Muffle is my way of solving the friction of sound management, and you can see how I approached the final implementation at https://play.google.com/store/apps/details?id=com.muffle.app for a deeper look at the final behavior.
DeepSeek vs Qwen vs Kimi vs GLM: An Architect's 2026 Breakdown
19 Aug 2026, 2:32 amDeepSeek vs Qwen vs Kimi vs GLM: An Architect's 2026 Breakdown
I spend my nights watching p99 latency graphs. When a model starts drifting past 800ms on the tail end, I know about it before the monitoring dashboard even refreshes. That's why I approached the Chinese AI model landscape the way I approach any new dependency — with load tests, synthetic traffic, and a healthy skepticism for any vendor that hasn't earned my 99.9% uptime badge.
Over the last quarter, I've pushed roughly 47 million requests through DeepSeek, Qwen, Kimi, and GLM via Global API's unified endpoint. I wanted to see which one actually holds up when you slam it with bursty workloads, route traffic across three regions, and measure the cold-start times after auto-scaling kicks in.
Here's what I found.
At a Glance: The Four Contenders
Before we get into the architectural weeds, here's the high-level matrix I built. I treat this like any RFC doc — at-a-glance, then deep-dive.
| Dimension | DeepSeek | Qwen | Kimi | GLM |
|---|---|---|---|---|
| Vendor | DeepSeek (幻方) | Alibaba (阿里) | Moonshot AI (月之暗面) | Zhipu AI (智谱) |
| Price Band | $0.25–$2.50/M | $0.01–$3.20/M | $3.00–$3.50/M | $0.01–$1.92/M |
| Budget Pick | V4 Flash @ $0.25/M | Qwen3-8B @ $0.01/M | — | GLM-4-9B @ $0.01/M |
| Flagship Pick | V4 Flash @ $0.25/M | Qwen3-32B @ $0.28/M | K2.5 @ $3.00/M | GLM-5 @ $1.92/M |
| Code Gen | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ |
| Chinese Tasks | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| English Tasks | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| Reasoning | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| Throughput | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ |
| Vision | Limited | ✅ (VL, Omni) | ❌ | ✅ (GLM-4.6V) |
| Context Window | 128K | 128K | 128K | 128K |
| OpenAI-Compatible | ✅ | ✅ | ✅ | ✅ |
All four speak the OpenAI wire protocol, which means I can flip models without rewriting a line of code. That's the first checkbox I look for — anything that doesn't speak OpenAI-compatible gets deprioritized immediately. I don't have time to maintain four SDKs.
DeepSeek: The Latency Darling
If raw throughput is your bottleneck, DeepSeek is the answer. I watched V4 Flash hold a consistent 60 tokens/sec on sustained loads during a weekend stress test, and my p99 stayed comfortably under 1.2 seconds. For a model that costs $0.25 per million output tokens, that's absurdly good.
The Roster I Care About
| Model | Output $/M | What I Use It For |
|---|---|---|
| V4 Flash | $0.25 | Default workhorse, low-priority batch |
| V3.2 | $0.38 | Latest architecture, A/B tests |
| V4 Pro | $0.78 | Quality-sensitive paths |
| R1 (Reasoner) | $2.50 | Math, multi-hop logic |
| Coder | $0.25 | Repository-level code generation |
Where It Wins for Me
The price-to-quality ratio on V4 Flash genuinely rivals GPT-4o on my internal evals. I've been routing English-language production traffic through it for eight weeks now, and the incident count sits at zero. For code generation specifically, HumanEval and MBPP scores put it in the top tier — and when I'm running code completion at 2,000 RPM, the latency consistency matters more than a 2% benchmark delta.
Where I Reach for Something Else
Vision is the dealbreaker for some of our pipelines. If your workload needs native image understanding, DeepSeek isn't your friend. Chinese-language quality is solid but not best-in-class — I'll explain that trade-off when we get to GLM. And the model family is narrow. I like options when I'm designing fallback chains across multi-region deployments.
Qwen: The Portfolio Approach
Alibaba gave me the most boring answer to my favorite question: "Can I get this in three sizes?" Yes. Qwen has the widest menu I've seen from any Chinese vendor — from a $0.01/M tiny model up to a $3.20/M flagship that I frankly haven't needed yet.
What Lives in My Qwen Pool
| Model | Output $/M | Architecture Role |
|---|---|---|
| Qwen3-8B | $0.01 | Edge inference, classification |
| Qwen3-32B | $0.28 | General production traffic |
| Qwen3-Coder-30B | $0.35 | Specialized code path |
| Qwen3-VL-32B | $0.52 | Vision-language workloads |
| Qwen3-Omni-30B | $0.52 | Multimodal pipelines |
| Qwen3.5-397B | $2.34 | Heavy reasoning, enterprise tier |
Why I Keep It in the Rotation
The breadth lets me build a tiered routing layer that's actually defensible. I send trivial classification traffic to Qwen3-8B at $0.01/M, and my cost-per-request drops by an order of magnitude. The VL-32B and Omni-30B models give me vision and audio in one endpoint, which simplifies my service mesh. Alibaba's enterprise-grade infrastructure also means the SLA conversation is easier — I'm not explaining to a VP why I picked a startup's API for a Tier-1 system.
The Annoyances
Naming conventions are a nightmare. Qwen3.5, Qwen3.6, Qwen3-Coder, Qwen3-VL — I've had to maintain a spreadsheet mapping every alias to its actual capability. And mid-range English quality is good, not DeepSeek-tier good. Some of the larger Qwen3.6 models also feel overpriced for what they deliver; the $1/M tier especially.
Code: Routing Through Qwen3-32B
Here's the pattern I use for general-purpose traffic:
from openai import OpenAI
client = OpenAI(
api_key="ga_xxxxxxxxxxxx",
base_url="https://global-apis.com/v1"
)
response = client.chat.completions.create(
model="Qwen/Qwen3-32B",
messages=[{
"role": "user",
"content": "Write a Python function to merge two sorted lists"
}]
)
print(response.choices[0].message.content)
Same client object. Same base URL. The only thing that changed was the model string. That's the kind of architecture I can defend in a post-incident review.
Kimi: When Reasoning Trumps Latency
I'll be honest — Kimi is the model I reach for when I'm willing to pay a latency tax. K2.5 at $3.00/M is the priciest option in this comparison, and the throughput is the slowest (⭐⭐⭐ is generous). But for multi-hop reasoning, math proofs, and chain-of-thought workloads where a wrong answer is expensive, Kimi is the one I trust.
The Slate
| Model | Output $/M | Workload |
|---|---|---|
| K2.5 | $3.00 | Complex reasoning, research synthesis |
| (other tiers) | up to $3.50/M | Premium paths |
There isn't really a "cheap" Kimi tier. You're paying for quality, full stop.
Where It Earns Its Keep
Chinese-language reasoning. If you've ever tried to run a Chinese legal contract through a Western model, you know the pain. Kimi handles it cleanly. The reasoning benchmarks are top of the stack. For research-heavy pipelines where I'm willing to accept 2.5x higher latency in exchange for fewer hallucinations, Kimi earns the slot.
Where I'd Push Back
No vision support at all. If your workload has any image input, Kimi drops out of the running. And p99 latency on sustained loads is the worst of the four — I've seen tail latencies climb past 3 seconds during peak hours. Not a dealbreaker for offline batch jobs. Absolutely a dealbreaker for user-facing chat.
GLM: The Regional Specialist
Zhipu's GLM family is my pick when Chinese-language quality is non-negotiable and I need a model that behaves well in regulated multi-region deployments. GLM-5 at $1.92/M is the flagship, and GLM-4-9B at $0.01/M gives me a tiny model for edge cases.
The Lineup
| Model | Output $/M | Sweet Spot |
|---|---|---|
| GLM-4-9B | $0.01 | Trivial classification, regex-ish tasks |
| GLM-5 | $1.92 | Flagship quality, Chinese-heavy workloads |
Why It Lives in My Stack
Best-in-class Chinese language understanding. If you're shipping a product to mainland China and your downstream users care about idiomatic responses, GLM is what you reach for. The GLM-4.6V vision model closes the multimodal gap. And the price floor at $0.01/M means I can throw cheap classification jobs at GLM-4-9B without thinking about it.
The Trade-Offs
Code generation is the weakest of the four. If your pipeline is code-heavy, GLM won't be your primary. The English-language quality is solid but not DeepSeek-grade. And model variety is narrower than Qwen — you're choosing between two real options, not six.
The Latency & SLA Deep Dive
This is where I earn my keep. I don't trust vendor benchmarks — I trust my own histograms.
What I Measured
Over a 14-day window, I sent 50,000 synthetic requests per model through Global API, distributed across us-east, eu-west, and ap-east endpoints. I logged p50, p95, and p99 latencies, plus error rates during simulated failover events.
DeepSeek V4 Flash:
- p50: 380ms
- p95: 720ms
- p99: 1.18s
- Error rate during failover: 0.02%
Qwen3-32B:
- p50: 420ms
- p95: 810ms
- p99: 1.34s
- Error rate during failover: 0.03%
Kimi K2.5:
- p50: 680ms
- p95: 1.6s
- p99: 2.9s
- Error rate during failover: 0.04%
GLM-5:
- p50: 510ms
- p95: 950ms
- p99: 1.55s
- Error rate during failover: 0.03%
DeepSeek wins on raw latency. Kimi is the slowest but the most accurate on reasoning tasks. Qwen and GLM sit in the middle, with GLM pulling ahead on Chinese-language workloads.
SLA Conversations
None of these vendors publish hard SLAs the way AWS or Azure do. That's why I route everything through Global API — I get one consolidated SLA conversation instead of four, and the failover logic is built into the endpoint instead of my application code.
Multi-Region Architecture: How I'd Deploy This
If I were building this stack for a real production system, here's how I'd structure it:
Tier 1 (User-facing, latency-critical): DeepSeek V4 Flash in us-east and eu-west, with automatic failover. p99 under 1.2s, error rate below 0.05%. This is where 80% of my traffic lands.
Tier 2 (Vision/multimodal): Qwen3-VL-32B or Qwen3-Omni-30B, deployed in regions where vision inference makes sense. Probably ap-east for cost reasons.
Tier 3 (Reasoning-heavy, batch-friendly): Kimi K2.5 for offline research synthesis. Higher latency is acceptable because the user isn't waiting on a streaming response.
Tier 4 (Chinese-language tier): GLM-5 for any flow that touches mainland Chinese users or content. p99 is fine for these workloads.
Tier 0 (Classification, edge): Qwen3-8B or GLM-4-9B at $0.01/M for trivial routing decisions. Don't waste a flagship model on spam detection.
Auto-scaling policies kick in when sustained token throughput exceeds 70% of capacity. Cold-start times after scale-out were acceptable across all four — typically 8–12 seconds for a warm node.
Cost Optimization at Scale
Here's the math that gets me out of bed in the morning. If I route 10 million requests per month through this stack with an average of 500 output tokens per request:
- DeepSeek V4 Flash: 10M × 500 × $0.25/M = $1,250/month
- Qwen3-32
Test-Driven Development: Why I Feel Like Neo in The Matrix
19 Aug 2026, 2:32 amThe Quest Begins (The "Why")
I still remember the first time I tried to add a new feature to a legacy codebase. The task seemed simple: calculate a discount based on a user's membership level. I opened the file, wrote a quick function, threw in a couple of console.log statements to see if it looked right, and called it a day.
A week later, QA filed a bug: the discount was wrong for premium users on leap years. I opened the code again, stared at the mess of conditionals, and realized I had no idea which branch was actually being executed. I spent three hours adding more logs, stepping through the debugger, and still felt like I was guessing. When I finally fixed it, I felt relieved—but also exhausted. The code was now a tangled web of “it works on my machine” patches, and I knew the next change would be just as painful.
That experience made me ask: Is there a better way to write code that gives me confidence from the start?
The Revelation (The Insight)
The answer showed up in a humble blog post about Test‑Driven Development, and it boiled down to one simple practice: write a failing test that describes the exact behavior you want before you write any production code.
Sounds trivial, right? Yet that tiny shift flips the whole development process on its head. Instead of coding first and hoping the tests will catch mistakes later, you start by expressing the requirement as an executable specification. The test fails (the “Red” state), you write just enough code to make it pass (“Green”), then you refactor while the test guards you against regression (“Refactor”).
Why does this change everything?
- Immediate feedback: You know instantly whether your code satisfies the requirement.
- Design pressure: To make a test pass, you often end up with smaller, more focused functions.
- Safety net for refactoring: When you later need to change something, the existing tests scream if you break behavior.
- Documentation that never lies: Tests become living examples of how the code is supposed to work.
The first time I tried it, I felt like I’d discovered a cheat code. The anxiety of “did I break something?” vanished, replaced by the thrill of watching a red bar turn green after each tiny increment.
Wielding the Power (Code & Examples)
Let’s look at a concrete example: a function that determines whether a user is eligible for a discount based on their membership level and the current date.
The Struggle (Before TDD)
// discount.js – written first, tests added later (if at all)
function isEligibleForDiscount(user, today) {
// Assume user.level is a string: "basic", "premium", "vip"
if (user.level === "premium") {
// Premium users get discount on weekdays only
return today.getDay() !== 0 && today.getDay() !== 6; // not Sat/Sun
}
if (user.level === "vip") {
// VIPs always get discount
return true;
}
// Basic users never get discount
return false;
}
I wrote the function, then added a test a day later:
// discount.test.js – after‑the‑fact
test('basic user never gets discount', () => {
const user = { level: 'basic' };
expect(isEligibleForDiscount(user, new Date())).toBe(false);
});
test('premium user gets discount on weekday', () => {
const user = { level: 'premium' };
const monday = new Date(2025, 0, 6); // Jan 6, 2025 is a Monday
expect(isEligibleForDiscount(user, monday)).toBe(true);
});
Everything looked fine… until I realized I had missed the leap‑year edge case for VIPs (the business rule said VIPs get a discount except on Feb 29 of a leap year). Because I wrote the test after the code, I never thought to ask that question. The bug slipped into production, and I spent hours tracing why a VIP user saw no discount on Feb 29, 2024.
The Victory (After TDD)
Now, let’s do it the TDD way. First, we write the test before any implementation:
// discount.test.js – written first
describe('isEligibleForDiscount', () => {
test('basic user never gets discount', () => {
const user = { level: 'basic' };
expect(isEligibleForDiscount(user, new Date())).toBe(false);
});
test('premium user gets discount on weekdays only', () => {
const user = { level: 'premium' };
const wed = new Date(2025, 0, 8); // Jan 8, 2025 – Wednesday
const sat = new Date(2025, 0, 11); // Jan 11, 2025 – Saturday
expect(isEligibleForDiscount(user, wed)).toBe(true);
expect(isEligibleForDiscount(user, sat)).toBe(false);
});
test('vip user always gets discount except on leap day', () => {
const user = { level: 'vip' };
const normalDay = new Date(2025, 1, 15); // Feb 15, 2025
const leapDay = new Date(2024, 1, 29); // Feb 29, 2024 (leap year)
expect(isEligibleForDiscount(user, normalDay)).toBe(true);
expect(isEligibleForDiscount(user, leapDay)).toBe(false);
});
});
Run the test suite – it fails spectacularly (all reds). Now we write the minimum code to make those tests pass:
// discount.js – implementation driven by tests
function isEligibleForDiscount(user, today) {
// Helper: is today Feb 29 of a leap year?
const isLeapDay = today.getMonth() === 1 && today.getDate() === 29 &&
((today.getFullYear() % 4 === 0 && today.getFullYear() % 100 !== 0) ||
today.getFullYear() % 400 === 0);
if (user.level === 'basic') return false;
if (user.level === 'premium') {
const day = today.getDay(); // 0 = Sun, 6 = Sat
return day !== 0 && day !== 6;
}
if (user.level === 'vip') return !isLeapDay;
return false; // fallback for unknown levels
}
All tests turn green. I refactor a bit (extract the leap‑day check, maybe rename variables) – the tests keep me safe.
What changed?
- I forced myself to think about the leap‑day rule before writing any logic.
- The test suite became a living spec that anyone (including future me) can read to understand the exact behavior.
- When I later needed to add a new membership tier, I wrote a failing test first, guaranteeing I wouldn’t accidentally break existing rules.
Traps to Avoid
- Testing implementation details – Don’t write a test that checks a private helper or a specific loop count. Focus on what the function does, not how it does it. If you couple tests to internals, refactoring becomes a nightmare.
- Skipping the Red step – If you write the test after the code and it passes immediately, you haven’t verified that the test actually catches a defect. Always see it fail first; that’s your proof the test is meaningful.
Why This New Power Matters
Adopting the “write a failing test first” habit turned my coding from a stressful guessing game into a confident, almost meditative flow. I spend less time debugging in production and more time building features that actually solve user problems. My pull requests are smaller, easier to review, and rarely need endless back‑and‑forth because the tests already prove correctness.
Most importantly, my code feels alive. It’s no longer a static script that works only under the exact conditions I imagined; it’s a resilient piece of software that tells me, via its tests, when I’m about to break something. That safety net lets me experiment, refactor, and even delete dead code without fear—something that used to feel like walking a tightrope without a net.
Your Turn
Pick a tiny function you’ve been meaning to write or fix—maybe a utility that formats a date, a validator for an email address, or a helper that calculates a shopping‑cart total. Before you write any logic, draft a single test that describes the exact outcome you expect. Watch it fail, then make it pass, then refactor.
Do you feel the shift? Does the red‑to‑green cycle give you a little jolt of satisfaction?
Give it a try on your next small task, and let me know how it changes the way you think about code. Happy testing! 🚀
The AI Agent Stack in 2026: Frameworks, Memory, Orchestration
19 Aug 2026, 2:30 amWhat an agent system is actually made of in 2026 — and why the framework you pick matters less than the layers around it.
Last month a founder called me with a familiar kind of panic. His team had built their agent on one framework, read a blog post, re-built it on a second framework in a two-week sprint, and were now asking whether a third was the reason their latency had doubled. "Just tell me which one to bet the company on," he said.
I did not give him the answer he wanted, because the answer is uncomfortable: the framework is the least important layer of the stack. Every framework in the current hype cycle compiles down to the same loop — a model, a context, tools, a stop condition. What separates production agents from demos in 2026 is the stack around that loop: the memory layer, the orchestration, the observability, and the discipline. Pick the framework that fits your team and your failure modes, and spend your real engineering effort on the layers that actually decide whether the thing survives.
This article is a tour of the 2026 stack as I actually build it: frameworks and what they are good for, the memory layer that everyone underestimates, and the orchestration choices that determine cost and latency. No framework evangelism — an honest map, with numbers.
The Frameworks, Ranked by What They Give You
Let me be direct about the current landscape. As of mid-2026, the frameworks you will actually encounter in production, and the honest thing each one is good at:
LangGraph. The graph-based framework from the LangChain ecosystem. You define a state machine as a directed graph — nodes are steps, edges are transitions, state flows through a typed object. Its real strength is durable state and human-in-the-loop checkpoints: a graph can pause, wait for a human, and resume without losing context. This is the closest thing the ecosystem has to a production-grade default, and it is what I reach for when a task is 80% process.
CrewAI. Role-based multi-agent, closest to the "team of specialists" marketing picture: a researcher, a writer, a reviewer, each with a goal and tools, coordinated by a manager. Fast to prototype, and genuinely good at producing polished drafts. The cost is real — every agent turn is a full model call — and coordination chatter eats budget fast. Prototype here, migrate to LangGraph when the cost matters.
AutoGen / AG2. The conversational multi-agent framework — agents with distinct personas that converse to solve a task, built for research and interleaved human–machine workflows. Flexible and powerful for experiments; heavier on coordination overhead. When your task is a known pipeline, it is usually overkill.
n8n. The visual workflow engine that gets the "agents" label slapped on it. Node-based, drag-and-drop, built for operations teams who want a toolchain without a software sprint. The reason it matters in 2026: it is the fastest way to wire an LLM step into an existing business process, and it turns "agent" into something a non-engineer can reason about. Its ceiling is lower for complex reasoning loops, but its floor is much higher.
OpenAI Agents SDK and Semantic Kernel. The thin, vendor-blessed layer. The Agents SDK gives you the loop, handoffs, and guardrails in a few hundred lines; Semantic Kernel is the enterprise-C#/copilot angle. Both are fine defaults if you are already deep in one vendor's ecosystem.
Custom. When the task is core to your product, you will end up here eventually — a framework you control, sized to your exact failure modes. My rule: start on a framework, and plan to own the loop the moment you outgrow it.
A comparison, as I would draw it for a client:
| Framework | Model | Best for | Watch out for |
|---|---|---|---|
| LangGraph | graph / state machine | process-heavy tasks, human-in-the-loop | learning curve, graph sprawl |
| CrewAI | role-based teams | rapid multi-agent prototyping | token cost, coordination chatter |
| AutoGen / AG2 | conversational agents | research, multi-agent experiments | coordination overhead |
| n8n | visual workflow | ops teams, toolchain glue | reasoning ceilings |
| OpenAI Agents SDK | thin loop | vendor-lock-in-friendly teams | vendor coupling |
| Custom | your loop | core product logic | maintenance |
The pattern you will notice: the more structure the framework gives you (graphs and workflows), the more production-friendly it is; the more freeform the agenting is (conversational peers), the more cost and unreliability you inherit. That is not an accident. Structure is what survives.
The Memory Layer: Where the Real Work Is
Here is what I tell founders who think picking a framework is the hard decision: the framework is a weekend. The memory layer is a quarter.
Production agents need three kinds of memory, and 2026's stack has settled into distinct tools for each:
Ephemeral working memory — the current task's context. This lives in the context window, trimmed to what the current step needs. Redis is where the fast, throwaway job state goes when you need it outside the prompt: a job ID, a step counter, a few recent tool results.
Durable task memory — what this task has accomplished, across retries and restarts. This is a Postgres table, not a vector database. I keep pushing this point: most "we need an agent memory system" conversations resolve to a tool_calls JSONB column and a step_count integer. It is boring, and it is the difference between an agent that resumes and an agent that starts over.
Semantic memory — the knowledge the agent retrieves. This is the vector layer, and the 2026 default is boring on purpose: pgvector in the Postgres you already run, or a purpose-built vector store like Qdrant or Weaviate when the index gets large. There is also a newer category of memory APIs (the Mem0-style layer) that turns past conversations into a user profile the agent reads before answering. Useful for personalization; be careful about the privacy surface.
The mental model that keeps this sane: the prompt is the agent's desk, the store is the filing cabinet, and retrieval is the assistant who fetches files. You measure retrieval quality with the same rigor you measure model quality — because in an agent loop, a wrong retrieval becomes a wrong belief, and a wrong belief becomes a confident wrong action.
Orchestration: The Layer Nobody Champions
If frameworks are the top and memory is the bottom, orchestration is what runs the whole thing in production: when to run the loop, how many times, what happens when it crashes, and how the agent talks to the rest of your systems. Three choices dominate:
Synchronous request loops. Call the agent, wait, return. Fine for a chatbot with a 10-second budget. Fails the moment a task legitimately takes three minutes — the HTTP connection dies and the agent's state dies with it.
Event-driven / queue-based. The agent is a consumer on a queue — Redis streams or RabbitMQ or SQS. A task arrives, a worker picks it up, state persists to Postgres after every step, and if the worker dies another one picks the task up from its last step_count. This is the single biggest reliability upgrade you can make to an agent system, and almost nobody does it on day one.
Durable execution. A workflow engine — Temporal being the serious one — that guarantees your orchestration code runs to completion even if the process dies mid-task. This is what you reach for when the agent is doing money-moving, multi-hour work and "it mostly works" is not a standard.
The rule of thumb I use: if the agent answers in seconds and is disposable, synchronous is fine. If the work is longer than a web request or the failure of a mid-task crash is expensive, move it to a queue with durable state. That one move eliminates more "flaky agent" complaints than any model swap.
A Working Example: LangGraph With Durable State
Let me make the stack concrete. Here is a minimal LangGraph research agent that persists state between steps — the pattern that survives production:
from typing import TypedDict
from langgraph.graph import StateGraph, END
class State(TypedDict):
goal: str
chunks: list[str]
answer: str
def retrieve(state: State) -> dict:
# embed the goal, query pgvector, return top-k chunks
return {"chunks": semantic_search(state["goal"], k=4)}
def draft(state: State) -> dict:
return {"answer": llm_call(
"Answer the goal using only the provided chunks. Cite chunk ids.",
{"goal": state["goal"], "chunks": state["chunks"]},
)}
graph = StateGraph(State)
graph.add_node("retrieve", retrieve)
graph.add_node("draft", draft)
graph.set_entry_point("retrieve")
graph.add_edge("retrieve", "draft")
graph.add_edge("draft", END)
app = graph.compile(checkpointer=postgres_checkpointer) # durable state
The checkpointer line is the whole point. With a Postgres-backed checkpointer, a crash mid-draft means you resume the graph from the last checkpoint — you do not re-pay the retrieval call, and you do not lose the task. This is the difference between an agent and a demo in one argument.
Production Reality: Costs, Latency, and the Framework Churn Trap
Let me give you the honest numbers from systems I run.
Cost. A single-loop task with two tool calls runs around $0.02–$0.05 at current model prices. The moment you add orchestrator–worker decomposition, plan for $0.15–$0.60 per task. Peer teams push past a dollar. I have stopped quoting "cost per token" to clients and started quoting "cost per resolved task," because that is the number that actually matters for a business decision.
Latency. Each tool call in a loop adds 1–4 seconds on top of model generation. A three-tool task is realistically 6–15 seconds end to end. If your product needs a sub-3-second answer, the honest options are: a router that deflects to fast paths, a workflow that does the expensive work asynchronously, or a model choice that trades quality for speed — not a more clever framework.
The churn trap. This is the one that actually hurts companies. A framework releases a breaking version, a blog post declares it dead, a new one reaches the top of Hacker News, and teams rewrite. I have watched a startup burn six engineer-weeks migrating between frameworks for zero measurable improvement. The escape: wrap the loop behind your own interface, keep the memory and orchestration layers framework-agnostic, and treat the framework as a replaceable engine you chose, not a religion.
Observability: The Layer Everyone Skips
Here is the part that only shows up in month two: every agent system eventually needs observability, and almost nobody budgets for it. A normal API endpoint is easy to monitor — status codes, latency, error rates. An agent is a program with a reasoning trace: the goal it received, the plan it formed, every tool call with its arguments and result, every retry, and the final answer. That trace is the single most valuable artifact you can store, for three reasons:
- Debugging. When an agent gives a wrong answer, you cannot reproduce it by replaying the input — you have to replay the trace. Without the trace, every incident is a mystery.
- Cost accounting. A task that cost $0.04 today and $0.40 tomorrow is usually not a model price change; it is the agent looping or retrieving too much. The trace shows you exactly where the tokens went.
- Evaluation. The held-out production traffic I keep insisting on is only useful if you can score each run against its trace. Store the trace, store the outcome, and you can measure success rate per pattern, per task type, per time of day.
The boring implementation is right: a traces table with the goal, the steps, the tool calls, the cost, and the outcome, written after every run. The observability platforms (LangSmith-style tooling) give you this for free if you use the framework's native instrumentation — another quiet argument for a framework with structure. Whatever you choose, decide before the first incident, because the trace you need for debugging is the trace you did not think to capture in week one.
When NOT to Use This Stack at All
The honest section. Before you assemble any of this:
- If the task is a fixed, known process, use a workflow (n8n or a LangGraph graph with no freeform loop) — or a deterministic script. You do not need agent memory to parse an invoice format that never changes.
- If the task is a single step, do not build an agent. A prompt template with a validated output beats a loop on cost, latency, and reliability.
- If your organization cannot handle a wrong autonomous action, add the human approval step first, not after the incident. Human-in-the-loop is an orchestration feature, not an afterthought.
The Practitioner's Checklist
- [ ] Framework chosen for structure-to-task fit, not hype; wrapped behind your own interface
- [ ] Working memory trimmed to the current step; durable state in Postgres
- [ ] Semantic retrieval measured (recall@k) before it is trusted
- [ ] Tasks longer than a web request run on a queue with resumable state
- [ ] Money-moving or multi-hour work uses durable execution
- [ ] Cost per resolved task is budgeted and monitored
- [ ] Latency budget is explicit (target, not accident)
- [ ] Tool-call rate monitored as a health signal
- [ ] Human approval wired in for mutating actions
- [ ] Framework upgrade path exists without a rewrite (interface boundary)
The Answer I Gave the Founder
I told the founder to stop migrating frameworks. I had him run his exact workload on LangGraph with a Postgres checkpointer and a queue in front of it, then measure. The migration was two days of work, and the changes that actually fixed his latency were: routing routine requests to a fast path instead of the loop, and caching retrievals. The framework was never the problem; the missing stack around it was.
Framework debates are a tax on teams that have not yet discovered what the rest of the stack costs. Pick something boring, put state in the store, run the loop on a queue, and measure cost per resolved task. That is the whole secret.
*Gulshan Yad
Accessible by Platform: How iOS and Android Differ from the Web
19 Aug 2026, 2:21 amThe guidelines you know were written for web pages. Native apps borrow their intent and rewrite the mechanics. If your accessibility strategy assumes one rulebook covers all three platforms, you are shipping compliance debt you cannot see.
There is a comfortable myth in a lot of accessibility programs: that WCAG is WCAG, a checklist is a checklist, and once your team has learned to write accessible HTML they have learned to build accessible products. The web team passes an audit, the mobile team is told to "follow the same standard," and everyone assumes the work transfers.
It mostly does not. The principles transfer beautifully — perceivable, operable, understandable, robust are universal. But the moment you leave the browser, almost every mechanism used to satisfy those principles changes. The markup changes. The assistive technology changes. The gestures change. The way a screen reader builds its picture of your interface changes. A button that is perfectly accessible on the web can be completely invisible to VoiceOver, and a label that TalkBack announces cleanly can be meaningless noise in Safari.
This piece is a map of where web, iOS, and Android genuinely diverge, and why "build it once, make it accessible once" is one of the more expensive assumptions a product organization can make.
One standard, three interpretations
Start with the standard itself, because even here the ground is less solid than most teams assume. WCAG was developed for web content, so some of its terminology and conformance concepts — a "web page" as the unit of conformance, for instance — assume a web context. The success criteria themselves are intentionally written to be largely technology-neutral, but native applications have no HTML DOM, which means those web-shaped requirements often need platform-specific interpretation and implementation.
To bridge the gap, the W3C publishes interpretive guidance rather than separate app standards. WCAG2ICT provides W3C guidance on applying WCAG 2.0, 2.1, and 2.2 to non-web documents and software. Building on top of it, the Mobile Accessibility Task Force published WCAG2Mobile — "Guidance on Applying WCAG 2.2 to Mobile Applications" — as a Draft Note in May 2025. Both are worth reading with the word guidance firmly in mind: they are informative, not normative. WCAG2Mobile in particular remains a work in progress — W3C states that it does not establish requirements, is not endorsed by W3C or its members, and may change. What it does is translate existing criteria: it reframes the unit of conformance from "a web page" to a single screen or view within the application, and works through which criteria mean something different, or nothing at all, on a touchscreen.
Layer regulation on top and the picture gets more textured — and, importantly, less uniform than a single "your app must be accessible" would suggest. In Europe, the European Accessibility Act has applied to covered products and services since 28 June 2025. EN 301 549 provides an important European technical framework for ICT accessibility, covering web and non-web software, and it is being revised under the EU standardization process to support the EAA as well as the Web Accessibility Directive.
In the United States, the framework varies by context rather than resolving into one rule. Section 508 incorporates WCAG requirements for federal ICT, including provisions adapted for non-web software. Under the ADA, Title II now points somewhere specific: the Department of Justice has adopted WCAG 2.1 Level AA as the technical standard for state and local government web content and mobile applications. Private-sector obligations under ADA Title III are less prescriptive — accessibility obligations exist, but federal regulations do not currently establish WCAG as a universal technical standard for all private mobile applications. The common thread across all of this is intent; the technical means of satisfying it splinters across three toolchains.
The accessibility tree is built from different materials
Every platform exposes an accessibility tree — a parallel representation of the interface that assistive technology actually reads, distinct from the visual layout. This is the single most important concept for understanding why platforms differ, because the tree is assembled from completely different source materials on each one.
On the web, the tree is derived from semantic HTML, supplemented and corrected by ARIA. A carries an implicit role; a becomes a landmark; aria-label, aria-expanded, and aria-live patch in the meaning that markup alone cannot express. The browser mediates everything, and it is forgiving — sometimes too forgiving, which is why so much broken markup still limps along.
On iOS, there is no DOM and no ARIA. Accessibility is expressed through the UIAccessibility API in UIKit, or through accessibility modifiers in SwiftUI. Instead of ARIA roles you have accessibility traits — a control is marked .button, .header, .adjustable, .selected, and so on. Instead of aria-label you set an accessibilityLabel, an accessibilityHint, and an accessibilityValue. The concepts rhyme with ARIA, but the vocabulary, the defaults, and the failure modes are entirely their own.
On Android, the tree is populated by AccessibilityNodeInfo, drawn from the View hierarchy or from Jetpack Compose semantics. You supply a contentDescription, mark elements as headings, define state descriptions, and manage focusable and important-for-accessibility flags. Compose changed the model again by making semantics a first-class, declarative property rather than something bolted onto a View.
The practical consequence: an accessible name that is correct on all three platforms had to be authored three separate times, in three different APIs, by developers who each need genuine platform expertise. There is no shared attribute that flows from one to the others.
| Concept | Web | iOS | Android |
|---|---|---|---|
| Accessible name | Text content, aria-label, alt
|
accessibilityLabel |
contentDescription |
| Role / type | HTML element or ARIA role
|
Accessibility trait (e.g. .button) |
Class name / semantics role |
| State |
aria-expanded, aria-checked
|
Traits + accessibilityValue
|
State description, checked/selected flags |
| Live updates |
aria-live region |
UIAccessibility.post(notification:) |
announceForAccessibility / live region |
| Primary screen reader | JAWS, NVDA, VoiceOver (desktop) | VoiceOver | TalkBack |
| Primary test tool | axe, Lighthouse, WAVE | Xcode Accessibility Inspector | Accessibility Scanner, Espresso checks |
The interaction model is not the same interaction model
Even when the tree is correct, users reach it differently, and this is where a lot of "technically labeled but practically unusable" interfaces are born.
The web inherited a keyboard-first focus model. Tab order, focus rings, tabindex, and managing focus after dynamic changes are the backbone of web accessibility. Screen reader users on a desktop navigate through a rich set of shortcuts — by heading, by landmark, by form field — layered over that keyboard model.
Mobile screen readers replace nearly all of that with gesture navigation over a flat linear order. A VoiceOver user swipes right to move to the next element and uses the rotor — a twisting gesture — to switch between navigating by headings, links, form controls, or characters. A TalkBack user swipes similarly and uses reading controls and the local context menu to change granularity. There is no visible focus ring to reason about, no Tab key in the everyday flow, and the "reading order" is whatever order your elements sit in the accessibility tree — which may have nothing to do with their visual position.
This has a direct design cost. WCAG 2.2 added Success Criterion 2.5.7, Dragging Movements, precisely because touch-based interactions like swipe-to-delete or drag-to-reorder can be impossible for someone who cannot perform a precise drag. On the web you might solve that with keyboard operability. On iOS and Android you solve it with an alternative such as an edit mode, a long-press menu, or explicit buttons — a different remedy for the same requirement.
Touch targets: three numbers for one idea
Sizing is the cleanest illustration of divergence, because everyone agrees on the principle and no one agrees on the number.
Apple's Human Interface Guidelines call for a minimum tappable area of 44 × 44 points.
Android's Material guidance calls for a minimum touch target of 48 × 48 density-independent pixels.
WCAG 2.2 introduced Success Criterion 2.5.8, Target Size (Minimum), at 24 × 24 CSS pixels for AA — a deliberately lower floor with several exceptions, meant as a baseline rather than best practice.
It helps to be precise about what these numbers are: Apple's 44 pt and Android's 48 dp are platform design and accessibility guidance, while WCAG's 24 CSS px is a conformance criterion with its own specified exceptions. A design system that treats a single number as a universal accessibility threshold therefore risks missing platform-specific guidance or an applicable conformance requirement. Points, density-independent pixels, and CSS pixels are different units, and each framework applies them in a different context. This is exactly the kind of detail that survives a checklist audit and fails a real user with a tremor or large fingertips.
System settings do the heavy lifting — differently
A great deal of mobile accessibility is not something you build so much as something you must not break. Both platforms ship powerful user-level settings, and your job is to respect them.
Text scaling is the classic example. iOS has Dynamic Type; Android has a font-size (and, more recently, bold-text and display-size) preference. A user may set text to well over 200% of its default. On the web, the analog is browser zoom and reflow, governed by WCAG's Reflow and Resize Text criteria. Build a fixed-height card with fixed-point text and it looks identical in every screenshot — and clips the moment a real user enlarges their font. Testing at default size proves nothing here.
The same pattern repeats for reduced motion (respected via prefers-reduced-motion on the web, and via system flags your native code must query), dark mode and contrast settings, and orientation. Each platform surfaces these preferences through its own API, and honoring them is a per-platform engineering task, not a shared one.
The cross-platform trap
By now the obvious hope is a framework that papers over all of this — React Native, Flutter, or a shared web view — so accessibility can be written once and mapped everywhere. These frameworks do provide accessibility props, and they are far better than they used to be. But they are abstraction layers, and abstraction layers leak.
A React Native accessibilityRole has to be translated into an iOS trait and an Android node role, and the mapping is not always faithful. A Flutter Semantics widget must render correctly through two very different platform channels. Custom components — the interesting, differentiated parts of any product — are exactly where these mappings are thinnest and where announcements go wrong. The framework gives you a single place to declare intent; it does not guarantee that VoiceOver and TalkBack both interpret that intent the same way. You still have to test on both, with the real screen readers, on real devices.
Cross-platform code lets you write the accessibility once. It does not let you verify it once. Those are different promises, and confusing them is how "accessible" apps ship that no screen reader user can actually operate.
What this means for how you run the program
The recurring theme in all of this — and, if you have read my other work, a theme I return to often — is the gap between passing a check and a human being able to use the thing. Automated tooling and cross-platform abstractions are both wonderful at producing the first while quietly failing at the second. They generate what I have elsewhere called plausible accessibility: markup and labels that look right, satisfy a scanner, and collapse under a real user's assistive technology.
A few practical commitments follow from taking platform divergence seriously:
Budget three test surfaces, not one. A web audit, a VoiceOver pass on iOS, and a TalkBack pass on Android are three distinct activities. Staffing and scheduling should reflect that.
Hire or train platform-native accessibility skill. "Our developers know ARIA" does not mean they know UIAccessibility traits or Compose semantics. These are separate competencies.
Test with the actual assistive technology, on real devices. Simulators and automated node checks miss the announcement order, the rotor experience, and the reading flow that define whether the app is usable.
Map your design system to each platform's numbers. One token for touch-target size, resolved to 44 pt, 48 dp, and a conformant web value — not a single hard-coded figure.
Treat framework accessibility as a starting point, not a guarantee. Verify the leaked abstractions, especially on custom components.
WCAG gave us a shared language for what accessibility means. It did not give us a shared implementation, and the arrival of WCAG2ICT and WCAG2Mobile is the standards world formally acknowledging as much. The organizations that build genuinely accessible products are the ones that stop treating "web, iOS, and Android" as one deliverable with three skins, and start treating them as three craft disciplines aimed at the same human outcome.
The intent is universal. The work is not. Plan for the work.
