Eu quero Vibe: Codar! Mas a IA local me fez repensar a infraestrutura

Com mais de 11 anos de estrada no desenvolvimento de software, a gente aprende a cheirar o hype de longe. Quando o boom das ferramentas de Inteligência Artificial Generativa estourou, o ecossistema vendeu o cenário dos sonhos: *”Baixe o Ollama, escolha o LLM do momento (DeepSeek, Qwen, Gemma, Llama), plugue no seu Cursor ou Claude Code e tenha um Engenheiro Sênior autônomo trabalhando de graça e localmente na sua máquina.

Privacidade total, custo zero de tokens, sem dados vazando para Big Techs. Lindo no papel.

Mas quando você decide tirar a IA do playground e a coloca para trabalhar em uma esteira de produção real — lidando com arquiteturas distribuídas, microsserviços em Java 21, ecossistemas com Kafka e AWS — a física do hardware e a engenharia de software cobram a conta.

Se você está pensando em migrar sua esteira de desenvolvimento inteiramente para o modelo open-source local, aqui estão os três gargalos estruturais cruciais que você vai enfrentar (e como a arquitetura está mudando para resolvê-los).

1. O Abismo da Infraestrutura Física: O Gargalo da Memória

A matemática por trás da execução local de um Large Language Model (LLM) é implacável. Para que uma IA processe e gere código de forma minimamente performática, os pesos do modelo precisam estar carregados inteiramente na memória RAM/VRAM.

Se considerarmos um modelo eficiente focado em código com parâmetros quantizados em 4-bits (como o Qwen2.5-Coder-7B ou DeepSeek-Coder-7B), ele precisa de cerca de 5GB a 6GB livres apenas para existir na memória.

Agora faça as contas: se você roda um setup de desenvolvimento padrão, como um MacBook M1 com 8GB de RAM unificada, a sua memória é dividida entre o Sistema Operacional, containers Docker (bancos locais, brokers de mensageria), IDEs e o Ollama. Quando o Context Window (as linhas do seu projeto que você envia para a IA analisar) aumenta, o consumo de memória dispara, o sistema entra em swap no SSD e a velocidade de geração de tokens desaba.

A dura realidade: A IA local não é barata de verdade; ela apenas transfere o custo da assinatura de nuvem para o upgrade do seu hardware. 16GB de RAM passou a ser o piso absoluto, mas para desenvolvimento fluido com modelos de código robustos, o mercado já exige máquinas com 32GB ou 64GB.

2. A Ilusão do “Clean Code” Probabilístico

Todo modelo de IA possui uma documentação atraente repleta de benchmarks que prometem maestria técnica em Clean Code, SOLID e Design Patterns. Mas na prática diária, o desenvolvedor sênior frequentemente esbarra em códigos com alta taxa de “sujeira”, métodos excessivamente longos, acoplamento desnecessário e soluções que ignoram boas práticas de encapsulamento.

Por que isso acontece? IAs não pensam logicamente sobre arquitetura; elas operam por probabilidade estatística. Os modelos focados em engenharia de software foram treinados com base em bilhões de linhas de código extraídas de repositórios públicos (como o GitHub). E o que mais existe no GitHub? Código legado, gambiarras históricas, soluções apressadas e padrões de projeto mal aplicados.

Se você não injetar System Prompts cirúrgicos e extremamente rígidos forçando o modelo a seguir restrições arquiteturais específicas da sua stack, o modelo local sempre tenderá a escolher o caminho de menor resistência matemática — o que raramente coincide com uma arquitetura sustentável a longo prazo.

3. A Degradação do RAG e a Revolução Silenciosa do MCP

Para tentar contornar a falta de conhecimento da IA local sobre o ecossistema privado de uma empresa, a indústria adotou massivamente o RAG (Retrieval-Augmented Generation): um banco de vetores que busca trechos de arquivos do seu projeto e os injeta como contexto no prompt da IA.

O problema é que o RAG tradicional sofre de um sintoma crônico conhecido na academia como “Lost in the Middle” (Perdido no Meio). Se o volume de arquivos e contextos injetados for muito grande, a atenção do modelo foca de maneira nítida no início e no final do prompt, degradando ou ignorando completamente regras de negócio cruciais localizadas no meio do texto. Além disso, o RAG é estático; ele apenas lê informações, mas não interage com o ambiente.

É por essa razão que o jogo está virando em direção ao MCP (Model Context Protocol), protocolo introduzido recentemente pela Anthropic.

O MCP muda o paradigma de integração. Em vez de você tentar empurrar gigabytes de contexto de código para dentro do prompt da IA de forma linear, o protocolo estabelece uma arquitetura estável de Cliente/Servidor. Através dele, a IA ganha um conjunto de “ferramentas” (tools) seguras e padronizadas para inspecionar o seu ambiente de desenvolvimento sob demanda. A IA não precisa adivinhar ou ler tudo; ela usa o protocolo para inspecionar um arquivo específico, disparar um script de testes locais ou consultar um esquema de banco de dados apenas quando necessário.

O Dilema do Engenheiro: O Caminho Fácil ou a Infraestrutura Robusta?

A conveniência dos modelos proprietários pagos rodando em nuvem (como as APIs do Claude 3.5 Sonnet ou GPT-4o) é inegável. Eles resolvem o gargalo da infraestrutura local, oferecem uma capacidade de raciocínio lógico-arquitetural muito superior e não degradam a performance da sua máquina de trabalho.

No entanto, essa facilidade traz duas grandes contrapartidas que gerentes e arquitetos de software precisam calcular:

  • Custo de Escala: O consumo de tokens em projetos grandes cresce de forma exponencial se a esteira de desenvolvimento não for otimizada.
  • Privacidade e Governança: O envio de propriedade intelectual, regras de negócio proprietárias e dados sensíveis (especialmente em setores regulados, como o financeiro) para servidores de terceiros continua sendo um ponto crítico de conformidade legal.

Uma das melhores estratégias para mitigar isso sem abrir mão da eficiência é a criação de um PKM (Personal Knowledge Management) focado em IA — um “cérebro centralizado” onde você mapeia, limpa e armazena suas pesquisas, estruturas arquiteturais comuns e queries recorrentes. Isso evita o desperdício de tokens refinando o mesmo problema repetidas vezes e serve de base contextual fixa para o seu agente.

O futuro do desenvolvimento assistido por IA não parece ser uma escolha binária entre o “100% Local” ou o “100% na Nuvem”. O caminho ideal reside na engenharia híbrida: delegar tarefas complexas de refatoração arquitetural para modelos potentes em nuvem orquestrados por protocolos eficientes como o MCP, enquanto mantemos validações e rotinas de automação locais leves bem otimizadas.

A IA mudou para sempre a velocidade com que escrevemos código, mas o design, a robustez e a sustentabilidade dos sistemas continuam dependendo do olho clínico e da maturidade do engenheiro humano.

E você? Como tem estruturado a sua esteira de desenvolvimento com IA? Tem sofrido com gargalos de hardware rodando modelos locais ou a qualidade do código gerado tem sido o maior desafio? Deixe sua experiência aqui nos comentários!

Developers keep pasting JSON into online formatters and leaking secrets — so I built json-explorer

The problem

Debugging large or deeply nested JSON responses means pasting into online formatters (a privacy risk), squinting at jq output, or writing throwaway scripts — there is no instant zero-setup local tool that gives you a rich interactive view.

If you’ve hit this before, you know the friction — online formatters work but they upload your JSON to a third-party server. That’s fine for public data; it’s not fine for API responses containing auth tokens, PII, or internal service data.

As a solution, I created json-explorer

Visualize any JSON file as an interactive collapsible tree in your browser — with search, path tracking, and copy-to-clipboard

Zero dependencies. Run instantly:

npx json-explorer response.json

Output:

$ json-explorer response.json

json-explorer
  file:   /home/user/projects/api/response.json
  server: http://localhost:7777

Opening browser... press Enter to stop the server.

Your browser opens to a polished local dashboard. The server only listens on localhost — nothing leaves your machine.

Features

  • Collapsible tree — top-level nodes open by default, deep nodes collapsed
  • Live search — highlights matching keys and values, auto-expands parents
  • Path display — hover any row to see its full dot/bracket path
  • Copy to clipboard — copy any value or subtree as formatted JSON
  • Expand all / collapse all buttons
  • Auto-closes after 60 seconds (or press Enter)

How it works

Pure Node.js: reads the file, spins up an http.createServer on port 7777, serves a single self-contained HTML page with all CSS and JS inlined, then opens the default browser; server auto-closes after 60 seconds or on Enter.

Why I built it

Developers routinely paste JSON into online formatters like jsonformatter.org or jsoneditoronline.org, leaking API responses and auth tokens to third-party servers. jq is powerful but requires learning a mini-language for ad-hoc inspection. No popular zero-dep npx tool fills the gap — a local-web-dashboard format is the right choice here because the entire value is in the visual presentation, not the data transformation.

Try it

npx json-explorer --help

Part of µ micro — one new developer tool, shipped every day.

Reading the Chain, Querying Data, and Understanding Solana’s Storage Model

Visualizing the data fetched through the rpc

Day 8 : Reading On-Chain Data with RPC Calls

This is where things got real. Instead of just generating wallets and checking balances manually, I started making actual RPC calls to read data from the Solana network programmatically.
The one that clicked immediately was getBalance, you pass in a public key, and the network hands you back the wallet’s balance in Lamports. Simple, but it made the connection between “wallet” and “on-chain account” feel concrete for the first time.

const rpc = createSolanaRpc(devnet("https://api.devnet.solana.com"));

const { value: balanceInLamports } = await rpc
  .getBalance(targetAddress)
  .send();

RPC calls are basically how you talk to the Solana network. No SQL, no REST endpoints you design yourself just a set of predefined functions the network exposes.

Day 9: Querying Recent Transactions

Next I queried transaction history using getSignaturesForAddress. The interesting part was adding a limit, without it, you’d potentially pull back thousands of transactions for an active wallet.

const signatures = await rpc
  .getSignaturesForAddress(targetAddress, { limit: 5 })
  .send();

What impressed me: transactions on Solana are identified by signatures, not IDs like in a traditional database. Every signed transaction leaves a permanent, public record on-chain. You can’t hide it, you can’t delete it.

Day 10: Building a Dashboard to Visualize It All

After two days of reading raw data from the terminal, I built a simple browser dashboard to visualize everything, wallet balance, recent transactions, the works.

Visualizing the data fetched through the rpc
This was the most satisfying day so far. Taking raw RPC responses and turning them into something a non-developer could actually read, that’s the frontend instinct kicking in.

Day 11: Solana Accounts vs Traditional Databases

This one reshaped how I think about on-chain storage. Coming from Web2, I instinctively think of data as rows in a table, managed by a database I control. Solana flips that entirely.
Here’s the comparison that made it click:

differences between traditional database and solana accounts
Two things that stood out most: storage costs a refundable deposit on Solana, you get it back when you close an account. And everything is public by default. In Web2 you design for privacy. In Web3 you design assuming everyone can see everything.

Day 12: Devnet vs Mainnet

Short but important. I’d been using devnet the whole time without fully understanding what I was opting into.
Devnet is your staging environment. Free SOL via airdrop, transactions that don’t cost real money, a sandbox to break things without consequences.
Mainnet is production. Real tokens, real money, real consequences. What you deploy here lives on the blockchain permanently.
The mental model that helped: devnet is like localhost. You’d never ship straight to mainnet without testing on devnet first, just like you wouldn’t push untested code straight to production.

Five days of going deeper into how Solana actually stores and exposes data. The dashboard on day 10 brought it all together visually, but day 11 was the one that genuinely changed how I think about data storage.

Next up: on-chain programs.

Running a non-English audiobook through an AI voice pipeline: what’s involved

Most TTS-based audiobook pipelines are built around English. The voice library is English voices, the dialogue heuristics assume English punctuation, the auto-assignment models train on English-language conventions. When a writer wants to run a French, German, Spanish, or Mandarin manuscript through the same pipeline, what actually changes? Some pieces port over cleanly. Others don’t. This is a walk through what we’ve learned building multilingual support into AudioProducer.ai – what the pipeline does when the source isn’t English, and where the rough edges still are.

Voice selection across languages

The voice library has 132 voices at the time of writing, and about 64 of them are tagged for the multilingual model. “Multilingual” here is a model capability, not a guarantee that the voice sounds equally good in every language. The underlying speech model handles phonetic mapping across the languages it was trained on, so a voice that ships as “American English neutral” can produce intelligible French or Spanish output. But cadence, intonation, and the small prosodic choices that make narration sound native are language-specific learned patterns. Some voices carry their non-English performance further than others.

For a writer starting a non-English project, the practical advice is to evaluate a few of the multilingual-tagged voices on a paragraph of the target language before committing to a narrator. Voice library previews give you a feel for each voice’s range, but for a specific non-English book the honest test is generating a short sample paragraph in the actual target language inside the editor. That’s the cheapest way to know whether the voice carries the language well enough for your purpose.

Per-character voice routing when the prose has multiple speakers

Auto-Assign Characters tags every line in a chapter by speaker. Narrator, named characters, in-world labels – the AI walks the prose and attaches a speaker tag to each line. The mechanism is language-agnostic in shape: the model identifies dialogue boundaries and attribution patterns, then ties each tagged line to a character.

In practice, non-English prose introduces two adjustments.

First, dialogue punctuation conventions vary by language. French dialogue uses em-dashes and guillemets rather than the curly-quoted attribution typical in English prose; Spanish often uses em-dashes too; German uses both guillemets and quotation marks depending on house style. Auto-Assign reads these conventions, but the cleaner the source punctuation, the cleaner the first pass. Standardizing dialogue punctuation in the source manuscript – picking one convention and applying it consistently – saves several rounds of hand-correction on the auto-assigned output.

Second, voice-per-character routing surfaces in the character panel after Auto-Assign completes. If a chosen voice doesn’t carry a particular language well for a specific character, the panel is where you swap it out. Same workflow as English, with the cross-language constraint that the candidate voices need to come from the multilingual-tagged subset.

Sound design across languages

The Auto-Assign Sounds pass – music beds, ambient soundscapes, one-shot sound effects – is genuinely language-agnostic. Sound effects don’t know what language the chapter was written in. A thunderclap is a thunderclap; rain over a city is rain over a city. The model that selects sounds reads the scene’s content – storm, fight, quiet interior, scene transition – not its lexicon.

This is the part of the pipeline that ports across languages with no adjustment. A Spanish-language historical thriller and an English-language historical thriller of the same scene shape end up with broadly similar Auto-Assign Sounds output. Music selection rules (genre, energy, mood) operate at the same layer. Soundscapes earn their place by what they signal narratively, which is upstream of language.

The practical implication: when planning a non-English audiobook through the platform, the voice layer is where the language-specific work happens. The sound design layer is the same pipeline you’d run on an English book.

UI language vs. content language

The editor UI ships in 8 languages: English, French, German, Spanish, Portuguese, Chinese, Hindi, and Arabic. The UI language is independent of the content language. A French-speaking writer can drive an English audiobook project with the editor in French. A Hindi-speaking writer can drive a Spanish audiobook project with the editor in Hindi.

The two layers are decoupled by design. UI locale is picked from the Accept-Language header and an optional locale cookie at SSR time, with translation strings living in their own per-locale files. The content language is determined separately, per chapter, when audio is generated – the model auto-detects the source language of the prose.

The reason this matters: writers shouldn’t have to use English buttons and English menus to produce a French or Spanish book just because that’s how the platform happened to start. The two locales live in different parts of the stack and shouldn’t be welded together in the user’s workflow either.

What’s still hard

The honest part. Multilingual TTS at the quality level audiobook listeners expect has rough edges, and it’s worth being explicit about which ones.

Accent within a language is still hard. A “multilingual French” voice may render Parisian French well and Quebec French unevenly; a “multilingual English” voice may handle American narration cleanly and an Indian-English or Scottish-English character less convincingly. Audiobook listeners are sensitive to accent authenticity, and the available voices don’t yet span every regional variant cleanly.

Code-switching in dialogue is also rough. A character who speaks two languages within one paragraph – common in immigrant fiction, regional literary fiction, and many real human conversations – pushes the model into edge cases. Sometimes the switch lands gracefully; sometimes the model forces one language across the boundary.

Idiomatic prosody is the third rough edge. Languages carry expectations about where a sentence’s emphasis lands, how a question rises, how a punchline pauses. These are learned per-language and can drift on voices whose training data was thinner in the target language than in English.

What this means operationally: if you’re producing in a language you’re a native or near-native speaker of, you’ll catch what’s off and route around it. If you’re producing in a language you don’t speak, route the audio past a native-speaker reviewer before treating the production as final. The Auto-Assigns are starting points, not final answers – true in English, and more emphatically true outside it.

Wrapping up

Multilingual audiobook production through an AI pipeline is realistic for many language pairs and not yet realistic for all of them. The voice layer carries language-specific quality, the character routing layer carries language-specific punctuation conventions, and the sound design layer carries across languages without changes. Knowing which layer is sensitive to language and which isn’t is most of the work in planning a non-English project on the platform.

If you want to try a non-English manuscript through the pipeline, the free tier supports 1,200 words per month – enough for a short chapter sample to evaluate voice quality in your target language before committing to a paid plan. The voice library at audioproducer.ai is where the multilingual-tagged voices live; preview is the cheapest way to see whether the pipeline handles your specific language well enough for your specific book.

Disclosure: this article was drafted by an AI agent working on behalf of the AudioProducer.ai team.

MVCC Misconceptions: The Indie Hacker’s Database Choice Dilemma

Database selection is a critical decision, especially for indie hackers developing their own projects. Often, technology choices are driven by PR, and the daily impact of fundamental mechanisms like MVCC (Multi-Version Concurrency Control) can be overlooked. However, this can lead to unexpected performance issues and surprises during the development process. In this post, I will share my experiences on what MVCC means in practice, how databases like PostgreSQL and SQLite behave in scenarios commonly faced by indie hackers, and the long-term implications of these choices.

For indie hackers, database selection is usually made based on immediate needs. Concerns like project scalability, maintenance costs, and potential performance bottlenecks might take a backseat in the initial stages. However, over time, as the application grows, these “small” technical details can determine the fate of a project. Understanding what MVCC is and why it’s important is the first step to minimizing such risks.

What is MVCC and Why is it Important?

MVCC is a mechanism used in databases to manage concurrent access. Its core idea is to maintain different versions of data for each transaction. This allows one transaction to read or write data without blocking other transactions. In traditional locking-based systems, a read operation can block a write operation, and a write operation can block a read operation. MVCC reduces these blockages, enabling the database to achieve higher levels of concurrency.

This mechanism is vital, especially for high-traffic web applications and systems requiring intensive read/write operations. For example, in an e-commerce site, there might be transactions simultaneously updating product stock and user-facing transactions displaying products. Thanks to MVCC, product listing operations are not affected while stock updates occur, which positively impacts the user experience.

ℹ️ Key Benefits of MVCC

  • Increased concurrency
  • Reduced locking
  • Better read performance
  • More consistent read results

The operating principle of MVCC relies on tagging each row with timestamps or visibility maps. When a transaction reads a row, it sees the version of the row valid from the moment that transaction started. If another transaction updates the same row, a new version is created, and the old version is not immediately deleted. When these old versions are deleted depends on the database’s garbage collection mechanism. This management of “old versions” is one of the most crucial practical aspects of MVCC and has a direct impact on performance.

PostgreSQL: A Robust MVCC Implementation

PostgreSQL is one of the databases that implements MVCC in the most advanced way. Every update or delete operation creates a new version of the row and does not immediately remove the old version. This is managed by structures like “transaction ID” and “visibility map”. A transaction determines which row versions it can see based on its own transaction ID.

The most significant result of this approach is that PostgreSQL performs relatively well even in high-concurrency scenarios. While one user views a report (intensive read), another user can simultaneously place an order (intensive write). PostgreSQL, thanks to MVCC, isolates these two operations as much as possible. However, this is not a perfect solution; especially long-running transactions or frequently updated data increase the importance of the VACUUM operation.

💡 The Role of VACUUM in PostgreSQL

PostgreSQL uses the VACUUM command to clean up old data versions, called “dead tuples,” to support MVCC. When VACUUM is not run or not run frequently enough, the database size increases, query performance decreases, and the statistics from the ANALYZE command become outdated. Even if autovacuum is enabled by default, manual adjustments or more frequent triggers might be necessary for heavily loaded systems.

While working on an ERP system for a manufacturing company, I encountered a scenario where the VACUUM operation was not running in a timely manner. Daily stock movements amounted to millions, and each movement created a new row version. autovacuum was running with default settings, but “dead tuples” were accumulating due to the heavy load. As a result, SELECT query performance dropped by up to 30%. To resolve the issue, I optimized database size and query times by reducing autovacuum parameters (especially autovacuum_vacuum_threshold and autovacuum_analyze_threshold) and using the VACUUM FULL command (with caution regarding disk space). This experience showed me how critical the “cleanup” operations behind the power of MVCC truly are.

SQLite: Simplicity and MVCC Limitations

SQLite is one of the first names that come to mind when talking about embedded and lightweight databases. Stored typically as a single file, this database is popular among indie hackers due to its extreme simplicity in setup and management. However, SQLite’s MVCC implementation differs considerably from PostgreSQL’s and has some limitations.

SQLite’s default WAL (Write-Ahead Logging) mode helps it implement MVCC more effectively. In WAL mode, write operations are recorded in a separate file called sqlite-wal. This allows read and write operations to occur concurrently. That is, while one transaction writes data, other transactions can read data simultaneously. This offers a sufficient level of concurrency for many indie hacker projects.

However, due to SQLite’s fundamental architecture, only one write operation can occur at a time. When multiple threads try to write to the same database file, one thread will lock the database file, and the others will have to wait. This can create a significant bottleneck, especially when the application’s concurrent write load increases.

⚠️ Concurrent Write Limitations in SQLite

Because SQLite operates through a single database file, it supports only one write operation at a time. If multiple threads attempt to write concurrently, deadlocks and wait times become inevitable. This situation is a major disadvantage for applications with high write traffic.

I encountered this limitation in a project where I used SQLite as the local database for a mobile application I developed. The application was continuously recording sensor data in the background, and simultaneously, there were operations changing some settings from the user interface. Initially, everything seemed fine, but as concurrency increased, the database started locking frequently for write operations. The user interface would freeze, and recordings were delayed. As a solution, I tried queuing write operations and writing them to the database serially through a single thread. This largely resolved the problem but increased the application’s complexity. This experience concretely showed me the concurrency limits underlying SQLite’s simplicity.

MVCC and Trade-offs for Indie Hackers

While MVCC has become a cornerstone feature for many databases, it may not perfectly suit every application’s needs. When choosing a database for indie hackers, the advantages and disadvantages of MVCC should be carefully evaluated.

Performance: MVCC can improve overall performance by increasing the concurrency of read and write operations. However, this improvement depends on how the database is structured, data volume, and query patterns. Advanced MVCC implementations like PostgreSQL offer excellent performance in complex scenarios, while in simpler systems like SQLite, locking mechanisms might be more prominent.

Storage Space: MVCC keeps multiple versions of data, which can increase storage space requirements, especially for frequently updated data. Regular execution of the VACUUM operation in PostgreSQL is critical for managing this issue. In SQLite, WAL mode also creates additional files, but this impact is generally not as pronounced as in PostgreSQL.

Complexity: The complex mechanisms of MVCC can make understanding and managing the database more challenging. Especially for beginners, these details can be confusing. PostgreSQL’s MVCC requires more configuration and maintenance compared to SQLite’s simplicity.

💡 Database Selection Guide for Indie Hackers

  • Simple, single-user projects: SQLite is usually sufficient. It’s easy to set up and performs well in most scenarios.
  • Web applications requiring high concurrency: PostgreSQL is a more suitable option. It offers better performance due to its advanced MVCC implementation and scalability.
  • Data integrity and complex queries: PostgreSQL stands out with its ACID compliance and richer feature set.
  • Mobile applications (local data storage): SQLite is preferred for its small footprint and simplicity. However, consider concurrent write needs.

At one point, I was developing a web service that powered my financial calculators. Initially, I used SQLite as the database because I wanted to quickly build a prototype. However, as the project grew and more users started performing calculations simultaneously, SQLite’s concurrent write limitations became evident. Queries slowed down, and sometimes I even encountered errors. At this point, I decided to switch to PostgreSQL. Although the migration process took some time, the concurrency and performance improvements provided by MVCC proved critical for the project’s long-term success. This transition once again showed me how important it is to correctly evaluate trade-offs.

Real-World Scenarios and Performance Analyses

To better understand the practical implications of MVCC, let’s look at examples from real-world scenarios. These scenarios will illustrate how different databases’ MVCC mechanisms behave and their effects on performance.

Scenario 1: E-commerce Site Stock Management

On an e-commerce site, thousands of users are simultaneously viewing products while stocks are being updated in the background.

  • PostgreSQL: Thanks to MVCC, stock update operations (writes) do not largely block product viewing operations (reads). Users can smoothly view products while stocks are updated in the background. However, very frequent and large-scale stock updates might require VACUUM to run more often. For instance, if there are hundreds of stock updates per second during a sale campaign, the n_dead_tup (number of dead tuples) in the pg_stat_user_tables table can increase rapidly.
  • SQLite (WAL Mode): WAL mode enables concurrent reading and writing. However, if multiple stock update operations arrive simultaneously, these operations become serialized. If the system needs to perform 500 stock updates per second and each update takes an average of 10ms, SQLite might struggle to handle this load alone, and wait times could increase. Although settings like PRAGMA busy_timeout = 5000; exist, the fundamental locking mechanism will be limiting.

Scenario 2: Blog Comment System

On a blog site, readers comment on articles while editors add new articles or update existing ones.

  • PostgreSQL: MVCC seamlessly separates comment submission and article addition operations. When a user submits a comment, an editor adding a new article does not directly affect their transaction. pg_locks table typically shows only short-lived and specific locks.
  • SQLite: Similarly, with WAL mode, comment and article addition operations can run concurrently. However, if a large number of comments arrive simultaneously and attempt to be written to the database at the same time, SQLite’s single write lock can come into play. This situation can lead to delayed comments or users receiving “database is locked” errors.

Scenario 3: Mobile Application Synchronization

A mobile application stores user data locally and synchronizes it with a server periodically.

  • SQLite: This is one of SQLite’s most common use cases. While the application reads data, new data from the server can be synchronized in the background. WAL mode ensures this synchronization occurs without degrading the application’s overall performance. However, if a large amount of data is being written during synchronization and, at the same time, data is being changed from the user interface, deadlocks can occur. In one project, I tried to manage these deadlocks using sqlite3_busy_handler during synchronization, but this increased the complexity of the code.

These scenarios demonstrate how MVCC can yield different results in various databases. For indie hackers, choosing the right database by considering the project’s current and future needs is key to avoiding such performance bottlenecks.

Conclusion: A Pragmatic Approach

MVCC is a cornerstone of modern databases and a powerful mechanism for resolving concurrency issues. However, MVCC itself is not a magic wand. Application architecture, database selection, and configuration determine the extent to which the benefits of MVCC will be realized.

As indie hackers, our resources are often limited, and our time is valuable. Therefore, when choosing a database, it’s essential to evaluate how well it fits the project’s real needs, rather than just how “cool” the technology is. PostgreSQL, with its advanced MVCC capabilities and scalability, is a strong option for many scenarios, while SQLite’s simplicity and lightweight nature can make it more suitable for specific use cases.

In my own experiences, I’ve seen that understanding the details of a database’s MVCC implementation is critical for predicting and resolving performance issues. Topics like the importance of VACUUM in PostgreSQL or concurrent write limitations in SQLite, when overlooked early in a project, can become the source of major headaches later on.

In conclusion, understanding what MVCC is and what it means in practice will help indie hackers make more informed and strategic database choices. This not only optimizes the performance of current projects but also lays a solid foundation for future growth and scalability.

Cloudflare R2 EU jurisdiction storage for UK GDPR: what we learned

Most UK SaaS founders default to S3 in eu-west-2 and call it GDPR compliance done. It is not.

S3 in London is operated by AWS Inc, a US entity. The data sits in London. The processor sits in Seattle. Under UK GDPR Chapter V and the post-Schrems II ICO position, that is a transfer to a non-adequate third country unless your DPA includes the latest SCCs and a documented Transfer Impact Assessment. Plenty of UK SMBs get away with it. Plenty also fail compliance audits because of it.

We built Beamprobe (a UK virtual data room) on Cloudflare R2 with the EU jurisdiction flag instead. After six months in production, here is what worked, what tripped us up, and what I would do differently.

Why R2 EU jurisdiction is different from “R2 in Europe”

R2 has a jurisdiction setting at the bucket level. You can set it to EU. When you do, three things change:

  1. The bucket is pinned to EU member-state data centres. No transparent replication to North America.
  2. The bucket lives under Cloudflare’s Frankfurt-based entity for billing and processor purposes.
  3. Cross-border transfer events (egress to non-EU regions) are logged and surfaced in the audit feed.

For UK GDPR purposes, the second one is the load-bearing detail. The processor entity matters more than the rack location. Cloudflare’s EU jurisdiction puts you on a DPA signed against a Frankfurt entity, with Chapter V transfers handled through their EU subprocessor list rather than a US parent.

The setting is buried. You pick it when you create the bucket in the Cloudflare dashboard, or you pass jurisdiction: 'eu' in the S3-compatible API.

# Wrangler example
wrangler r2 bucket create beamprobe-prod --jurisdiction eu

There is no way to migrate an existing bucket to a different jurisdiction. You create a new one, copy, swap. We learned that the hard way after launching with a default-jurisdiction bucket and realising during a customer audit.

The Phoenix / ExAws setup

We use ExAws against the R2 S3-compatible endpoint. Configuration sits in runtime.exs so the same release binary runs in dev with local-disk storage and in prod with R2.

# config/runtime.exs

if storage_adapter == :s3 do
  r2_account = System.get_env("R2_ACCOUNT_ID") || raise "R2_ACCOUNT_ID missing"
  r2_key = System.get_env("R2_ACCESS_KEY_ID") || raise "R2_ACCESS_KEY_ID missing"
  r2_secret = System.get_env("R2_SECRET_ACCESS_KEY") || raise "R2_SECRET_ACCESS_KEY missing"

  config :ex_aws,
    access_key_id: r2_key,
    secret_access_key: r2_secret,
    region: "auto"

  config :ex_aws, :s3,
    scheme: "https://",
    host: "#{r2_account}.eu.r2.cloudflarestorage.com",
    region: "auto"
end

Two non-obvious bits:

  • region: "auto". R2 ignores region but ExAws insists on having one. "auto" is the canonical value.
  • The host has .eu. baked in for EU-jurisdiction buckets. Default-jurisdiction is <account>.r2.cloudflarestorage.com. Wrong host on either side returns a confusing 404.

R2 access keys are scoped per bucket, not per account. Create one set of credentials per environment. Rotate quarterly.

Encryption at rest: what R2 gives you and what it does not

R2 encrypts at rest using AES-256 with Cloudflare-managed keys. That satisfies Article 32 on its own for most categories of data. For investor-grade documents with NDAs, we add envelope encryption on top:

  1. Generate a 256-bit data encryption key (DEK) per file.
  2. AES-256-CBC encrypt the file body with the DEK.
  3. S/MIME-encrypt the DEK under a per-user key, store separately.
  4. Upload the ciphertext to R2.

The result: even a leaked R2 access key does not give the attacker plaintext. They need our user-key store too. That is two distinct breach paths needed for one disclosure, which is the bar UK GDPR Article 32 “appropriate technical measures” actually expects for special-category data.

Cloudflare also offers SSE-C (server-side encryption with customer keys). We do not use it because the key never leaves our Phoenix backend in our model; envelope encryption gives us the same guarantee with one less moving part.

Presigned URLs without leaking the bucket

R2 presigned URLs work the same as S3 presigned URLs. Time-bound, signed with HMAC-SHA256, single-resource.

defmodule Beamprobe.Storage do
  def presigned_url(key, expires_in \ 3600) do
    full = prefixed(key)
    ExAws.S3.presigned_url(:get, bucket(), full, expires_in: expires_in)
  end
end

Three things to lock down:

  • Short TTL. We use 600 seconds for viewer page rasters, 60 seconds for downloads. Long TTLs end up in browser history and crash dumps.
  • Per-object prefix. We path-prefix every object as documents/<user_id>/<room_id>/<random>.pdf. The random suffix is 16 bytes of crypto, base64. Guessing one URL gets you one document, not the bucket.
  • CORS off by default. R2 buckets default to no CORS. Do not enable it unless a browser actually needs to fetch the object directly. We serve every public asset through a viewer endpoint that signs URLs server-side.

Lifecycle policies and the egress trap

R2’s headline feature is zero egress fees. There is a footnote.

The “no egress” applies to clients of Cloudflare’s network: workers, pages, your own zone. Egress to third-party origins via S3 API call is billed. Cross-region replication is billed. Lifecycle transitions to Glacier-equivalent classes (R2 calls them “Infrequent Access”) are billed at the transition.

If you write a worker or a server that pulls objects out of R2 to a non-Cloudflare endpoint, you pay normal egress. The zero-egress model assumes your serving plane lives inside Cloudflare.

Our setup: Phoenix on Hetzner Germany. We DO pay egress when Phoenix fetches a document body to decrypt and serve. That is what we are willing to pay for control.

The lesson: model your egress before you bet your unit economics on “R2 = free egress.” For most B2B SaaS workloads this is fine. For media streaming or large-file delivery it is not.

Cost comparison vs S3 eu-west-2

Real numbers from a single Beamprobe production month (May 2026):

Line item R2 EU S3 eu-west-2 equivalent
Storage 540 GB $8.10 $12.42
Class A operations (writes) 380K $1.71 $1.90
Class B operations (reads) 4.2M $1.51 $1.68
Egress to Hetzner 180 GB (paid same as S3) $16.20
Monthly total ~$11 ~$32

For our workload R2 is roughly a third the cost. The math flips if you are serving public CDN-style traffic where R2’s free egress to Cloudflare zones wins big.

Mistakes we made

  1. Created bucket without EU jurisdiction. Lost a week migrating to an EU-jurisdiction bucket.
  2. Did not pin region in the access key. Access keys are scoped per bucket, not account. We over-permissioned an early key.
  3. Stored presigned URLs in logs. A 1-hour presigned URL is a 1-hour credential. We now redact ?X-Amz-Signature from log lines server-side.
  4. Used default CORS off but did not test. Browsers fail silently. Wrap every CORS-bound flow in a Playwright test that hits the actual bucket.
  5. Forgot to set lifecycle rules for failed multipart uploads. R2 charged us for orphaned parts for two months before we noticed. Set the lifecycle rule on day one:
wrangler r2 bucket lifecycle put beamprobe-prod 
  --abort-multipart-uploads-after-days=1

What I would do differently

  • Pick EU jurisdiction at bucket creation, no exceptions.
  • Document the processor relationship in the DPA from day one. Cloudflare publishes a UK GDPR-aware DPA at cloudflare.com/legal/data-processing-agreement. Sign it.
  • Bake the envelope-encryption pattern into the storage abstraction, not into individual upload handlers. Less risk of forgetting a code path.
  • Use Cloudflare Workers as a thin signing layer for presigned URLs if your serving plane is also on Cloudflare. We did not because Phoenix-on-Hetzner is our model. If you are on Workers anyway, do it.
  • Run a quarterly access-key rotation drill. Cloudflare makes rotation easy; the human process of updating env vars across dev/staging/prod is the slow bit.

When R2 is not the right call

  • You need single-region S3 endpoint matching for a customer who requires eu-west-2 in their DPA. R2 cannot give you AWS-region semantics.
  • You depend on AWS IAM features (resource policies, KMS integration, CloudTrail audit). R2’s IAM is simpler. Some compliance auditors prefer the AWS depth.
  • You need cross-region replication for disaster recovery. R2 does not yet ship that for EU-jurisdiction buckets at the time of writing.

For everything else, R2 EU is faster to set up, cheaper for our workload, and easier to defend in a UK GDPR audit because the processor entity is in Frankfurt rather than Seattle.

Sources

  • ICO Data Sharing Code of Practice (2024 update)
  • UK GDPR Chapter V (international transfers)
  • Cloudflare R2 jurisdiction documentation
  • Schrems II judgement, CJEU C-311/18

I write about UK data residency, document security and Elixir/Phoenix at beamprobe.com. Beamprobe is a UK virtual data room for fundraising teams and M&A advisors. Bootstrapped, built in the UK, used worldwide.

Tutorial: Build a Cost-Aware AI Support Triage API

image

Key takeaways

  • AI applications use a single endpoint to handle multiple complex tasks: classification, urgency scoring, customer-facing drafting, and long-form summarization.
  • This does not account for varying cost, latency, and quality requirements.
  • Building a FastAPI and using serverless inference infrastructure makes it possible to address these requirements through effective routing.

Most AI applications start with a single model hard-coded into the app. That works well for a prototype, but it breaks down the moment a single endpoint has to handle multiple complex task categories: classification, urgency scoring, customer-facing drafting, and long-form summarization all benefit from different model choices. Those tasks do not share the same cost, latency, or quality requirements.

Support triage is the cleanest example of this. A user types “how do I reset my password?” and you spend the same per-token rate as you do on a multi-paragraph escalation from an enterprise customer with logs pasted in. You can branch on ticket type in your app code and pick a different model per branch, but now your model selection logic lives inside your handler, your fallback strategy is a try/except, and every pricing change means a redeploy. The consequences include a 70B model classifying one-word tickets, no fallback when that model is slow, and a redeploy every time pricing shifts.

In this tutorial, we’ll use serverless inference via DigitalOcean’s Inference router to easily and quickly build a FastAPI support triage endpoint that solves all these problems at once. By the end, you’ll route classification, urgency scoring, customer replies, and escalation summaries to the right model for each job — automatically, with built-in fallback, and without a single model name in your application code. You’ll have a production-ready API that’s 71% cheaper than running everything on a frontier model.

What you’re building

Let’s construct a single endpoint, POST /triage, that takes a ticket payload and returns:

  • Classification: the issue category (billing, bug, how-to, account, etc.)
  • Urgency + sentiment: a severity score and a read on customer mood
  • Drafted reply: a short, customer-facing response
  • Escalation summary: a structured brief for a human agent, generated only when the ticket is complex enough to need one

The architecture moves from this:

App → hardcoded model (one model handles every task)

to this:

App → Serverless inference via Inference Router → best-fit model per task

The router is what makes the second diagram possible without your app knowing anything about which models exist.

Serverless Inference and DigitalOcean’s Inference Router

The Inference Router lets you define tasks and model pools, then routes incoming prompts to the best-fit model based on those task definitions and selection policies. A task is a named job with a description: “classify_ticket, for example. A model pool is the set of candidate models the router can choose from for that task, governed by a selection policy: lowest cost, lowest latency, a manually set ranking, or a fallback order. You configure all of this once at the router level, and your app calls the router instead of any specific model.”

Serverless inference lets you send API requests to models without having to create an AI agent or worry about managing infrastructure. This allow you to get started quickly without managing any components behind an inference endpoint.

The API surface is OpenAI-compatible. The base URL is https://inference.do-ai.run/v1/, and a single model access key covers both foundation models and routers.

Project setup

In order to continue, you need Python 3.10+, a DigitalOcean account with Serverless Inference enabled, and a model access key. We have already configured the full project in this repository for your convenience, but follow along in this next section to build out your own version of the API and learn why we made specific choices for the API.

The project layout is intentionally small:

support-triage/
├── main.py
├── sample_tickets.json
├── requirements.txt
└── .env

main.py holds the application code, requirements.txt the required packages, sample_tickets.json is a sample for testing the router, and .env holds the required secrets, keys, and URL base values.

To get started, clone the repo onto your machine and install everything by pasting the following into your terminal:

git clone https://github.com/Jameshskelton/triage_app
cd triage_app
python3 -m venv venv_triage
source venv_triage/bin/activate
pip install -r requirements.txt

The OpenAI SDK works as-is for DigitalOcean’s Serverless Inference: you just point base_url and api_key at DigitalOcean instead of OpenAI.

Step 1: The baseline – direct model calls

Before we touch the router, let’s build the version most developers would write first: one model, hardcoded, doing all four jobs. The next few step sections outline the work we did to build the application demo. If you would like to just test the final version, check out our repository where we stored this project.

To get started, we created main.py:

import os
import re
from openai import OpenAI
from fastapi import FastAPI
from pydantic import BaseModel
from dotenv import load_dotenv

load_dotenv()

client = OpenAI(
    base_url=os.environ["DO_INFERENCE_BASE_URL"],
    api_key=os.environ["DO_MODEL_ACCESS_KEY"],
)

MODEL = "llama3.3-70b-instruct"  # one model for everything

app = FastAPI()

class Ticket(BaseModel):
    subject: str
    body: str

def call_model(system: str, user: str) -> str:
    resp = client.chat.completions.create(
        model=MODEL,
        messages=[
            {"role": "system", "content": system},
            {"role": "user", "content": user},
        ],
    )
    return resp.choices[0].message.content.strip()

@app.post("/triage")
def triage(ticket: Ticket):
    text = f"Subject: {ticket.subject}nn{ticket.body}"

    category = call_model(
        "Classify this support ticket into one of: billing, bug, how-to, account, other. Reply with one word.",
        text,
    )
    urgency = call_model(
        "Score urgency from 1 (low) to 5 (critical) and note sentiment. Reply as 'score: N, sentiment: X'.",
        text,
    )
    reply = call_model(
        "Write a short, professional reply to this customer. Maximum 4 sentences.",
        text,
    )
    summary = call_model(
        "Summarize this ticket for a human agent. Include the problem, what's been tried, and recommended next steps.",
        text,
    )

    return {
        "category": category,
        "urgency": urgency,
        "reply": reply,
        "escalation_summary": summary,
    }

If we have set up our .env file correctly with the right API keys and values, we can run it using the code below:

uvicorn main:app --reload

Let’s test it with two tickets (one trivial, one complex), and audit the results.

Example input 1:

curl -X POST localhost:8000/triage -H "Content-Type: application/json" -d '{
  "subject": "Password reset",
  "body": "How do I reset my password?"
}'

Example output 1:

{
  "category": "account",
  "urgency": "score: 1, sentiment: neutral",
  "reply": "You can reset your password by selecting the Forgot password link on the sign-in page and following the email instructions. If you do not receive the reset email, check your spam folder or contact support for help.",
  "escalation_summary": "The customer is asking how to reset their password. No signs of account compromise, outage, or escalation risk. Recommended next step: provide standard password reset instructions."
}

Example input 2:

curl -X POST localhost:8000/triage -H "Content-Type: application/json" -d '{
  "subject": "Production outage on enterprise account",
  "body": "Our team has been unable to access the dashboard since 09:14 UTC. We have ~200 internal users blocked. Attached are logs showing 502s from the API gateway..."
}'

This gives us something like the corresponding example output 2:

{
  "category": "bug",
  "urgency": "score: 5, sentiment: frustrated",
  "reply": "Thank you for reporting this. We understand that a production dashboard outage affecting around 200 users is urgent, and we are escalating this to our engineering team immediately. Please continue to share any relevant logs or timestamps while we investigate.",
  "escalation_summary": "Enterprise customer reports a production dashboard outage beginning at 09:14 UTC. Approximately 200 internal users are blocked. Logs indicate 502 responses from the API gateway. Recommended next steps: escalate to engineering, inspect gateway and upstream service health, correlate errors around 09:14 UTC, and provide the customer with frequent status updates."
}

Both responses are useful. That is exactly why this baseline is tempting.

But look at what just happened: the same 70B model handled everything. The model classified “How do I reset my password?” into a simple category, scored urgency, drafted a short reply, and wrote an escalation summary that the ticket did not really need. Then it handled the enterprise outage, where the larger model actually makes sense.

That is the problem. The trivial ticket and the production outage have very different cost, latency, and quality requirements, but the app treats them the same. You are paying overkill rates for simple work, there is no fallback if the model is slow or unavailable, and any model-selection change means editing application code and redeploying. Let’s fix that.

Step 2: Configure the Inference Router

image

In the DigitalOcean control panel, navigate to the Inference Router using the left-hand sidebar. Then, create a new Inference Router. Name your Router appropriately, and give it a descriptive description of what it will do. For example, we named ours triage-router, and described it as “Demo Triage API for DO tutorial”.

The router then needs its four tasks, each with a description and a model pool with a selection policy. Each of these is outlined below. If you want to copy them to recreate this experiment, copy and paste the values within to the Router tasks individually. This will make probabilistically similar results to what we have.

image

Task name Description (fed to the router) Model pool strategy
classify_ticket Categorize short support messages into issue types (billing, bug, how-to, account). Lowest cost
urgency_detection Detect severity, sentiment, and escalation risk in a single pass. Lowest latency
draft_customer_reply Generate a short, professional customer-facing reply. Manual ranking
escalate_complex_issue Summarize complex tickets into structured briefs for a human agent. Manual ranking

image

When we are creating the description, selecting the router prioritization policy, and selecting the model, we need to consider the exact task we want completed to optimize our results. Here are a few things worth noting as you configure these:

  • Task descriptions matter. The router uses them to match incoming requests to the right task. Be specific about what the task does, what kind of input it expects, and the format of the output.
  • Put at least two models in every pool. A pool of one is a single point of failure. Even your “lowest cost” pool should have a fallback in case the primary is unavailable.
  • The selection policy is enforced inside the pool, not across pools. “Lowest cost” means “the cheapest model in this pool that’s currently healthy,” not “the cheapest model on the platform.”

Once the router is saved, you’ll get a router ID. That’s what your app will call.

Step 3: Refactor the app to use the router

Now the satisfying part. Replace the hardcoded MODEL constant with the router ID, and pass the task name through the request. Below is an example of what you could do to make it work, though not exactly what we did in our final release.

ROUTER = "your-router-id"  # from the DigitalOcean control panel

def parse_urgency(urgency_text: str) -> int:
    """Extract the integer score from 'score: N, sentiment: X'. Defaults to 3 if unparseable."""
    match = re.search(r"score:s*(d)", urgency_text, re.IGNORECASE)
    return int(match.group(1)) if match else 3

def call_router(task: str, system: str, user: str) -> dict:
    resp = client.chat.completions.create(
        model=ROUTER,
        messages=[
            {"role": "system", "content": system},
            {"role": "user", "content": user},
        ],
        extra_body={"task": task},  # router uses this to pick the pool
    )
    return {
        "content": resp.choices[0].message.content.strip(),
        "served_by": resp.model,  # the model the router actually picked
    }

@app.post("/triage")
def triage(ticket: Ticket):
    text = f"Subject: {ticket.subject}nn{ticket.body}"

    category = call_router(
        "classify_ticket",
        "Classify this support ticket into one of: billing, bug, how-to, account, other. Reply with one word.",
        text,
    )
    urgency = call_router(
        "urgency_detection",
        "Score urgency from 1 (low) to 5 (critical) and note sentiment. Reply as 'score: N, sentiment: X'.",
        text,
    )
    reply = call_router(
        "draft_customer_reply",
        "Write a short, professional reply to this customer. Maximum 4 sentences.",
        text,
    )

    # Only escalate when urgency warrants a human brief
    urgency_score = parse_urgency(urgency["content"])
    summary = None
    if urgency_score >= 4:
        summary = call_router(
            "escalate_complex_issue",
            "Summarize this ticket for a human agent. Include the problem, what's been tried, and recommended next steps.",
            text,
        )

    return {
        "category": category["content"],
        "urgency": urgency["content"],
        "urgency_score": urgency_score,
        "reply": reply["content"],
        "escalation_summary": summary["content"] if summary else None,
        "routing": {
            "classify_ticket": category["served_by"],
            "urgency_detection": urgency["served_by"],
            "draft_customer_reply": reply["served_by"],
            "escalate_complex_issue": summary["served_by"] if summary else None,
        },
    }

That’s the whole change. It’s already done for you in the GitHub version, so there’s no need to manually do it yourself.

With this, there are no model names anywhere in the app. The router decides which model handles each task, using the policies you configured. If you want to swap the underlying model for draft_customer_reply next month, you do it in the router, not in this file.

The app triages one ticket by breaking it into smaller AI jobs instead of asking one model to do everything at once. When you call POST /triage, main.py builds the ticket text, then sends separate router calls for:

  • classify_ticket: decides the ticket category, like billing, bug, how-to, account, or other.
  • urgency_detection: scores severity from 1 to 5 and detects sentiment; the code uses the score to decide whether to escalate.
  • draft_customer_reply: writes a short customer-facing response.
  • escalate_complex_issue: Tickets scoring 4 or 5 on urgency trigger the escalation summary; lower scores skip it entirely, which is where most of the cost savings live.

The key thing: the app always calls your DigitalOcean router ID from .env as the model, and the router decides which underlying model should handle each prompt.

Step 4: Run mixed tickets through the router

With the router wired in, let’s test it. The interesting behavior shows up when you feed the endpoint a mix of simple and complex examples. Here’s a small batch of simple to complex examples in sample_tickets.json:

[
  {"subject": "Password reset", "body": "How do I reset my password?"},
  {"subject": "Invoice question", "body": "Why was I charged twice on invoice INV-3382?"},
  {"subject": "This is ridiculous", "body": "Third time this week your dashboard has gone down during our standup. We're seriously evaluating alternatives."},
  {"subject": "Dashboard weird", "body": "the dashboard is weird since yesterday"},
  {"subject": "Production outage", "body": "Our team has been unable to access the dashboard since 09:14 UTC. ~200 internal users blocked. Logs attached show 502s from the API gateway, traced to..."},
  {"subject": "Feature request + complaint", "body": "Can you add bulk export? Also the existing export is too slow and crashes on >10k rows."},
  {"subject": "API auth", "body": "Getting 401s after rotating my key. Following the docs at /auth/rotate but the new key returns invalid."}
]

In order to test them in sequence, we have provided run_batch.py to facilitate this test. You can run it yourself with the following command:

python3 run_batch.py sample_tickets.json --json

Loop through them and you’ll see the routing do its job. The one-line “how do I reset my password?” hits the lowest-cost pool for classification and a small, fast model for urgency. The angry churn-risk message gets flagged high-urgency quickly, but the drafted reply comes from the higher-quality pool because that response is going to a real customer. The production outage gets routed to the higher-quality pool for the escalation summary, because that summary is what a human engineer is going to read at 09:15 UTC.

Because call_router surfaces resp.model as served_by, every response now tells you exactly which model handled each task. Here’s what the production outage ticket returns:

{
  "category": "bug",
  "urgency": "score: 5, sentiment: frustrated",
  "urgency_score": 5,
  "reply": "Thank you for reporting this...",
  "escalation_summary": "Enterprise customer reports a production dashboard outage...",
  "routing": {
    "classify_ticket": "openai-gpt-5-nano",
    "urgency_detection": "anthropic-claude-haiku-4.5",
    "draft_customer_reply": "anthropic-claude-sonnet-4.6",
    "escalate_complex_issue": "anthropic-claude-opus-4.7"
  }
}

One request, four different models, zero model names in your application code. The cheap classifier handled the one-word category decision, Haiku scored urgency in a single fast pass, Sonnet drafted the customer-facing reply, and Opus produced the brief your on-call engineer reads. Run the password-reset ticket and the routing.escalate_complex_issue field comes back as null — the urgency score didn’t clear the threshold, and that null is real money saved.

What this actually saves you

Let’s put numbers on it. Assume an average ticket is 300 input tokens, with output tokens varying by task (40 for classification, 30 for urgency, 150 for a reply, 250 for an escalation summary). In our 7-ticket sample, 2-3 score high enough to escalate; we use 20% as a steady-state estimate.

Using DigitalOcean’s published serverless inference rates:

Task Model Per-ticket cost
classify_ticket GPT-5 Nano $0.000031
urgency_detection Claude Haiku 4.5 $0.000450
draft_customer_reply Claude Sonnet 4.6 $0.003150
escalate_complex_issue (fires ~20% of tickets) Claude Opus 4.7 $0.007750

At 100,000 tickets/month, three strategies compared:

Strategy Monthly cost
Hardcoded Llama 3.3 70B for everything $109
Router (cost-aware) $518
Hardcoded Claude Opus 4.7 for everything $1,775

The honest result: the router isn’t the cheapest option. Hardcoded Llama 70B is. But Llama 70B writing your enterprise outage reply is the cost. You’re only saving money by treating a churn-risk ticket the same as a password reset.

The fair comparison is against the realistic alternative: once you decide Llama’s customer-facing replies aren’t good enough, the choice is Opus-for-everything or the router. The router is 71% cheaper than all-Opus while only routing the expensive Opus 4.7 model to the tickets that actually need it.

Run this math on your own ticket mix before committing. The ratio of trivial-to-complex tickets is the biggest lever: a queue that’s 80% password resets saves far more than one that’s 80% escalations.

Production checklist

Before you put this in front of real tickets:

  • Log task type, latency, token usage, and selected model on every call. You can’t tune what you can’t see, and the router’s value is invisible without per-task metrics.
  • Build a small eval set per task. Maybe 20 tickets per task with known-good outputs. Run it before changing pool composition. The whole point of the router is that you can swap models without code changes, but you still want to know whether the swap was an improvement.
  • Keep at least one fallback in every pool. A pool of one defeats half the reason to use a router.
  • Use direct model calls for controlled benchmarks. When you’re measuring a specific model’s behavior, you don’t want the router making your benchmark non-deterministic.
  • Revisit routing rules quarterly. Model pricing and quality shift. The pool that was “lowest cost” six months ago might not be today.
  • Treat task descriptions as production config. Version them, review changes, don’t edit them in the UI without a record.

Closing thoughts

The app you ended up with isn’t bigger than the one you started with: it’s actually smaller, because the model selection logic moved out of the code and into the router. The router is doing the work that used to be a match statement: matching tasks to models, falling back when something’s unavailable, and giving you a single place to change strategy. Serverless inference via DigitalOcean’s Inference Router enables your app more flexibility and efficiency without any of the hassle of a hardcoded setup.

From here, a few natural next steps: stream the draft_customer_reply task back to the client so agents can start reading before generation finishes; wire the escalation summaries into your real ticketing system; or stand up a second router for an unrelated workflow and reuse the same access key.

The full sample code is available in the companion repo, and the router configuration takes about five minutes in the DigitalOcean control panel.

I benchmarked 6 WASM image codecs in the browser. Here is what beats the server.

TL;DR

Modern WebAssembly codecs (MozJPEG, libavif, OxiPNG/imagequant, libwebp, gifsicle, SVGO) now run fast enough in the browser that you can ship a real image-compression tool without uploading anything to a server. I built one (convertilo.io) and benchmarked the codec mix on 100 real-world images. Results below — and a couple of surprises about what doesn’t work.

Raw data + repo: github.com/convertilo/wasm-image-benchmarks

The setup

  • Corpus: 100 mixed real-world images (photos, screenshots, transparent PNGs, animated GIFs, vector icons)
  • Quality: 75 (sane default for lossy)
  • Environment: Chrome 130, M-series Mac, warm WASM cache
  • Median reduction (per-image variance is ~±15%)

Results

Format Engine Mode Median size reduction
JPEG MozJPEG (@jsquash/jpeg) lossy q=75 −53%
PNG imagequant + @jsquash/png lossy palette −75%
PNG OxiPNG (@jsquash/png) lossless varies*
WebP libwebp (@jsquash/webp) lossy re-encode −17%
AVIF libavif (@jsquash/avif) lossy q=75 −59%
GIF (static) gifsicle-wasm-browser lossy −O3 −75%
GIF (animated) gifsicle-wasm-browser lossy −O3 −27%
SVG SVGO v4 browser bundle lossless multipass −42%

*OxiPNG depends on whether the input was already optimized — anything from 0% to 60%.

PNG via imagequant and JPEG via MozJPEG hit numbers competitive with server-side TinyPNG. That was the moment I stopped looking for a server.

What actually shipped this

  1. @jsquash — Sam Sneddon’s WASM bindings around mozjpeg, libwebp, libavif, OxiPNG, imagequant. Lazy-loaded per format so the bundle stays tiny: ~250 KB only when the user picks a real file.
  2. gifsicle-wasm-browser — full gifsicle 1.92 CLI compiled to WASM. Preserves animation frames properly (more on this below).
  3. SVGO v4 — works fine in the browser via the bundled svgo/browser import. No need for a server.

Three things that didn’t work (and saved you the time)

imagequant for WebP/JPEG

imagequant gives PNG the −75% number, so my first instinct was “apply the same quantization to WebP/JPEG before encoding.” It actively hurts there: the palette quantization introduces dithering noise that wrecks lossy entropy coding. WebP went from −17% to −12%.

Rule: imagequant for lossless palette PNG only. Don’t cross-apply.

WebP knob-twisting

I sunk a few hours into method: 4, pass: 3, sns_strength: 75, use_sharp_yuv: 1 for libwebp. Total impact: <1% extra reduction on already-optimized WebPs. If you’re re-encoding WebP that’s been touched by another tool, you’re squeezing a stone.

GIF via canvas/ImageData

I tried decoding GIF, processing as ImageData, re-encoding. Animation frame timings die in transit. Just use gifsicle as a File → File pipeline and pass -O3 --lossy=80 directly. Don’t reimplement what’s already a 30-year-old C tool.

The privacy-first angle

The reason I cared at all: image compressors historically upload your files. Receipts, IDs, family photos, contracts — your stuff sits on someone’s server. With WASM you can run the same codecs (MozJPEG, libavif, OxiPNG) entirely in the user’s tab. Nothing leaves the device.

I learned the hard way that this also requires not loading Yandex.Metrika or Google Analytics before consent, which is a whole other post. The codec part is the easy half.

Reproduce

git clone https://github.com/convertilo/wasm-image-benchmarks
cd wasm-image-benchmarks
# results.json has the medians I cite above
cat results.json

If you want a live implementation rather than a benchmark, all of these codecs are wired up at convertilo.io — drop a file in and watch network: no upload happens.

If you’ve squeezed more out of any of these (especially WebP — I’m convinced there’s more there), I’d love the numbers. Drop a comment.

What we built to help cat owners answer ‘when did this start?’

Every first-time kitten owner eventually faces this: the vet asks “when did you first notice that?” and you draw a blank. Not from carelessness — just from never being prompted to track.

That’s the problem Nekososdate was built around.

The core mechanic

Register your kitten’s birthdate. From that point, care guidance arrives automatically, calibrated to your cat’s current age. What matters at week 3 is not what matters at month 4. We built 54 guides to cover the phases new owners find most confusing, structured as a timeline rather than a static FAQ.

What else is in the app

  • 48 troubleshooting topics — a reference for “is this normal?” moments
  • 34 breed profiles — overview of traits and tendencies by breed
  • 15-question temperament diagnostic — focused on how this specific cat behaves, not just breed

The records and logs are really what the app is about. The guides teach; the records give you something concrete to bring to the vet.

Stack and content model

Flutter frontend, Firebase, StoreKit 2 for subscriptions. Content ships as asset-bundled JSON — no network dependency at read time. That keeps it usable in places without signal, which matters in vet waiting rooms.

Free to download. All 54 guides unlock for ¥200/month.

What it doesn’t do

iOS only — no Android build. No family sharing. Two people raising a kitten together can’t sync records across devices. It’s a real gap and one we know about.

Why we shipped it

Small, focused tools tend to outlast ambitious ones. A kitten care guide that’s genuinely useful for the first year of ownership is enough scope for a first version.

Nekososdate on the App Store

I got tired of fragmented Linux app installs, so I made Depot

Linux gives you a lot of freedom.

Sometimes too much.

Over the years, installing applications on Linux turned into a maze of different formats and workflows:

  • .deb
  • .rpm
  • .AppImage
  • .flatpakref
  • .tar.gz
  • random install scripts
  • terminal commands copied from README files

Even for experienced users, things start feeling fragmented fast.

Apps come from different places.
They install differently.
They update differently.
Some integrate with the desktop correctly.
Some don’t.

So I started building something for myself called Depot.

What is Depot?

Depot is a unified Linux application installer and manager.

The goal is simple:

Make Linux applications feel consistent, understandable, and easy to control.

Instead of thinking about package formats, repositories, extraction steps, or desktop integration, you just install the application.

Depot currently supports:

  • AppImages
  • .deb
  • .rpm
  • .tar.gz
  • Flatpak refs (.flatpakref)

with automatic handling for things like:

  • desktop integration
  • icons
  • launch entries
  • organization
  • application tracking
  • uninstalling

The idea is that applications should feel like they belong to one coherent system, regardless of how they were packaged.

Why I made it

At some point I realized something weird:

When I used Depot, Linux itself started feeling easier.

Not because Linux changed.
Because the application layer became understandable.

That was the moment I realized the real problem wasn’t technical complexity.

It was fragmentation.

A lot of people don’t dislike Linux because it’s bad.
They dislike the constant friction:

  • different install methods
  • inconsistent app behavior
  • scattered tooling
  • confusing setup instructions
  • applications living everywhere

Depot tries to reduce that friction without taking control away from the user.

You still own your system.
Things just feel more unified.

The philosophy

I don’t want Depot to become a giant bloated platform.

The goal is not:

  • cloud sync
  • accounts
  • telemetry
  • AI assistants
  • replacing the terminal
  • forcing ecosystems

The goal is much simpler:

A universal application layer for Linux.

Something lightweight.
Fast.
Understandable.
Local-first.

Something that makes Linux feel calmer.

Current state

Depot is still early, but it’s already being used by real people and has started getting downloads organically.

A lot still needs polishing:

  • UI improvements
  • better visuals
  • more testing
  • edge-case handling
  • package behavior consistency

But the core idea already works.

And honestly, that’s the exciting part.

GitHub

If you want to check it out, contribute, or give feedback:

https://github.com/netizensnoopy/depot/

I’m especially interested in hearing where Linux app management still feels painful for people.