<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Devico Solutions</title>
    <description>The latest articles on DEV Community by Devico Solutions (devico-solutions).</description>
    <link>https://dev.to/devico-solutions</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Forganization%2Fprofile_image%2F14571%2Fbaba7ed0-f054-4d76-be34-0b8e01296966.jpeg</url>
      <title>DEV Community: Devico Solutions</title>
      <link>https://dev.to/devico-solutions</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/devico-solutions"/>
    <language>en</language>
    <item>
      <title>How to Modernize a Medical Imaging Platform Without Compromising Availability</title>
      <dc:creator>Vic Tekys</dc:creator>
      <pubDate>Tue, 03 Nov 2026 10:31:00 +0000</pubDate>
      <link>https://dev.to/devico-solutions/how-to-modernize-a-medical-imaging-platform-without-compromising-availability-368n</link>
      <guid>https://dev.to/devico-solutions/how-to-modernize-a-medical-imaging-platform-without-compromising-availability-368n</guid>
      <description>&lt;p&gt;A radiologist mid-read is a hard stop on your maintenance window. Not "please schedule around it" — a hard stop. And the old trick of deploying at 2 a.m. dies the moment your platform serves teleradiology across several time zones, because it's always working hours somewhere.&lt;/p&gt;

&lt;p&gt;The reflex answer is more redundancy: another replica, blue-green deploys, chase another nine. I've watched teams spend quarters on that and still get paged, because the real availability risk on imaging platforms usually isn't in the deploy pipeline. It's in not knowing how the system is assembled — and in touching the wrong layer first.&lt;/p&gt;

&lt;p&gt;After several years of working on radiology and imaging systems at Devico, my rule is: modernize from the outside in. The layers furthest from the reading path — infrastructure, backup and DR, monitoring, device fleets, analytics — absorb change safely, and that's where most of the actual risk lives. The viewing and reporting path changes last, incrementally, and only once you can observe it and restore it.&lt;/p&gt;

&lt;p&gt;The stakes aren't abstract. In the &lt;a href="https://www.proofpoint.com/us/newsroom/press-releases/nearly-three-four-us-healthcare-organizations-report-patient-care-disruption" rel="noopener noreferrer"&gt;2025 Ponemon/Proofpoint study&lt;/a&gt; of 677 US healthcare IT and security professionals, 72% of organizations hit by common cyberattacks reported disruption to patient care, and the average cost of the most significant attack was $3.9 million. On an imaging platform, "disruption to patient care" means someone's diagnosis is late.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;What "availability" means when a radiologist is mid-read&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Before you modernize anything, get precise about what failure looks like. Imaging platforms fail in ways uptime checks are blind to.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Downtime, degraded reads, and lost studies are three different failures&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;They have different blast radii and different fixes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Downtime&lt;/strong&gt; — the worklist won't load, nobody can read. Loud, obvious, and honestly the rarest of the three.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Degraded reads&lt;/strong&gt; — the worklist responds, but studies arrive late, images render slowly, prior exams take 30 seconds to hydrate. The radiologist works slower and trusts the system less. Your HTTP 200 monitoring says everything is fine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lost or stalled studies&lt;/strong&gt; — a routing device drops a series, a study sits in a queue nobody watches. This is the worst one, because nothing looks broken until a clinician asks where the scan went.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We saw the second failure mode play out at &lt;a href="https://devico.io/success-stories/medneo" rel="noopener noreferrer"&gt;medneo&lt;/a&gt;, a radiology-as-a-service provider founded in 2012 that runs MRI and CT sites across Germany and the UK on a bespoke RIS. There was no outage to point at. Patient volumes grew, features kept shipping, and load times quietly degraded while regressions started to slip through. Availability eroded without a single incident ticket.&lt;/p&gt;

&lt;p&gt;So the first modernization deliverable isn't a new architecture. It's a definition:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Split the platform into paths: ingest, storage, routing, viewing, reporting.&lt;/li&gt;
&lt;li&gt;Give each path its own SLO.&lt;/li&gt;
&lt;li&gt;Measure what clinicians feel — study-arrival latency, time-to-first-image, worklist refresh time — not HTTP status codes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The honest limitation: per-path SLOs need instrumentation that legacy imaging stacks don't have. That's not a reason to skip it. That's your first project.&lt;/p&gt;

&lt;p&gt;And the load is only going up. Signify Research counted &lt;a href="https://www.signifyresearch.net/insights/diagnostic-imaging-procedure-volumes-infographic/" rel="noopener noreferrer"&gt;5.9 billion&lt;/a&gt; diagnostic imaging procedures globally in 2024 across CT, MRI, X-ray and ultrasound, growing at a projected 4.5% a year through 2029. Meanwhile the Harvey L. Neiman Health Policy Institute &lt;a href="https://www.neimanhpi.org/press-releases/new-studies-shed-light-on-the-future-radiologist-workforce-shortage-by-projecting-future-radiologist-supply-and-demand-for-imaging/" rel="noopener noreferrer"&gt;projects&lt;/a&gt; that imaging demand will grow up to 26.9% by 2055 against a 25.7% growth in radiologist supply — the shortage persists. Every radiologist-minute your platform wastes is getting more expensive, not less.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Map the system before you change it&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Here's the unglamorous truth: on most legacy imaging estates, the biggest availability risk is that nobody can tell you how the thing is put together.&lt;/p&gt;

&lt;p&gt;Our engagement with &lt;a href="https://devico.io/success-stories/openrad" rel="noopener noreferrer"&gt;OpenRad&lt;/a&gt; started exactly there. OpenRad — formed in November 2022 from the merger of Biotronics3D and Visbion — runs a cloud radiology reporting platform with a teleradiology module, an enterprise worklist, and a thin-client architecture that streams pixels instead of transferring image data. The brief wasn't "rearchitect the platform." It was: enterprise-standard IT, minimum downtime, and critical-system availability in a disaster event.&lt;/p&gt;

&lt;p&gt;What we found first was an infrastructure with no documentation and a recovery path nobody had ever tested. Until that's fixed, every change is a gamble — you can't reason about blast radius on a system you can't describe.&lt;/p&gt;

&lt;p&gt;This isn't an imaging-specific quirk. In the Uptime Institute's &lt;a href="https://uptimeinstitute.com/about-ui/press-releases/uptime-announces-annual-outage-analysis-report-2025" rel="noopener noreferrer"&gt;2025 outage analysis&lt;/a&gt;, 40% of organizations reported a major outage caused by human error in the past three years — and 85% of those incidents traced back to staff not following procedures, or the procedures themselves being broken. Undocumented systems don't just fail more; they fail in ways you can't rehearse.&lt;/p&gt;

&lt;p&gt;So the sequence before any migration is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Inventory the critical services.&lt;/strong&gt; Every VM, every dependency, every "oh right, that box" that turns out to route half the studies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write the runbook.&lt;/strong&gt; On OpenRad we kept documentation in Trilium Notes — the tool matters far less than the habit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test the restore.&lt;/strong&gt; Not "backups ran green." Actually rebuild a critical service from backup and time it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A team that can't rebuild a node deterministically has no availability story, whatever its deploy strategy diagram says.&lt;/p&gt;

&lt;p&gt;The cost is real, though: discovery produces no user-visible change for weeks, which makes it the first thing stakeholders want to cut. Defend it anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Layer one — infrastructure you can rebuild and restore&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Once the map exists, the highest availability-per-effort work is the layer that touches zero clinical code: virtualization, provisioning, backup, monitoring.&lt;/p&gt;

&lt;p&gt;On OpenRad, a two-person Devico team — one sysadmin, one BI developer, engaged since 2023 — standardized the estate on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Proxmox VE&lt;/strong&gt; for virtualization, replacing an unoptimized, inconsistent setup&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Proxmox Backup Server&lt;/strong&gt; for backups that get restore-tested, not just taken&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ansible&lt;/strong&gt; so a node is a playbook run, not an artisanal snowflake&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prometheus + Grafana&lt;/strong&gt; so degradation shows up on a graph before it shows up in a radiologist's complaint&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Deliberately boring choices, and mostly open source — which also cut vendor licence cost, a real consideration when the platform runs on infrastructure margins.&lt;/p&gt;

&lt;p&gt;Why backups-you-can-actually-restore leads the list: Veeam's &lt;a href="https://www.veeam.com/company/press-release/ransomware-continues-to-cause-mayhem-as-victims-are-unable-to-recover-43-percent-of-affected-data.html" rel="noopener noreferrer"&gt;2024 Ransomware Trends report&lt;/a&gt; (1,200 organizations that had been breached) found victims were unable to recover 43% of their affected data, and one in three organizations that paid the ransom still couldn't recover. For a hospital-facing platform, "we lost 43% of the studies" is not an IT incident, it's a clinical one. Ransomware pressure on the sector isn't easing either — Comparitech &lt;a href="https://www.comparitech.com/news/healthcare-ransomware-roundup-2025-stats-on-attacks-ransoms-and-data-breaches/" rel="noopener noreferrer"&gt;tracked 445 ransomware attacks&lt;/a&gt;against healthcare providers in 2025 alone.&lt;/p&gt;

&lt;p&gt;The monitoring half of this layer is what makes the per-path SLOs from earlier real. The shape of the check matters more than the stack — what you want is an alert on the clinical symptom:&lt;/p&gt;

&lt;h1&gt;
  
  
  Alert on the thing the radiologist feels, not on the box
&lt;/h1&gt;

&lt;ul&gt;
&lt;li&gt;alert: StudyArrivalLatencyHigh&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;expr: histogram_quantile(0.95, rate(study_arrival_seconds_bucket[10m])) &amp;gt; 300&lt;/p&gt;

&lt;p&gt;for: 15m&lt;/p&gt;

&lt;p&gt;annotations:&lt;/p&gt;

&lt;p&gt;summary: "p95 study arrival exceeds 5 minutes — reads are degrading"&lt;/p&gt;

&lt;p&gt;(That's the shape, not OpenRad's config — the point is the metric: seconds from modality push to worklist-visible, not CPU on the ingest box.)&lt;/p&gt;

&lt;p&gt;Be honest about what this layer is: infrastructure hardening, not application modernization. The result on OpenRad was a stable, enterprise-level infrastructure with documentation and a tested critical-service recovery plan. Nobody's reading experience changed — and that's exactly the point. You just removed the biggest class of catastrophic risk without touching the reading path.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Layer two — the transport edge: DICOM routing and device fleets&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Engineers who haven't worked in imaging consistently forget this layer exists. A big share of an imaging service's reliability lives in hardware sitting at the hospital edge — devices that route, translate, compress and encrypt DICOM between modalities, trailers, and the platform.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://devico.io/success-stories/visbion" rel="noopener noreferrer"&gt;Visbion&lt;/a&gt; is the sharpest example I've got. Their Image Cube is a DICOM 3.0 routing, translation, compression and encryption device used by NHS Breast Screening and Diagnostic Imaging Services — including managing mobile scanning trailers — and it's the UK's largest installed base of dedicated imaging routing hardware. When investment-funded growth pushed the fleet into the hundreds of devices, the constraint wasn't application code at all. It was fleet management.&lt;/p&gt;

&lt;p&gt;The work — three Devico DevOps consultants, engaged since 2023 — was classic outside-in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Infrastructure automation&lt;/strong&gt; for fleet deployment, so provisioning hundreds of edge boxes stops being manual toil&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DICOM compression and encryption optimization&lt;/strong&gt;, because at fleet scale, transport efficiency is a reliability property&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A modular redesign using cloud components&lt;/strong&gt;, so the architecture can absorb the next few hundred devices&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The reported outcome: lower operational costs and a marked increase in the performance and reliability of the entire imaging service. I'll flag the limitation myself — those results are qualitative; no before/after numbers are published.&lt;/p&gt;

&lt;p&gt;Two more constraints make this layer slower than it looks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Edge changes are gated by hospital network policy — you ship on the hospital's terms, not your sprint cadence.&lt;/li&gt;
&lt;li&gt;Some of it needs physical access. You can't ansible-playbook your way into a scanning trailer that's parked at a different site today.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But notice what this layer shares with layer one: massive availability upside, zero changes to the viewing and reporting code that a radiologist depends on mid-read.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Layer three — the application: refactor under load, don't rewrite under load&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Eventually you do have to touch the reading path. The question is how.&lt;/p&gt;

&lt;p&gt;Under live clinical load, my strong preference is refactoring, framework upgrades and pipeline work over structural rewrites. A rewrite concentrates regression risk, and on an imaging platform regression risk &lt;em&gt;is&lt;/em&gt; availability risk — a bug in study routing isn't a bad release, it's a delayed diagnosis.&lt;/p&gt;

&lt;p&gt;medneo is the case I keep coming back to. The RIS wasn't down; it was accreting features, and every release made it a little slower and a little more regression-prone. Over three years (2019–2022), a team of eight Devico engineers worked on it like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Code optimization on the hot paths&lt;/strong&gt; — including hunting down and removing unnecessary database calls that had accumulated over years of feature work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Framework upgrades&lt;/strong&gt; — moving legacy frameworks to current, more efficient versions in increments, instead of a big-bang platform swap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI/CD introduction&lt;/strong&gt; — so every change to the reading path ships through the same tested pipeline.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The published results are directional, not numeric, and I'll keep them that way: significantly reduced load times, fewer outages, reduced impact from new feature rollouts, and headroom to scale with patient growth without performance loss.&lt;/p&gt;

&lt;p&gt;The CI/CD piece deserves emphasis. On a system where you can't afford regressions, the pipeline isn't developer comfort — it's an availability control, the mechanism that lets you change the most sensitive layer in small, reversible steps.&lt;/p&gt;

&lt;p&gt;And the limitation, stated plainly: refactoring cannot raise an architectural ceiling. If the reading path's fundamental design can't carry your growth, incremental optimization is buying time, not solving the problem. More on that at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Layer four — move analytics off the transactional path&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The last layer is about adding capability without adding risk: reporting and decision support belong &lt;em&gt;beside&lt;/em&gt; the transactional system, on separate stores — never as heavy queries against the database your worklist runs on.&lt;/p&gt;

&lt;p&gt;The failure mode you're preventing is mundane: someone builds a management dashboard against the production database, a quarterly report kicks off mid-morning, and suddenly image load times spike for every reading radiologist. Analytics traffic and clinical traffic have opposite shapes, and they should never compete for the same I/O.&lt;/p&gt;

&lt;p&gt;On OpenRad this was the second workstream: BI across multiple databases, built on Power BI, Azure and MS SQL Server, shipped as standardized dashboard templates that OpenRad's clients drive themselves — slicing their own data without anyone touching the clinical path to answer a question.&lt;/p&gt;

&lt;p&gt;For the architectural pattern at its cleanest, look at &lt;a href="https://devico.io/success-stories/gaze-health" rel="noopener noreferrer"&gt;GazeHealth&lt;/a&gt; — with a caveat I'll make explicit: it's a mental-healthcare analytics platform and EMR, not an imaging system, so take it as evidence for the separation pattern only. Six Devico full-stack developers and two AI data scientists have been building it since 2019 as two components — an EMR and an analytics platform — designed to work independently or together, with role- and company-based routing enforced through AWS Cognito so users never even get routes to data they can't access. Either half can evolve, degrade, or be rebuilt without taking the other down. That's the property you want between your imaging platform and its analytics.&lt;/p&gt;

&lt;p&gt;The costs of decoupling, honestly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your analytics are now behind the transactional truth by whatever your pipeline latency is.&lt;/li&gt;
&lt;li&gt;You own a second consistency model, and someone will eventually ask why the dashboard and the worklist disagree.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both are acceptable prices for the guarantee that a heavy query can never slow a read.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;What this sequencing costs you&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Outside-in sequencing is not free, and pretending otherwise is how the plan dies in month three. Going in, know that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;User-visible progress arrives slowly.&lt;/strong&gt; Documentation, backup, monitoring and fleet work are invisible to clinicians for months. If leadership expects a new viewer by Q2, this ordering will read as stalling — set the expectation before you start.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The legacy core lives longer.&lt;/strong&gt; You are deliberately keeping the old reading path in production while you harden everything around it. That's a feature of the plan, but it means carrying legacy operational cost longer than a rewrite roadmap promises (and roughly as long as a rewrite roadmap actually delivers).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It needs a whole-system owner.&lt;/strong&gt; Without someone accountable for the full sequence, you end up with a permanently half-modernized estate: nice dashboards, solid backups, and a reading path nobody ever got to. These are multi-year engagements — medneo ran three years, OpenRad and Visbion have been running since 2023.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The ordering rule underneath all of it: you can't safely change what you can't restore or observe. So documentation, DR and monitoring come first. The transport edge and analytics come next, because they're separable from the reading path. The reading path itself comes last, and moves in small, pipeline-guarded steps.&lt;/p&gt;

&lt;p&gt;And one test decides whether this playbook applies to you at all. Ask: can the current reading path, refactored but not rearchitected, carry the next two years of study volume? If yes, work outside-in and let the core evolve incrementally. If no, the infrastructure work is still worth doing — but be honest that it's buying time for a deeper change on the core, and start planning that change now, while your restores still work and your graphs still tell you the truth.&lt;/p&gt;

</description>
      <category>healthcare</category>
      <category>devops</category>
      <category>architecture</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>How to Take a Fintech Product from Web to Mobile</title>
      <dc:creator>Vic Tekys</dc:creator>
      <pubDate>Tue, 27 Oct 2026 10:27:00 +0000</pubDate>
      <link>https://dev.to/devico-solutions/how-to-take-a-fintech-product-from-web-to-mobile-2ha2</link>
      <guid>https://dev.to/devico-solutions/how-to-take-a-fintech-product-from-web-to-mobile-2ha2</guid>
      <description>&lt;p&gt;Every web-to-mobile project I've been on had the same scoping meeting. Someone opens with "we already have the API, so mobile is mostly UI," the room nods, and a quarter gets pencilled in.&lt;/p&gt;

&lt;p&gt;That sentence hides four things: the read contract your web app grew up with, the auth model that lives in a cookie, the OS plumbing a browser gave you for free, and a release cycle where "ship the fix" meant a redeploy, not a store review.&lt;/p&gt;

&lt;p&gt;Most of the work lands behind the screens. The mobile client is the cheap part; what costs money is everything the web app was allowed to assume. This is about re-negotiating those assumptions, with shipped projects as evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Decide what parity means before you scope&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;"Parity" is the default nobody chooses. It arrives because nobody wrote down a narrower target, and it quietly becomes the acceptance criterion.&lt;/p&gt;

&lt;p&gt;There are three defensible targets, and you should pick one out loud:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Companion app.&lt;/strong&gt; Read everything, do one core action (approve a payment, sign a transaction, check a balance). Cheapest, and often the right first release.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Functional parity.&lt;/strong&gt; Every flow on web works on mobile. This is what most stakeholders mean when they say "the app."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mobile-first rebuild.&lt;/strong&gt; Mobile is a net-new surface with its own flows, and web follows later.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Two projects show the ends of that spectrum. On &lt;a href="https://devico.io/success-stories/money-gate" rel="noopener noreferrer"&gt;Moneygate&lt;/a&gt;, a payment provider regulated by the Central Bank of Cyprus, the brief was explicitly a mobile app replicating all the functionality of the web platform: payments, multi-currency, fraud prevention, reporting, analytics.&lt;/p&gt;

&lt;p&gt;Parity was a stated requirement, and a five-person team delivered it: two React Native devs, a QA engineer, a designer and a business analyst. The BA mattered; the case page counts more than 22 business flows and features that had to be documented before they could be scoped.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://devico.io/success-stories/ctrl-wallet" rel="noopener noreferrer"&gt;Ctrl&lt;/a&gt;, the wallet formerly known as XDEFI, went the other way. The product existed only as a web version and a browser extension, and "no mobile access" was the first problem on the list. Mobile there was new surface, not a port.&lt;/p&gt;

&lt;p&gt;One limitation to accept up front: parity is measured in flows, not features. A web screen with six optional filters and a CSV export is not one mobile screen; it's three, plus a decision about which filter combinations deserve a bottom sheet.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Your web API is the first thing to break&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Web APIs evolve under assumptions mobile invalidates:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Many small round trips are fine, because the tab sits on Wi-Fi.&lt;/li&gt;
&lt;li&gt;Large payloads are fine, because the page renders whatever arrives.&lt;/li&gt;
&lt;li&gt;Offline is a non-case; the user reloads.&lt;/li&gt;
&lt;li&gt;Old API versions die the moment you redeploy, because every client reloads on the next visit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Mobile invalidates all four, and the last one hurts most. The moment you ship a binary, you own a population of clients you cannot force-upgrade.&lt;/p&gt;

&lt;p&gt;In June 2025 Apple's own numbers put iOS 18 on &lt;a href="https://www.macrumors.com/2025/06/05/ios-18-adoption-numbers-june/" rel="noopener noreferrer"&gt;82% of all active iPhones&lt;/a&gt;, nine months after release: one in five devices still on an older OS. Android is worse; April 2026 Statcounter data compiled by apilevels shows &lt;a href="https://apilevels.com/" rel="noopener noreferrer"&gt;Android 11 or newer on 86.9% of devices&lt;/a&gt;, with the newest release at 22.3%.&lt;/p&gt;

&lt;p&gt;Your app's update curve follows the same shape, so a /v1 endpoint you'd love to delete will get traffic for a year.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Chatty endpoints, cold starts and the unified read&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;The practical fix is server-side aggregation and deliberate versioning. Android vitals flags a cold start of &lt;a href="https://developer.android.com/topic/performance/vitals/launch-time" rel="noopener noreferrer"&gt;5 seconds or longer&lt;/a&gt; as excessive, and a home screen that fans out to eight REST calls on a cellular connection eats that budget before the first balance renders.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://devico.io/success-stories/defi-wallet" rel="noopener noreferrer"&gt;DEFI Wallet&lt;/a&gt;, a non-custodial multichain wallet spanning 20 blockchains, is the clearest example of building the read contract once. Its backend is a set of microservices behind a GraphQL router that combines their schemas: blockchain indexers for real-time transactions, a Python service for token metadata, and Node.js/Nest.js Asset and NFT balance services.&lt;/p&gt;

&lt;p&gt;That single graph serves the browser extensions (Chrome, Edge, Opera, Brave), the web app and the iOS/Android apps. &lt;a href="https://devico.io/success-stories/mode-app" rel="noopener noreferrer"&gt;Mode&lt;/a&gt;, a UK-listed crypto company with GBP, EUR and BTC accounts, used microservices for the same reason across its mobile backend and admin portal, exposing GraphQL to both.&lt;/p&gt;

&lt;p&gt;What that looks like from the client side is one query per screen, not one per widget:&lt;/p&gt;

&lt;h1&gt;
  
  
  One round trip for the portfolio screen; the router fans out server-side
&lt;/h1&gt;

&lt;p&gt;query PortfolioScreen($account: ID!, $fiat: Currency!) {&lt;/p&gt;

&lt;p&gt;account(id: $account) {&lt;/p&gt;

&lt;p&gt;balances(fiat: $fiat) { asset { symbol chain } amount fiatValue }&lt;/p&gt;

&lt;p&gt;nfts(first: 20) { edges { node { id name imageUrl } } }&lt;/p&gt;

&lt;p&gt;recentTransactions(first: 10) { hash status timestamp }&lt;/p&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;The thing to look at is the first: arguments. Pagination limits that were optional on web become mandatory on mobile, because the alternative is a 4 MB response on a train.&lt;/p&gt;

&lt;p&gt;The limitation is real, though. A unified API is a coupling decision: one aggregation layer serving three clients becomes the bottleneck every team queues behind, and a schema change for the extension now has a mobile review attached to it.&lt;/p&gt;

&lt;p&gt;In the 2024 State of React Native survey, &lt;a href="https://results.2024.stateofreactnative.com/en-US/data-fetching/" rel="noopener noreferrer"&gt;59.3% of respondents&lt;/a&gt; use TanStack Query and 34.2% use Apollo Client. Whichever you pick, the server contract decides how many requests a screen makes, not the client library.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;State management doesn't survive the trip&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Redux-shaped web state is tuned for a tab that reloads on demand. A mobile app backgrounds, loses the network in a lift, and resumes four hours later with a store full of stale balances and an expired token. The state model has to separate two things the web app blurred:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Server state&lt;/strong&gt;: cache, refetch, invalidation, background sync. Belongs to a query library.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Client state&lt;/strong&gt;: navigation, form input, UI toggles. Belongs to a small store, persisted deliberately.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Moneygate used React Query for server state under a server-state pattern, with React Hook Form for input. Ctrl runs Redux Toolkit with Redux Persist in the extension but Zustand in the React Native app. DEFI Wallet made the same split: Redux on web, Zustand on mobile.&lt;/p&gt;

&lt;p&gt;That is three teams independently accepting two state libraries in one product. They're not alone: Redux is still used by &lt;a href="https://results.2024.stateofreactnative.com/en-US/state-management/" rel="noopener noreferrer"&gt;71% of React Native respondents&lt;/a&gt; and Zustand by 46.7%, with Redux complexity the third most-cited pain point. Two stores is a maintenance tax; the case evidence shows teams paying it, not that it's free.&lt;/p&gt;

&lt;p&gt;The part that bit us was hydration ordering. A persisted store that rehydrates after the first render shows a logged-out screen for one frame, then flips, and on a payments app that flash is a support ticket. Gate rendering on hydration explicitly:&lt;/p&gt;

&lt;p&gt;// Zustand + persist: don't render auth-dependent screens until the store is back&lt;/p&gt;

&lt;p&gt;export const useSession = create()(&lt;/p&gt;

&lt;p&gt;persist(&lt;/p&gt;

&lt;p&gt;(set) =&amp;gt; ({ token: null, hydrated: false, setToken: (t) =&amp;gt; set({ token: t }) }),&lt;/p&gt;

&lt;p&gt;{&lt;/p&gt;

&lt;p&gt;name: 'session',&lt;/p&gt;

&lt;p&gt;storage: createJSONStorage(() =&amp;gt; secureStorage), // not AsyncStorage for tokens&lt;/p&gt;

&lt;p&gt;onRehydrateStorage: () =&amp;gt; (state) =&amp;gt; state &amp;amp;&amp;amp; (state.hydrated = true),&lt;/p&gt;

&lt;p&gt;},&lt;/p&gt;

&lt;p&gt;),&lt;/p&gt;

&lt;p&gt;);&lt;/p&gt;

&lt;p&gt;The comment on storage is the point. AsyncStorage is used by 83.8% of respondents in the same survey, and it is plaintext. Session material goes in the keychain or keystore; everything else can go in AsyncStorage or MMKV.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Platform plumbing is the backlog nobody writes down&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;A browser handed the web app downloads, PDF viewing, printing and camera access, and none of it appeared in a ticket. On mobile each is an integration with its own permission model and OS-version behaviour. Budget them as features, not tasks.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Files, PDFs and the Android storage rules&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Moneygate's challenge list included secure file downloads on Android and PDF preview with actions. Neither is exotic on web. On Android, apps targeting Android 11 or later run under scoped storage; the system &lt;a href="https://developer.android.com/about/versions/11/privacy/storage" rel="noopener noreferrer"&gt;ignores the requestLegacyExternalStorage flag&lt;/a&gt;, WRITE_EXTERNAL_STORAGE grants nothing extra, the Storage Access Framework refuses ACTION_OPEN_DOCUMENT_TREE on the Download directory, and declaring All Files Access "can affect your ability to publish and update your app on Google Play."&lt;/p&gt;

&lt;p&gt;So the web app's &lt;a&gt; became, on Moneygate:&lt;/a&gt;&lt;/p&gt;
&lt;a&gt;

&lt;ul&gt;
&lt;li&gt;expo-file-system with the Storage Access Framework for user-directed saves.&lt;/li&gt;
&lt;li&gt;rn-pdf-reader-js for in-app PDF preview with actions.&lt;/li&gt;
&lt;li&gt;react-native-gesture-handler plus bottom-sheet for full-screen modals replacing traditional buttons.&lt;/li&gt;
&lt;li&gt;Reanimated for animation performance, because a statement list that stutters reads as a broken app.&lt;/li&gt;
&lt;/ul&gt;

&lt;/a&gt;&lt;p&gt;&lt;a&gt;&lt;/a&gt;&lt;a href="https://devico.io/success-stories/soapbox" rel="noopener noreferrer"&gt;Soapbox&lt;/a&gt;, a fulfilment-tech company's Android warehouse app, hit the other classic: React Native library compatibility problems implementing barcode scanning, settled with vision-camera-code-scanner and react-native-print.&lt;/p&gt;

&lt;p&gt;Every one of those libraries carries native code, narrows your upgrade path and becomes a dependency you own through every SDK bump. The 2024 survey put New Architecture adoption at &lt;a href="https://www.devclass.com/development/2025/03/04/react-native-survey-shows-rocky-path-for-new-architecture/1623425" rel="noopener noreferrer"&gt;almost 50%&lt;/a&gt; with library compatibility the standing concern; the PDF viewer you picked in year one is exactly what blocks you in year two.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Biometrics are an unlock, not an authentication&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;This is the argument I have most often with product owners. Face ID does not log a user in. It gates something on the device, and the server should never learn or care that a biometric happened.&lt;/p&gt;

&lt;p&gt;Treat it as a local factor:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The server issues a session or refresh token after real authentication (password, passkey, OTP, whatever your risk model requires).&lt;/li&gt;
&lt;li&gt;The token sits in the keychain or keystore, wrapped by a key that requires user presence.&lt;/li&gt;
&lt;li&gt;Biometrics unlock that key. Server-side authorization is unchanged and still expires and revokes on its own schedule.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Android's docs separate &lt;a href="https://developer.android.com/identity/sign-in/biometric-auth" rel="noopener noreferrer"&gt;BIOMETRIC_STRONG (Class 3) from BIOMETRIC_WEAK (Class 2)&lt;/a&gt; and provide CryptoObject so a successful prompt unlocks a Cipher or Signature rather than returning a boolean. OWASP lists &lt;a href="https://mas.owasp.org/MASTG/best-practices/MASTG-BEST-0036/" rel="noopener noreferrer"&gt;cryptographic binding&lt;/a&gt; as a best practice for exactly that reason: a boolean callback can be bypassed or replayed; a keystore key with setUserAuthenticationRequired(true) cannot be used without the prompt succeeding.&lt;/p&gt;

&lt;p&gt;Moneygate used expo-local-authentication for biometric authorization. That library gives you the prompt; the key-wrapping around it is your design.&lt;/p&gt;

&lt;p&gt;Users want this. In the FIDO Alliance's 2024 barometer, biometrics ranked as the login method consumers consider &lt;a href="https://thefintechtimes.com/password-usage-stagnates-as-consumers-turn-to-biometrics-and-passkeys-the-fido-alliance-reveals/" rel="noopener noreferrer"&gt;both the best experience and the most secure&lt;/a&gt; for the second year running, and 42% had abandoned a purchase in the previous month over a forgotten password. That demand is exactly why teams are tempted to let the prompt stand in for auth.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Custody and secrets change shape on mobile&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Custodial fintech has the easy version: the secret is a session token, and the worst case is re-login. Non-custodial products have the hard version. The key material lives on a device you don't control, next to a screenshot tool, a clipboard and whatever the user side-loaded.&lt;/p&gt;

&lt;p&gt;Ctrl and DEFI Wallet are both non-custodial. DEFI Wallet's case page states it does not store users' seed phrases or passwords, and it ships the same key operations across extensions, web and mobile, with Ledger hardware-wallet support as an alternative to on-device keys.&lt;/p&gt;

&lt;p&gt;What I can offer as engineering practice, kept separate from what those clients published:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Generate and use keys inside the platform's hardware-backed store; export only for recovery flows, never for convenience.&lt;/li&gt;
&lt;li&gt;Bind signing to Class 3 biometrics or a device credential, with explicit confirmation per transaction, not per session.&lt;/li&gt;
&lt;li&gt;Invalidate biometric-bound keys when enrolment changes, another OWASP best practice for the same reason.&lt;/li&gt;
&lt;li&gt;Design recovery as a first-class flow. A wallet whose recovery path is "the user wrote the phrase down" has outsourced its most dangerous UX to a napkin.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No source describes Ctrl's or DEFI Wallet's key storage, recovery or attestation design, so don't read that list as their architecture. It's the checklist the shipped surface implies you'll need.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Reuse what's shared, fork what isn't&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The shared-code fantasy: one monorepo, one business-logic package, two thin UI shells. In practice the reuse band is narrower:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Design tokens.&lt;/strong&gt; Colours, spacing, typography. Cheap to share, high payoff.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Components.&lt;/strong&gt; Worth sharing when the primitive is the same on both platforms; the layout around it usually isn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validation schemas.&lt;/strong&gt; Zod or equivalent; IBAN rules don't change with screen size.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Business logic.&lt;/strong&gt; Partial at best. The moment a flow touches storage, auth or navigation, it diverges.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ctrl's problem list named component inconsistency across platforms as something slowing development, and the answer was a unified component library for web and mobile, with the extension on Tamagui and Zod. DEFI Wallet listed accelerating web development through component reusability as an explicit challenge.&lt;/p&gt;

&lt;p&gt;Both teams treated the component layer as the reuse target. Neither claims they shared the business logic.&lt;/p&gt;

&lt;p&gt;The cost is coupling. A shared library forces one release cadence on two platforms with different constraints: the extension can ship a fix the same day; the app cannot. That's the price of consistency, and it's worth paying only if you also set up the release side.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Shipping to a store is not shipping to a URL&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;This is the shift teams underrate. Store review, staged rollout and users on months-old binaries change how you branch, feature-flag and hotfix.&lt;/p&gt;

&lt;p&gt;The numbers are better than they were, but they're not a redeploy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Apple says &lt;a href="https://developer.apple.com/app-store/review/" rel="noopener noreferrer"&gt;90% of submissions are reviewed in under 24 hours&lt;/a&gt;. The other 10% is where your Friday incident lives.&lt;/li&gt;
&lt;li&gt;Google Play's &lt;a href="https://support.google.com/googleplay/android-developer/answer/6346149" rel="noopener noreferrer"&gt;staged rollouts&lt;/a&gt; let you halt a release, and the percentage never increases on its own; you go up, you don't go down.&lt;/li&gt;
&lt;li&gt;Apple's guideline 2.5.2 says apps &lt;a href="https://developer.apple.com/app-store/review/guidelines/" rel="noopener noreferrer"&gt;may not download or execute code&lt;/a&gt; that introduces or changes features. OTA JavaScript updates live inside the interpreted-code exception and the platforms' content rules; Expo's own docs are blunt that EAS Update handles &lt;a href="https://docs.expo.dev/eas-update/introduction/" rel="noopener noreferrer"&gt;non-native pieces such as JS, styling and images&lt;/a&gt; and anything native, any permission change, any SDK bump needs a new build.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Soapbox is what it looks like when none of this is set up. The app arrived with no cloud-based JS update system, no automated code review or deployment, no separation between development, staging and production, frequent push conflicts, and manual Play Store updates required from users.&lt;/p&gt;

&lt;p&gt;The fix was environment stratification via GitLab and CodePush, ESLint, Datadog and full-cycle testing, followed by more than 15 successful releases. Fifteen is a count, not an improvement measure, but it's fifteen more than a team with no pipeline ships. Mode's case page lists 23.&lt;/p&gt;

&lt;p&gt;Two more things that shape release engineering, both easy to miss at scoping:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Your OTA vendor is a dependency.&lt;/strong&gt; Microsoft &lt;a href="https://github.com/microsoft/code-push-server/blob/main/README.md" rel="noopener noreferrer"&gt;retired App Center and CodePush on March 31, 2025&lt;/a&gt; and published a standalone server to self-host. Adopt CodePush in 2023, inherit a migration in 2025. In the 2025 State of React Native survey, &lt;a href="https://results.stateofreactnative.com/en-US/build-and-publish/" rel="noopener noreferrer"&gt;EAS Build is at 68.2%&lt;/a&gt; and GitHub Actions at 61.9%; the ecosystem consolidated, and that's a bet on someone else's roadmap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The web side doesn't escape platform-mandated rewrites.&lt;/strong&gt; Ctrl's extension had to migrate from Manifest V2 to V3. Chrome disabled MV2 for &lt;a href="https://developer.chrome.com/docs/extensions/develop/migrate/mv2-deprecation-timeline" rel="noopener noreferrer"&gt;all users on all channels&lt;/a&gt; by March 2025, removed the re-enable switch with Chrome 138, and deletes the remaining MV2 extensions from the Web Store on August 31, 2026. An extension is a store-distributed client with its own review; teams that file it under "web" get surprised.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Set up environments and OTA before the first store submission, not after the first incident. After the first incident you'll do it anyway, under worse conditions.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;What the move actually costs&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Stated plainly, going web-to-mobile buys you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Two more runtimes with their own crash signatures.&lt;/li&gt;
&lt;li&gt;Two more release channels, each with review and rollout mechanics.&lt;/li&gt;
&lt;li&gt;A device and OS-version test matrix that grows every September.&lt;/li&gt;
&lt;li&gt;Permanent version skew, so every API change is a compatibility decision.&lt;/li&gt;
&lt;li&gt;A native-dependency surface (files, PDF, camera, biometrics) you upgrade by hand.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that is a reason not to do it. Moneygate's five-person team and Ctrl's twelve-person dev and QA team show the headcount range between parity and net-new surface. Ctrl reports a user base expansion of 5–7% and a revenue increase of 10–25% as ranges on its case page, but that engagement also covered the extension redesign, the MV3 migration and backend work, so none of it isolates the mobile app.&lt;/p&gt;

&lt;p&gt;The counter-case: if the mobile use case is read-heavy and occasional, a responsive web app plus one narrow native capability (push, or a biometric-gated approval companion) is often the better trade. Apple's guideline 4.2 rejects apps that don't rise above a repackaged website anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Sequencing it&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;If I were scoping this again tomorrow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pick a parity target and write it down in flows, not features.&lt;/li&gt;
&lt;li&gt;Fix the read contract first: aggregate server-side, paginate everything, version deliberately, and plan to keep old versions alive for a year.&lt;/li&gt;
&lt;li&gt;Decide the auth and custody model before the first screen. Biometrics unlock a local key; the server never trusts them.&lt;/li&gt;
&lt;li&gt;Budget the platform plumbing (files, PDFs, camera, permissions) as features with owners.&lt;/li&gt;
&lt;li&gt;Set up dev/staging/prod separation and OTA delivery before the first store submission.&lt;/li&gt;
&lt;li&gt;Share tokens, components and schemas. Fork the logic and stop feeling bad about it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The uncomfortable part is the last one. Once the app is live, the web product has a permanent second client that can't be hotfixed, running on binaries you shipped months ago, against an API you can no longer change on a whim. That constraint outlives the launch, and it's the real thing you signed up for in that scoping meeting.&lt;/p&gt;

</description>
      <category>mobile</category>
      <category>reactnative</category>
      <category>fintech</category>
      <category>architecture</category>
    </item>
    <item>
      <title>How to Design an Offline-First Web App with IndexedDB and Conflict Resolution</title>
      <dc:creator>Vic Tekys</dc:creator>
      <pubDate>Tue, 20 Oct 2026 09:16:00 +0000</pubDate>
      <link>https://dev.to/devico-solutions/how-to-design-an-offline-first-web-app-with-indexeddb-and-conflict-resolution-4l85</link>
      <guid>https://dev.to/devico-solutions/how-to-design-an-offline-first-web-app-with-indexeddb-and-conflict-resolution-4l85</guid>
      <description>&lt;p&gt;An inspector finishes a site audit from a van with no signal. The form validates, the app says "saved," she drives off. Two hours later the device reconnects, the queue flushes, and one of two things happens: a colleague's older edit to the same record — synced first, because his van hit coverage earlier — silently overwrites hers, or the server rejects the audit on a rule the form never checked, and she finds out a week later, when the context is gone.&lt;/p&gt;

&lt;p&gt;Both failures come from the same misunderstanding. The moment your client accepts a write without the server, it stops being a cache. It is a replica with its own history, and the design problem is what happens when two histories meet.&lt;/p&gt;

&lt;p&gt;So my thesis: offline-first is a data-modeling decision, not a caching layer. The change log, the replay contract and the merge rule are the work. IndexedDB — the part most tutorials spend all their time on — is the easy part.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Decide what "offline" means per screen, not per app&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The most expensive sentence in an offline project is "the app has to work offline." All of it? Every screen I've built lands in one of three classes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Read-only offline.&lt;/strong&gt; Catalogues, price lists, 3D models, reference data. A service worker and a cache solve this — nobody writes, so nothing conflicts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write-offline.&lt;/strong&gt; Inspections, orders, audits, stock counts. The only class that needs a change log, replay and a merge rule.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Online-only.&lt;/strong&gt; Payments, identity changes, anything with an external side effect — an email, a printed label, a card charge. Making these offline creates a duplicate-side-effect problem you cannot undo.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Only the middle class costs real money, so scoping it down is the biggest cost lever you have.&lt;/p&gt;

&lt;p&gt;For scale, the industry mostly hasn't got past class one. The &lt;a href="https://almanac.httparchive.org/en/2025/pwa" rel="noopener noreferrer"&gt;2025 Web Almanac&lt;/a&gt; reports service workers on 20.5% of desktop sites, up roughly tenfold since 2022 — but only 12% of PWA sites' service workers even handle fetch. Most are installed for lifecycle events and install prompts, not offline behaviour of any kind.&lt;/p&gt;

&lt;p&gt;The example from our work is a &lt;a href="https://devico.io/success-stories/castrol" rel="noopener noreferrer"&gt;PWA for Castrol&lt;/a&gt; that authorizes and monitors the partner workshops selling its products, with both offline and online modes as a hard requirement. Authorizing a workshop is an audit workflow — the user records findings in the field. That is a class-two write path by definition, and it dictated the whole architecture below.&lt;/p&gt;

&lt;p&gt;The limitation: the boundary moves. A read-only price list is class one until someone edits a quantity on it. Re-classify on every feature, not once at kickoff.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Store intent, not state&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The hack persists final state: the user edits an inspection, you write the new object to IndexedDB, and on reconnect you PUT it. A snapshot cannot be merged — the server sees a whole object and can't tell which fields were touched, from what version, or in what order.&lt;/p&gt;

&lt;p&gt;The architecture persists intent: an append-only log of mutations, with local state derived from replaying it.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;What belongs in the change log&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Each entry carries enough to be replayed, rebased, partially rejected, and shown to a user:&lt;/p&gt;

&lt;p&gt;interface ChangeLogEntry {&lt;/p&gt;

&lt;p&gt;opId: string;&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; // client-generated UUID — the idempotency key&lt;/p&gt;

&lt;p&gt;seq: number; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; // local monotonic sequence, for ordering&lt;/p&gt;

&lt;p&gt;entity: 'inspection' | 'workshop' | 'finding';&lt;/p&gt;

&lt;p&gt;entityId: string;&amp;nbsp; &amp;nbsp; &amp;nbsp; // client-generated for creates, too&lt;/p&gt;

&lt;p&gt;op: 'create' | 'update' | 'delete';&lt;/p&gt;

&lt;p&gt;baseVersion: number | null;&amp;nbsp; // server version this was made against&lt;/p&gt;

&lt;p&gt;payload: Record;&amp;nbsp; // only the fields touched&lt;/p&gt;

&lt;p&gt;createdAt: number; &amp;nbsp; &amp;nbsp; // for display only — never for ordering&lt;/p&gt;

&lt;p&gt;status: 'pending' | 'sent' | 'acked' | 'rejected' | 'conflict';&lt;/p&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;Three fields do the heavy lifting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;opId lets the server dedupe a replayed operation.&lt;/li&gt;
&lt;li&gt;baseVersion lets the server detect that the record moved while the client was offline — the basis of conflict detection.&lt;/li&gt;
&lt;li&gt;payload holding only touched fields is what makes per-field merge possible later.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On Castrol, the architecture kept all data in sync with local IndexedDB on the frontend, with the ability to log changes and new entries so the system could be updated as soon as the user reconnected, plus a merge-conflict resolution mechanism on top. The stack was React and Redux, Dexie.js over IndexedDB, Koa on Node.js, and CosmosDB on Azure. The case study doesn't say why Dexie or CosmosDB were picked, so I won't invent a rationale — the log-plus-merge shape is the point.&lt;/p&gt;

&lt;p&gt;Two costs you sign up for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Logs grow. Once an entry is acknowledged, fold it into the local snapshot and drop it — but keep the snapshot's version.&lt;/li&gt;
&lt;li&gt;Replaying old entries against a schema that has since changed is a migration problem. The log format needs a version field too.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;IndexedDB is a database — treat it like one&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Most IndexedDB pain comes from treating it as a key-value bag. What I hold to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Versioned schemas and upgrade transactions.&lt;/strong&gt; Dexie's design doc is blunt: &lt;a href="https://dexie.org/docs/Tutorial/Design" rel="noopener noreferrer"&gt;a version with an upgrader attached must never be altered&lt;/a&gt; — further changes go in a new version declaration, and the upgrade function for version 3 transforms data as it existed in version 2.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real indexes for the queries you actually run.&lt;/strong&gt; Dexie stores any property but only queries indexed ones. statusand seq on the change log matter, because "pending ops in order" is the query on every reconnect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One transaction per unit of work.&lt;/strong&gt; A create that touches inspections and appends to changelog is one transaction, or you will eventually get a logged intent with no entity, or the reverse.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A wrapper over the raw event API.&lt;/strong&gt; Dexie auto-commits a transaction once you stop doing database work in it, and awaiting a non-Dexie promise inside one ends it — the failure everyone hits once.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;import Dexie, { type Table } from 'dexie';&lt;/p&gt;

&lt;p&gt;class FieldDB extends Dexie {&lt;/p&gt;

&lt;p&gt;inspections!: Table;&lt;/p&gt;

&lt;p&gt;changelog!: Table;&lt;/p&gt;

&lt;p&gt;constructor() {&lt;/p&gt;

&lt;p&gt;super('field');&lt;/p&gt;

&lt;p&gt;this.version(1).stores({&lt;/p&gt;

&lt;p&gt;inspections: 'id, workshopId, version',&lt;/p&gt;

&lt;p&gt;changelog: 'opId, seq, status, [status+seq]',&lt;/p&gt;

&lt;p&gt;});&lt;/p&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;export async function recordUpdate(db: FieldDB, id: string, patch: Partial) {&lt;/p&gt;

&lt;p&gt;return db.transaction('rw', db.inspections, db.changelog, async () =&amp;gt; {&lt;/p&gt;

&lt;p&gt;const current = await db.inspections.get(id);&lt;/p&gt;

&lt;p&gt;if (!current) throw new Error(&lt;code&gt;unknown inspection ${id}&lt;/code&gt;);&lt;/p&gt;

&lt;p&gt;await db.inspections.update(id, patch);&lt;/p&gt;

&lt;p&gt;await db.changelog.add({&lt;/p&gt;

&lt;p&gt;opId: crypto.randomUUID(),&lt;/p&gt;

&lt;p&gt;seq: await nextSeq(db),&lt;/p&gt;

&lt;p&gt;entity: 'inspection',&lt;/p&gt;

&lt;p&gt;entityId: id,&lt;/p&gt;

&lt;p&gt;op: 'update',&lt;/p&gt;

&lt;p&gt;baseVersion: current.version,&lt;/p&gt;

&lt;p&gt;payload: patch,&lt;/p&gt;

&lt;p&gt;createdAt: Date.now(),&lt;/p&gt;

&lt;p&gt;status: 'pending',&lt;/p&gt;

&lt;p&gt;});&lt;/p&gt;

&lt;p&gt;});&lt;/p&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;Two things to look at: the compound index [status+seq] is the reconnect query, and baseVersion is captured at write time, not sync time.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Quota, eviction and the cache you thought was durable&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Client storage is a budget, not a disk, and someone else sets the budget.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Per &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Storage_API/Storage_quotas_and_eviction_criteria" rel="noopener noreferrer"&gt;MDN's quota documentation&lt;/a&gt;, Chromium gives an origin up to 60% of total disk, Firefox's best-effort mode caps a site group at the smaller of 10% of disk or 10 GiB, and Safari on macOS 14 / iOS 17 allows roughly 60% of disk for browser apps but only about 15% for embedded web views.&lt;/li&gt;
&lt;li&gt;Under storage pressure, browsers evict best-effort origins least-recently-used first, and eviction is all-or-nothing: IndexedDB, Cache API and OPFS for that origin go together.&lt;/li&gt;
&lt;li&gt;Safari's tracking prevention deletes script-written storage from any origin the user hasn't touched in seven days of browser use — though &lt;a href="https://web.dev/articles/storage-for-the-web" rel="noopener noreferrer"&gt;web.dev notes&lt;/a&gt; the cap doesn't apply to installed PWAs.&lt;/li&gt;
&lt;li&gt;A committed transaction isn't necessarily on disk. IndexedDB's &lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/IDBDatabase/transaction" rel="noopener noreferrer"&gt;durability option&lt;/a&gt; treats "relaxed" data as committed once handed to the OS, while "strict" waits for the storage medium. For a change log, ask for strict.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two of our projects show storage discipline with large payloads. &lt;a href="https://devico.io/success-stories/vitus" rel="noopener noreferrer"&gt;Vitus&lt;/a&gt;, a Danish constructability platform, stores 3D models locally in IndexedDB as part of a package that also moved to SVF2, parallel loading, web workers and list virtualization; the team reports models 5x smaller and loading roughly 2.5x faster — for the bundle, not IndexedDB alone, and Vitus is not offline-first. On a &lt;a href="https://devico.io/success-stories/sales-platform-for-seamless-customer-interactions" rel="noopener noreferrer"&gt;fashion-tech sales platform&lt;/a&gt; for in-store kiosks, the previous app cached so much media locally it ran out of memory; the rebuild moved to a central media service with caching and preloading plus Workbox, and reports a 95% smaller local cache, memory crashes eliminated, and 40% faster startup. That's storage hygiene, not offline sync.&lt;/p&gt;

&lt;p&gt;The rule that follows: local writes are durable only until acknowledged, never the system of record. Losing the entire local store should lose at most the unsynced tail — and the user should be able to see how long that tail is.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Replay is a distributed-systems problem&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Reconnection is not "retry the failed POSTs." It is a batch write from a stale replica, with all the failure modes of one. The contract I use:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The client sends pending operations in seq order, each with its opId and baseVersion.&lt;/li&gt;
&lt;li&gt;The server processes each operation independently and returns a per-operation verdict: accepted (with the new version), rejected (with a reason), or conflict (with current server state).&lt;/li&gt;
&lt;li&gt;The client marks each entry by verdict and never discards the batch because one entry failed.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;type Verdict =&lt;/p&gt;

&lt;p&gt;| { opId: string; status: 'ok'; version: number }&lt;/p&gt;

&lt;p&gt;| { opId: string; status: 'rejected'; code: string; message: string }&lt;/p&gt;

&lt;p&gt;| { opId: string; status: 'conflict'; serverState: unknown; serverVersion: number };&lt;/p&gt;

&lt;p&gt;async function replay(db: FieldDB, api: SyncApi) {&lt;/p&gt;

&lt;p&gt;const pending = await db.changelog&lt;/p&gt;

&lt;p&gt;.where('[status+seq]').between(['pending', Dexie.minKey], ['pending', Dexie.maxKey])&lt;/p&gt;

&lt;p&gt;.toArray();&lt;/p&gt;

&lt;p&gt;if (!pending.length) return;&lt;/p&gt;

&lt;p&gt;const verdicts = await api.push(pending); &amp;nbsp; // same opIds every attempt&lt;/p&gt;

&lt;p&gt;for (const v of verdicts) {&lt;/p&gt;

&lt;p&gt;await db.changelog.update(v.opId, { status: v.status === 'ok' ? 'acked' : v.status });&lt;/p&gt;

&lt;p&gt;if (v.status === 'conflict') await parkForResolution(db, v);&lt;/p&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;The batch is re-sent with the same opIds on every attempt. That is what makes three scenarios survivable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Partial-batch failure.&lt;/strong&gt; Five of twelve operations applied before the connection dropped. With per-op verdicts, the client knows which seven to resend; without them, it resends all twelve and the server must dedupe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The tab that reconnects twice.&lt;/strong&gt; Two tabs, both detect online, both flush. Same opId, so the server applies each once. Take a navigator.locks lock around replay anyway, so they don't also fight over the local log.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two devices on one account.&lt;/strong&gt; Phone and laptop, both with pending edits to one record. That isn't a replay bug, it's a merge — the merge-rule section below.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Idempotency and the duplicate-record bug&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;The naive replay outcome is the duplicate: a retried create that produces two records. Stripe's design is the reference I copy: the server &lt;a href="https://docs.stripe.com/api/idempotent_requests" rel="noopener noreferrer"&gt;saves the status code and body of the first request&lt;/a&gt; for each idempotency key — success or failure — returns the same result on every replay, errors if the parameters differ, and prunes keys after at least 24 hours. Your sync endpoint needs exactly that table, keyed on opId.&lt;/p&gt;

&lt;p&gt;This bug class doesn't need an offline app to appear. &lt;a href="https://devico.io/success-stories/sway" rel="noopener noreferrer"&gt;Sway&lt;/a&gt;, a US logistics company running an online system, hit race conditions in trip creation where processing delays assigned parcels to the wrong trips; the fix was queue functionality that eliminated the unnecessary trip creation, with 20% less stored data reported as a result. Sway never had an offline client — I cite it because if concurrent creation misbehaves with milliseconds between requests, it will with hours of buffered writes arriving at once.&lt;/p&gt;

&lt;p&gt;The limitation of strict ordering: one poisoned operation — a create the server will never accept — blocks everything behind it, so you need to park an entry (status rejected, out of the replay path, shown to the user) and let the rest drain. And background delivery is uneven: the Background Sync API sits at &lt;a href="https://caniuse.com/background-sync" rel="noopener noreferrer"&gt;76.7% global support&lt;/a&gt; with no Firefox or Safari implementation, so on iOS replay only runs while the app is open.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Validation has to run twice&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;If the client only checks required fields and the server enforces the real rules, offline users fill forms that fail on replay — when they can no longer see the site, the workshop or the part number they were describing. The client must apply the server's rules at write time.&lt;/p&gt;

&lt;p&gt;The way to do that without maintaining two rule sets is one schema, two runtimes. On Castrol that was JSON Schema with the same validation applied on the client and the backend, using AJV on both sides.&lt;/p&gt;

&lt;p&gt;// shared/schemas/inspection.json is imported by both the React app and the Koa API&lt;/p&gt;

&lt;p&gt;import Ajv from 'ajv';&lt;/p&gt;

&lt;p&gt;import inspectionSchema from '../shared/schemas/inspection.json';&lt;/p&gt;

&lt;p&gt;const ajv = new Ajv({ allErrors: true });&lt;/p&gt;

&lt;p&gt;export const validateInspection = ajv.compile(inspectionSchema);&lt;/p&gt;

&lt;p&gt;// client, at write time — before anything hits the change log&lt;/p&gt;

&lt;p&gt;if (!validateInspection(draft)) {&lt;/p&gt;

&lt;p&gt;return showErrors(validateInspection.errors);&lt;/p&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;What travels and what doesn't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Travels:&lt;/strong&gt; types, ranges, required fields, enums, cross-field rules within one record.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stays server-side:&lt;/strong&gt; uniqueness, quotas, permissions — anything depending on state the client doesn't have.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So rejections still happen. Design the rejection UI — the parked entry, the reason, the "edit and resubmit" path — instead of pretending the shared schema made them impossible.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Auth when the token expires offline&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The access token expires at 09:00, the device is offline from 08:30 to 16:00, and the user is locked out of data already on the device. There is nothing to refresh against. Two decisions have to be made explicitly:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;What may an expired session still do locally?&lt;/strong&gt; My default: read everything cached, write to the change log, block replay until re-authentication. Offline work continues; nothing reaches the server under a dead session.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How are credentials held on the device?&lt;/strong&gt; Castrol's approach was to encrypt credentials on the fly using dynamically generated RSA keys, store the encrypted credentials in the user's JWT with the public key on the backend, and generate a fresh key pair on every token refresh, on top of OAuth2. The case study doesn't say why this design over alternatives, so treat it as one working shape, not a recommendation.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Whatever you hold locally, follow &lt;a href="https://www.rfc-editor.org/rfc/rfc9700.html" rel="noopener noreferrer"&gt;RFC 9700&lt;/a&gt;, the January 2025 OAuth 2.0 Security Best Current Practice: refresh tokens for public clients must be sender-constrained or rotated on every use.&lt;/p&gt;

&lt;p&gt;The honest limitation: any credential usable offline weakens revocation. A lost device stays useful until it reconnects and the server gets to say no. You can shorten the window — local PIN or biometrics, wipe the store after N days offline — but you cannot close it. Say so in the design review instead of presenting it as solved.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Choosing a merge rule you can defend&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The rule most teams start with is last-write-wins on a client timestamp. It is wrong for offline in a specific way: the device that was offline longest often carries the newest wall-clock time, because it was still being edited after the others synced. LWW then rewards the stalest replica. And the clocks aren't trustworthy anyway — Airship's analysis of its mobile event stream (from &lt;a href="https://www.airship.com/blog/devices-and-timestamps-seriously-though-wtf/" rel="noopener noreferrer"&gt;2016&lt;/a&gt;, so older data) found about 2% of devices with clocks three or more minutes off. A merge rule built on those clocks is arbitrary with extra steps.&lt;/p&gt;

&lt;p&gt;Ranked by cost, the options I'd actually consider:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Server-authoritative rejection.&lt;/strong&gt; If baseVersion doesn't match the current version, reject and return the server state. Cheap, correct, annoying — the user re-does the edit. The right default for anything money- or compliance-shaped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-field merge on disjoint fields.&lt;/strong&gt; The change log carries only touched fields, so if two edits touched different fields, apply both; conflict only on the same field. This resolves most real conflicts on form-shaped data for very little code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Domain-specific merge.&lt;/strong&gt; Append-only collections merge trivially (findings on an inspection are a set, not a document). Counters travel as deltas, never absolutes — "+3 units" merges, "quantity = 12" doesn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CRDTs.&lt;/strong&gt; Real convergence without coordination, at real cost. Kleppmann and colleagues' &lt;a href="https://martin.kleppmann.com/papers/local-first.pdf" rel="noopener noreferrer"&gt;local-first paper&lt;/a&gt; (2019) is upfront that CRDTs store all history, that performance and memory became a problem in their own prototypes, and that the one change they cannot resolve is concurrent updates to the same property. The engineering has improved: Automerge 2.0 (2023) stores a &lt;a href="https://automerge.org/blog/automerge-2/" rel="noopener noreferrer"&gt;~260k-keystroke editing trace&lt;/a&gt; in 129 KB against 107 KB of plain text, Automerge 3.0 (July 2025) reports a &lt;a href="https://automerge.org/blog/automerge-3/" rel="noopener noreferrer"&gt;10x memory cut&lt;/a&gt;, and the &lt;a href="https://github.com/dmonad/crdt-benchmarks" rel="noopener noreferrer"&gt;crdt-benchmarks&lt;/a&gt; suite puts the same trace at 160 KB for Yjs with a 39 ms parse against 1.8 s for Automerge 2.1. Those are text-editing numbers; for a form with forty fields, the library is a dependency you carry for a problem you may not have.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;User-mediated resolution.&lt;/strong&gt; Show both versions, let the human choose. Most correct, most expensive — the UI has to exist for every conflicting entity type.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;LWW is defensible in exactly one case: single-user-per-record data, ordered by server-assigned timestamps on arrival. Then it's just "the server applies operations in the order it receives them," which is fine.&lt;/p&gt;

&lt;p&gt;The limitation that applies to every option, CRDTs included: convergence is about data, not business rules. Two replicas can converge perfectly on an order whose delivery date precedes its creation date. Invariants still get checked on the server after the merge, and you're back to the rejection UI.&lt;/p&gt;

&lt;p&gt;None of the case studies above published which merge rule they used — Castrol's mechanism is described only as merge-conflict resolution. The ranking is mine, from building and rebuilding this a few times.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;What offline-first costs&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;I'd rather you hear this from me than from your on-call rotation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The write path roughly doubles: local apply plus log, then server apply plus verdict.&lt;/li&gt;
&lt;li&gt;Schema migrations happen on two sides, and the client has to migrate a log written by an older version of itself.&lt;/li&gt;
&lt;li&gt;Every bug becomes a distributed one. Reproducing it means reconstructing a device's log, in order, against the server state it saw. Ship an "export my change log" button on day one.&lt;/li&gt;
&lt;li&gt;Storage is a budget you don't control, and eviction is silent.&lt;/li&gt;
&lt;li&gt;Revocation has a hole the size of the offline window.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your users don't have real intermittent connectivity — a warehouse with one dead aisle, a clinic basement, a van on a rural route — you don't need this. An optimistic UI with a retry queue covers flaky office Wi-Fi at a tenth of the work.&lt;/p&gt;

&lt;p&gt;If they do, this is the order of effort:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Scope offline per screen, and fight every flow out of the write-offline class that you can.&lt;/li&gt;
&lt;li&gt;Log intent, not state, with client-generated IDs and base versions.&lt;/li&gt;
&lt;li&gt;Make replay idempotent with per-operation verdicts, and give a poisoned entry somewhere to go.&lt;/li&gt;
&lt;li&gt;Share validation as one schema in both runtimes, then design the rejection UI for what doesn't travel.&lt;/li&gt;
&lt;li&gt;Decide the merge rule per entity, write it down, and reach for CRDTs only where convergence without a server is a requirement rather than a preference.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And the unglamorous half: conflicts you don't design for still get resolved. Just arbitrarily, by whichever device reconnected last — and the user finds out weeks later, from a colleague, that their inspection isn't there.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>architecture</category>
      <category>pwa</category>
    </item>
    <item>
      <title>How to Scale a SaaS Platform 10 Without a Full Rewrite</title>
      <dc:creator>Vic Tekys</dc:creator>
      <pubDate>Tue, 13 Oct 2026 09:13:00 +0000</pubDate>
      <link>https://dev.to/devico-solutions/how-to-scale-a-saas-platform-10x-without-a-full-rewrite-464f</link>
      <guid>https://dev.to/devico-solutions/how-to-scale-a-saas-platform-10x-without-a-full-rewrite-464f</guid>
      <description>&lt;p&gt;A barcode scan took 3–5 seconds. One scan per package. Hundreds of thousands of packages.&lt;/p&gt;

&lt;p&gt;That was the state of &lt;a href="https://devico.io/success-stories/sway" rel="noopener noreferrer"&gt;Sway&lt;/a&gt;, a US logistics startup handling returns and deliveries for online retailers, when our team started on it. The company had processed more than $10 million worth of returns and was expanding city by city, and every package still cost an operator 3–5 seconds of waiting before it could move.&lt;/p&gt;

&lt;p&gt;Nobody called that a scaling problem. It looked like UX. But at 4 seconds a scan, 100,000 packages is 111 hours of a human standing still. That was the throughput ceiling, and it had nothing to do with the architecture.&lt;/p&gt;

&lt;p&gt;The reflex when volume grows an order of magnitude is to reach for a rewrite, a sharding plan, or a bigger instance class. All three would have missed the scan. And the first one has a documented failure rate: in a &lt;a href="https://vfunction.com/blog/survey-why-application-modernization-projects-fail/" rel="noopener noreferrer"&gt;2022 Wakefield Research survey&lt;/a&gt; of 250 senior IT leaders at large enterprises, 79% of application modernization projects failed, at an average cost of $1.5 million and a 16-month timeline. That's the thesis of this piece: &lt;strong&gt;10× volume is rarely absorbed by new architecture. It's absorbed by finding the two or three places where you pay a cost per unit of work — a scan, a query, a stored row, a message — cutting each, and then redistributing what's left across roles, services, and teams.&lt;/strong&gt;The architecture usually survives. The hot paths don't.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Define the unit before you name the bottleneck&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;"We need to scale 10×" is not a requirement until you say 10× &lt;em&gt;of what&lt;/em&gt;. Every platform I've worked on had one or two units that everything else hangs off, and the unit is where the per-unit cost lives.&lt;/p&gt;

&lt;p&gt;Across the projects I'll use as evidence here, the unit was different every time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sway: the package. Every package gets scanned, routed, stored, and reported on.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://devico.io/success-stories/cydef" rel="noopener noreferrer"&gt;CYDEF&lt;/a&gt;, a Canadian cybersecurity company whose analysts review 100% of endpoint activity: the telemetry message, arriving at hundreds of gigabytes per day.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://devico.io/success-stories/medneo" rel="noopener noreferrer"&gt;medneo&lt;/a&gt;, a radiology-as-a-service business running MRI and CT centers across Germany and the UK: the patient, multiplied by a feature set that never stopped growing.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://devico.io/success-stories/mint" rel="noopener noreferrer"&gt;MINT&lt;/a&gt;, an Italian AdTech platform managing media investment across 16 channels and 350+ vendor integrations: the release.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before you touch code, put a price on the unit. For Sway that price was 3–5 seconds of scan latency, plus whatever a package cost in query time and stored bytes. For CYDEF it was the cost of ingesting, storing, and querying one message. You can't optimize a number you haven't measured.&lt;/p&gt;

&lt;p&gt;Why a 3-second delay counts as a scaling cost and not a cosmetic one: Jakob Nielsen's &lt;a href="https://www.nngroup.com/articles/response-times-3-important-limits/" rel="noopener noreferrer"&gt;response-time research&lt;/a&gt; puts 1 second as the limit for a user's flow of thought to stay uninterrupted and 10 seconds as the limit for keeping their attention on the task at all. A 3–5 second scan sits past the first limit on every single package, which means the operator context-switches hundreds of times per shift. Getting Sway's scan to 1–2 seconds moved it back under the flow-of-thought threshold. That's a throughput change, not a polish change.&lt;/p&gt;

&lt;p&gt;The trap is multi-tenant platforms with several units at once — tenants, users, records, API calls. Pick the wrong one and you'll spend a quarter making the wrong path fast. If revenue and the ops team's pain both point at the same unit, that's your unit.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Profile the hot path, then change architecture around it&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Sequencing matters more than technique. Profiling tells you which architectural change is worth making. Guessing produces re-architecture that doesn't move the number.&lt;/p&gt;

&lt;p&gt;On CYDEF, the platform had serious production performance issues and slow database request processing. The path we took was not "microservices" or "move to a new database." It was: run profilers against the running system, find the actual weak points, then make architectural modifications around what the profiler said — and build a dedicated memory-leak tracking service with alerting and process blocking, because leaks were one of the things the profiler kept surfacing.&lt;/p&gt;

&lt;p&gt;The mental model I use is Brendan Gregg's &lt;a href="https://www.brendangregg.com/usemethod.html" rel="noopener noreferrer"&gt;USE method&lt;/a&gt;: for every resource, check utilization, saturation, and errors. Gregg's own claim is that the checklist finds roughly 80% of server issues with a small fraction of the effort, and saturation — work queued because the resource can't service it — is the one that usually exposes a per-unit cost.&lt;/p&gt;

&lt;p&gt;If your SaaS runs on PostgreSQL you're in the majority: in the &lt;a href="https://survey.stackoverflow.co/2025/technology" rel="noopener noreferrer"&gt;2025 Stack Overflow Developer Survey&lt;/a&gt;, 58.2% of professional developers reported working with PostgreSQL in the past year, against 39.6% for MySQL. For a PostgreSQL-backed SaaS, the cheapest first profiler is pg_stat_statements, which the &lt;a href="https://www.postgresql.org/docs/current/pgstatstatements.html" rel="noopener noreferrer"&gt;PostgreSQL docs&lt;/a&gt; describe as tracking planning and execution statistics of all SQL statements executed by a server. It needs shared_preload_libraries = 'pg_stat_statements' and a restart, and then the question "what is our per-unit cost?" becomes one query:&lt;/p&gt;

&lt;p&gt;SELECT&lt;/p&gt;

&lt;p&gt;calls,&lt;/p&gt;

&lt;p&gt;round(total_exec_time::numeric, 0)&amp;nbsp; AS total_ms,&lt;/p&gt;

&lt;p&gt;round(mean_exec_time::numeric, 2) &amp;nbsp; AS mean_ms,&lt;/p&gt;

&lt;p&gt;rows,&lt;/p&gt;

&lt;p&gt;left(query, 80) &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; AS query&lt;/p&gt;

&lt;p&gt;FROM pg_stat_statements&lt;/p&gt;

&lt;p&gt;ORDER BY total_exec_time DESC&lt;/p&gt;

&lt;p&gt;LIMIT 15;&lt;/p&gt;

&lt;p&gt;Sort by total_exec_time, not mean_exec_time. A 2 ms query called 40 million times a day costs more than a 4-second report run hourly, and it's the one that grows linearly with your unit.&lt;/p&gt;

&lt;p&gt;The limitation: synthetic load lies. Lock contention, the race condition Sway had in trip creation, a cache that thrashes at one specific working-set size — these appear only at real concurrency. Profile production, or a replay of it, before you trust the plan.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The data layer carries most of the 10×&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;If I had to bet where a growth-stage SaaS is paying its per-unit cost, I'd bet on the database, and I'd usually win. Most "we need to scale" is three things: unindexed reads, repeated aggregation, and data you chose to keep.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Indexes and materialized views vs. more instances&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Sway is the primary evidence. The codebase had query inefficiencies and missing indexes, excessive data in API requests, redundant entries created during parcel creation, and routing errors under high load. None of that is fixed by a bigger RDS instance: a bigger instance speeds an unindexed scan in proportion to the hardware, while an index speeds it in proportion to the table size.&lt;/p&gt;

&lt;p&gt;The "just buy more instances" reflex also has a bill attached that most engineering teams never see. In Flexera's &lt;a href="https://www.flexera.com/about-us/press-center/new-flexera-report-finds-84-percent-of-organizations-struggle-to-manage-cloud-spend" rel="noopener noreferrer"&gt;2025 State of the Cloud report&lt;/a&gt;, 84% of the 750+ respondents named managing cloud spend as their top cloud challenge, and organizations reported cloud budgets already exceeded by 17%. Scaling by instance class is a linear cost curve; an index is a one-off.&lt;/p&gt;

&lt;p&gt;What we shipped on the data side of Sway:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;PostgreSQL indexes on the columns the hot queries actually filtered and joined on.&lt;/li&gt;
&lt;li&gt;Materialized views for the aggregations that reports and dashboards recomputed on every request.&lt;/li&gt;
&lt;li&gt;Query rewriting to stop pulling columns and rows the API never used.&lt;/li&gt;
&lt;li&gt;Parcel-creation refactoring that removed redundant writes, which the Sway case study reports reduced database load by at least 5%.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The Sway case study reports query speed up by at least 40% from the PostgreSQL optimization. Treat that as a floor, not a mean — it's the client's published figure, and it wasn't measured per query.&lt;/p&gt;

&lt;p&gt;The materialized view pattern is worth spelling out, because it's the one people get wrong. Precompute the aggregate, index it, and refresh it on a schedule:&lt;/p&gt;

&lt;p&gt;CREATE MATERIALIZED VIEW daily_parcel_stats AS&lt;/p&gt;

&lt;p&gt;SELECT&lt;/p&gt;

&lt;p&gt;date_trunc('day', created_at) AS day,&lt;/p&gt;

&lt;p&gt;hub_id,&lt;/p&gt;

&lt;p&gt;status,&lt;/p&gt;

&lt;p&gt;count(*)&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; AS parcels&lt;/p&gt;

&lt;p&gt;FROM parcels&lt;/p&gt;

&lt;p&gt;GROUP BY 1, 2, 3;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;- CONCURRENTLY requires a unique index covering all rows&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;CREATE UNIQUE INDEX ON daily_parcel_stats (day, hub_id, status);&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;- On a schedule (cron, pg_cron, a Lambda):&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;REFRESH MATERIALIZED VIEW CONCURRENTLY daily_parcel_stats;&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://www.postgresql.org/docs/current/sql-refreshmaterializedview.html" rel="noopener noreferrer"&gt;PostgreSQL docs&lt;/a&gt; state that CONCURRENTLY refreshes without locking out concurrent selects but is only allowed with at least one unique index that uses only column names and includes all rows — and that even with it, only one REFRESH at a time may run against a given view.&lt;/p&gt;

&lt;p&gt;That last sentence is the trade-off. Materialized views trade freshness for speed, and the refresh cost doesn't disappear — it moves. If the refresh takes longer than its schedule interval, you have relocated the bottleneck, not removed it. Size the refresh against the growth of the underlying table, not against today's row count.&lt;/p&gt;

&lt;p&gt;CYDEF ran the same play on a different stack: database requests were optimized through indexation, normalization and denormalization where each was warranted, and improved caching. Same three levers, .NET and PostgreSQL instead of Node.js and PostgreSQL.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Store less: queues, retention, and the cost of keeping everything&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;The second data-layer lever is the one teams skip because it feels like deleting value: stop storing everything.&lt;/p&gt;

&lt;p&gt;On Sway, introducing queue functionality — moving transient work through SQS instead of persisting every intermediate state — reduced stored data by 20% and cut storage costs, per the case study. Twenty percent less data is 20% less to index, back up, vacuum, and scan.&lt;/p&gt;

&lt;p&gt;The related move was getting analytics off the transactional database entirely. Reporting on Sway runs through a Node.js pipeline into Google Cloud Storage, then Snowflake, then Sigma; the operational PostgreSQL never sees a dashboard query. Reporting is the workload that grows fastest with volume, because every new customer wants a new cut of the data.&lt;/p&gt;

&lt;p&gt;A checklist I now run on any table over a few hundred million rows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which columns does nothing read after 30 days? Those are candidates for a cold store.&lt;/li&gt;
&lt;li&gt;Which rows are intermediate states that only exist because a workflow persisted every step? Those belong in a queue.&lt;/li&gt;
&lt;li&gt;Which aggregates get recomputed on read? Those become materialized views or a warehouse table.&lt;/li&gt;
&lt;li&gt;Which retention policy is "forever" because nobody wrote one? Write one.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Get the firehose off the request path&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Continuous high-volume input is a different problem from a slow query, and it needs a different answer: a transport built for throughput, plus cold storage for history, rather than a bigger API tier.&lt;/p&gt;

&lt;p&gt;CYDEF's telemetry was the firehose — hundreds of gigabytes of endpoint data daily, all of which had to be processed and kept, because the product's promise is that experts review 100% of endpoint activity. Putting that through a request/response API and into the transactional database was the per-unit cost that would have scaled linearly with every new endpoint.&lt;/p&gt;

&lt;p&gt;The changes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Ingestion moved to Azure IoT Hub, which the case study reports receiving, processing, and storing millions of messages. For scale, the &lt;a href="https://azure.microsoft.com/en-us/pricing/details/iot-hub/" rel="noopener noreferrer"&gt;IoT Hub pricing tiers&lt;/a&gt; are quoted per unit per day: 400,000 messages on S1, 6 million on S2, 300 million on S3 — the throughput is a provisioning decision, not a code change.&lt;/li&gt;
&lt;li&gt;The threat-processing workflow was completely redefined so that workflow history lives in the database and AWS Blob Storage. The original workflow had no way to step back to a previous stage; long-running investigations needed that, and keeping history in cheap storage rather than in the hot tables is what made it affordable.&lt;/li&gt;
&lt;li&gt;An internal device-monitoring tool replaced the manual, labor-intensive process of watching devices and resolving issues.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The pattern generalizes past IoT: any continuous input — events, webhooks, logs — should land in a partitioned transport first and be consumed at the rate the system can afford, not the rate producers send it.&lt;/p&gt;

&lt;p&gt;The limitation: managed ingestion moves cost to storage and egress, and gives your architecture a vendor shape — IoT Hub semantics, Blob Storage tiers, region constraints — you'll live with for years. On CYDEF the trade was correct because the alternative was linear growth of a database that was already slow.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Split the work, not just the code&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The lever engineers underrate is the non-code one: throughput also rises when the same work is divided across roles, environments, and teams.&lt;/p&gt;

&lt;p&gt;Sway's drivers and operators were doing manual tasks with no mobile app, and the two roles were working through the same interfaces. We built React Native apps that split driver logic from operator logic into separate applications. The case study reports delivery speed and efficiency up 50% and package processing roughly 2× faster. I won't attribute those numbers to the app alone — the same period included the scanner and API optimization, the indexes, an admin audit page for routing errors, AWS Location Service and Google Maps, and Zebra printer and carrier API (FedEx, ClearJet) integrations. The case study lists changes and outcomes without mapping one to the other, so neither will I.&lt;/p&gt;

&lt;p&gt;What I will say: splitting the work by role removed contention no query optimization could touch — two roles no longer waited on each other's screens.&lt;/p&gt;

&lt;p&gt;MINT is the team-level version. The goal was to scale development while keeping QA discipline, and the shortcoming was process: getting a feature approved for production was slow because the business kept changing requirements mid-release. The fix was structural:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Development split into independent teams, each with its own sandbox.&lt;/li&gt;
&lt;li&gt;A dedicated UAT environment, so business sign-off stopped blocking the pipeline.&lt;/li&gt;
&lt;li&gt;At least one QA professional embedded in every team.&lt;/li&gt;
&lt;li&gt;Daily rallies, feature demos, and retrospectives to keep 14 teams aligned.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result, per the case study, is 14 active development teams shipping several releases per day, on a 40-engineer team of developers, QA, and BAs.&lt;/p&gt;

&lt;p&gt;For context on what "several releases per day" means: in the &lt;a href="https://dora.dev/research/2024/dora-report/" rel="noopener noreferrer"&gt;2024 DORA report&lt;/a&gt;, as summarized by &lt;a href="https://octopus.com/blog/2024-devops-performance-clusters" rel="noopener noreferrer"&gt;Octopus Deploy&lt;/a&gt;, the elite cluster — under 20% of respondents — deploys on demand with a 5% change failure rate and recovers from a failed deployment in under an hour, while the low cluster deploys monthly to biannually with a 40% change failure rate and takes a week to a month to recover. The gap between the two is 182× in deployment frequency. Team structure is what moves an organization between those clusters; database indexes don't.&lt;/p&gt;

&lt;p&gt;Be precise about what this buys. Team splitting raises release throughput, not runtime throughput — MINT's platform doesn't handle more ad spend per second because there are 14 teams; it ships faster. And the rallies and demos aren't free; they're the price of independence.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Keeping the ceiling from coming back&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Every optimization above is a regression waiting to happen: a column added to the hot query, a new endpoint that skips the index, a workflow step persisted that was meant to be transient. The per-unit cost creeps back at exactly the rate you stop measuring it. The only durable defense is making regression detection cheaper than regression.&lt;/p&gt;

&lt;p&gt;The price of not doing that is measurable. In the Uptime Institute's &lt;a href="https://www.datacenterdynamics.com/en/news/uptime-institute-outages-in-2024-less-frequent-and-severe-but-more-expensive/" rel="noopener noreferrer"&gt;2024 Annual Outage Analysis&lt;/a&gt;, 54% of respondents said their most recent significant outage cost more than $100,000 and 20% said more than $1 million, and IT and networking issues — change management and misconfiguration, mostly — made up 53% of all reported causes. A performance regression that ships under load is exactly that category of outage.&lt;/p&gt;

&lt;p&gt;On CYDEF, that meant automated regression suites integrated into the CI/CD pipeline, parallel execution on cloud-based test environments, AWS virtual machines for dynamic test data, and CI/CD extended to components that previously had none. The case study reports over 1,500 test cases, over 700 automated tests, and 98% test coverage of the desktop agents. Note the scope: 98% is the desktop agents, not the platform.&lt;/p&gt;

&lt;p&gt;On medneo, the RIS had been degrading under patient growth while every new feature introduced regressions. The work was refactoring and removing unnecessary database calls, upgrading legacy frameworks and libraries, and a CI/CD pipeline specifically to reduce the impact of feature rollouts. The published results are qualitative — significantly reduced load times, fewer outages, headroom to scale — and I'd rather say that than invent a percentage. Eight engineers, 2019 to 2022.&lt;/p&gt;

&lt;p&gt;Coverage numbers describe effort, not defect escape rate; 700 automated tests that never exercise the hot path won't catch a dropped index. Put a performance assertion in CI for each unit cost you optimized — scan latency, p95 of the top five pg_stat_statements queries, storage growth per unit — and fail the build when it moves.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;When incremental scaling stops being honest&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Everything above is an argument against the rewrite reflex. It's also an argument against its mirror image: the belief that tuning is infinite.&lt;/p&gt;

&lt;p&gt;Incremental scaling ends in two places I've seen:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Where the infrastructure is manual, undocumented, and per-client.&lt;/strong&gt; CYDEF's deployment for each new client was time-consuming, hand-built, and undocumented. No index fixes that. What fixed it was automating the majority of infrastructure creation and writing the deployment documentation — structural work, not tuning.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Where ingestion can't be batched.&lt;/strong&gt; If every unit needs a synchronous answer and the units arrive faster than the synchronous path can serve them, you're out of per-unit tricks and into a topology change.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The diagnostic is simple to state and hard to fake. After you've optimized, plot cost per unit against volume:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Flat&lt;/strong&gt; means you can keep growing on the platform you have. Sway's stack — React and Next.js on the front, Node.js and Serverless on the back, PostgreSQL alongside DynamoDB and Aurora, GraphQL, AWS — is the same stack it started with. It absorbed a 10× increase in package volume, per the case study, and a seven-engineer team has been running it since 2020.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rising&lt;/strong&gt; after optimization means the ceiling is structural. That is the only case where a rewrite is the cheaper answer, and it's the case you should be able to prove with the same per-unit measurement you started with — before you join the 79% from the Wakefield survey who started one without it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One note on timelines: none of this was fast. Sway and CYDEF have run since 2020, MINT since 2019, medneo took three years. Absorbing 10× on your existing stack is cheaper than a rewrite. It is not quick.&lt;/p&gt;

&lt;p&gt;Measure the unit. Cut its cost where you're paying it. Split the work. Then check the curve — and only rewrite if it's still rising.&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>architecture</category>
      <category>performance</category>
      <category>saas</category>
    </item>
    <item>
      <title>How to Build White-Label SaaS Without Creating a Code Fork for Every Customer</title>
      <dc:creator>Vic Tekys</dc:creator>
      <pubDate>Tue, 06 Oct 2026 09:28:00 +0000</pubDate>
      <link>https://dev.to/devico-solutions/how-to-build-white-label-saas-without-creating-a-code-fork-for-every-customer-ia6</link>
      <guid>https://dev.to/devico-solutions/how-to-build-white-label-saas-without-creating-a-code-fork-for-every-customer-ia6</guid>
      <description>&lt;p&gt;I still remember the pull request that created release/acme-corp. One enterprise customer needed a different label format on their shipping documents. The deadline was tight, the config system didn't cover it, and branching felt like the pragmatic move. Everyone agreed it was temporary.&lt;/p&gt;

&lt;p&gt;Eleven months later we were fixing the same date-parsing bug twice, in two codebases, with two QA passes — and the second fix shipped three weeks after the first because nobody remembered the fork existed until Acme filed the ticket.&lt;/p&gt;

&lt;p&gt;Here's the position I'll argue: a fork is a &lt;em&gt;code&lt;/em&gt; decision, not a deployment decision. Teams fork because they never sat down and named which layers actually vary per customer. Name them — branding, data model, workflow, integrations, infrastructure — give each one an explicit extension mechanism, and per-customer work collapses into configuration against one codebase. Even when a customer demands their own servers.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;What "white-label" means to the customer&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;"We need white-labeling" is one of the least precise sentences a prospect can say. In my experience it decodes into four different requests, at four wildly different costs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A logo.&lt;/strong&gt; Their brand on your product: colors, name, domain. Cheap if you built for it, a fork factory if you didn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A data boundary.&lt;/strong&gt; "Our data can't sit next to anyone else's." An isolation requirement, not a code requirement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Their workflow.&lt;/strong&gt; Approval steps, field validation, document formats that differ from your default. This is the expensive one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Their integrations.&lt;/strong&gt; "We use carrier X and SSO provider Y." Every enterprise deal comes with this rider.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Real customers ask for all four. On the &lt;a href="https://devico.io/success-stories/e2ee-mobile-messenger" rel="noopener noreferrer"&gt;E2EE mobile messenger&lt;/a&gt; we've worked on since 2020 — a Geneva-based product for privacy-focused enterprises, 6 people on dev and QA — the product ships customizable branding options to align with each client's unique identity. On the &lt;a href="https://devico.io/success-stories/carbon-management-solution" rel="noopener noreferrer"&gt;carbon management platform&lt;/a&gt; we build for a German client, the product has to integrate with the ESG, GRC, compliance, and supply-chain software its customers already run.&lt;/p&gt;

&lt;p&gt;Both of those are published demand, not published mechanism — the case pages tell you what customers ask for, not how to implement it. The implementation opinions below are mine.&lt;/p&gt;

&lt;p&gt;The point of the taxonomy: classify the ask &lt;em&gt;before&lt;/em&gt; estimating it. A logo request priced like a workflow request loses the deal; a workflow request priced like a logo request loses your margin, and eventually your architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Branding is the cheap layer — keep it that way&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Branding stays cheap under exactly one condition: it lives in runtime configuration, not build-time flags. The moment someone writes #ifdef ACME — or its JS equivalent, if (BUILD_TARGET === 'acme') — you've started a fork, just distributed across the codebase instead of across branches.&lt;/p&gt;

&lt;p&gt;The mechanics that have worked for me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Resolve the tenant from the host header&lt;/strong&gt;, once, in middleware. acme.yourapp.com or app.acme.com maps to a tenant ID; everything downstream reads config, never hostnames.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ship theme as data&lt;/strong&gt;: design tokens, logo URLs, product name, email sender, legal footer — one JSON document per tenant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Always render a default.&lt;/strong&gt; Missing config must produce your stock brand, not a crash. Tenant onboarding will forget a field; that can't be a sev-2.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;// theme resolution: config, not conditionals&lt;/p&gt;

&lt;p&gt;async function resolveTheme(hostname: string): Promise {&lt;/p&gt;

&lt;p&gt;const tenant = await tenants.byHostname(hostname); // cached, obviously&lt;/p&gt;

&lt;p&gt;return { ...DEFAULT_THEME, ...(tenant?.theme ?? {}) };&lt;/p&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;The spread against DEFAULT_THEME is the load-bearing line. Every tenant override is partial; the default absorbs whatever onboarding missed.&lt;/p&gt;

&lt;p&gt;One warning from the trenches: branding stops being cheap the moment a customer asks to &lt;em&gt;move&lt;/em&gt; things — "can the sidebar go on the right," "we want approvals before the summary screen." That's not theming. That's a workflow request wearing a theming costume, and it needs to be priced and architected as one.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Embedded surfaces are a tenant boundary&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;There's a surface everyone forgets when listing what varies per customer: the part of your product that runs inside &lt;em&gt;someone else's&lt;/em&gt; website.&lt;/p&gt;

&lt;p&gt;We've been on &lt;a href="https://devico.io/success-stories/xola" rel="noopener noreferrer"&gt;Xola&lt;/a&gt;, a booking platform for tour and activity operators, since 2021 with 11 engineers across the USA and India. One of its core products is a checkout module — an app embedded into a reseller's website so that their clients can purchase tours and activities right on that website. And per-customer integrations there include Google Analytics, Facebook pixel, MailChimp, and Zoom.&lt;/p&gt;

&lt;p&gt;An embedded widget inherits everything about its host page:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the host's CSS resets and global styles,&lt;/li&gt;
&lt;li&gt;the host's Content-Security-Policy,&lt;/li&gt;
&lt;li&gt;the host's analytics and consent tooling,&lt;/li&gt;
&lt;li&gt;whatever ancient jQuery the host still loads.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your widget's styling or script execution depends on assumptions about the surrounding page, every new reseller is a new integration project — and integration projects that touch core code are how forks get born. Isolate the surface instead: an iframe when you can afford the postMessage tax, shadow DOM when you need to live in the host's document. (That's my reasoning, to be clear — the Xola case study documents the embedded checkout and the integrations, not the isolation technique.)&lt;/p&gt;

&lt;p&gt;Treat the embed like a public API with a version, not like a page you own. The reseller's site is production you don't control.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Integrations are where forks are born&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;If I had to name the single largest source of per-customer branches across projects I've seen, it's this sentence: "Customer X uses carrier Y."&lt;/p&gt;

&lt;p&gt;The naive implementation adds if (tenant === 'x') { fedexClient.ship(...) } somewhere in the order flow. The second carrier adds an else if. The fifth carrier — with its slightly different retry semantics and label format — is when someone proposes "a dedicated build for the logistics-heavy customers," and now you're forking.&lt;/p&gt;

&lt;p&gt;The scale of the problem is real. &lt;a href="https://devico.io/success-stories/sway" rel="noopener noreferrer"&gt;Sway&lt;/a&gt;, a US logistics company (formerly Returnmates) where a dedicated team of 7 Devico engineers has worked since 2020, serves more than 250 brands — Rent The Runway among them — through one unified tracking system spanning FedEx, ClearJet, Million, T-Force, SmartKargo, and UDS, plus Stripe for payments, Google Maps and AWS Location Service for geo, and Zebra ZPL label generation. Six carriers, 250+ brands, one system. (The case study lists the integrations; the adapter design below is my prescription, not a description of Sway's internals.)&lt;/p&gt;

&lt;p&gt;The pattern that keeps this from forking is old and boring, which is why it works:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Define one internal interface per vendor category — Carrier, PaymentProvider, SsoProvider.&lt;/li&gt;
&lt;li&gt;Implement each vendor as an adapter behind that interface.&lt;/li&gt;
&lt;li&gt;Select the adapter per tenant from config, at one composition point.&lt;/li&gt;
&lt;li&gt;Contract-test every adapter against the same suite, so "works for tenant X" is provable without tenant X.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;interface Carrier {&lt;/p&gt;

&lt;p&gt;createShipment(o: Order): Promise;&lt;/p&gt;

&lt;p&gt;track(id: string): Promise;&lt;/p&gt;

&lt;p&gt;renderLabel(s: Shipment): Promise; // ZPL, PDF, whatever&lt;/p&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;const carriers: Record = {&lt;/p&gt;

&lt;p&gt;fedex: new FedExAdapter(),&lt;/p&gt;

&lt;p&gt;smartkargo: new SmartKargoAdapter(),&lt;/p&gt;

&lt;p&gt;};&lt;/p&gt;

&lt;p&gt;function carrierFor(tenant: TenantConfig): Carrier {&lt;/p&gt;

&lt;p&gt;return carriers[tenant.carrier] ?? carriers[DEFAULT_CARRIER];&lt;/p&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;The discipline is in what the interface &lt;em&gt;excludes&lt;/em&gt;. The moment Carrier grows a fedexSpecificOptions field, tenant knowledge has leaked into core code and the boundary is decorative. Vendor quirks live inside the adapter or they live everywhere.&lt;/p&gt;

&lt;p&gt;Budget honestly for this: each adapter is real work — auth, rate limits, sandbox environments, webhook shapes. But it's work that lands in an isolated, contract-tested module instead of in if statements sprayed through your order pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Per-tenant infrastructure, not per-tenant code&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Now the assumption I most want to kill: &lt;strong&gt;"separate infrastructure per customer means a separate build."&lt;/strong&gt; It doesn't. Isolation is an infrastructure-as-code concern, and IaC takes parameters.&lt;/p&gt;

&lt;p&gt;Compliance is the usual driver of these demands, and it's not exotic. &lt;a href="https://devico.io/success-stories/arklign" rel="noopener noreferrer"&gt;Arklign&lt;/a&gt;, a US dental technology platform where a 7-person Devico team has worked since 2016, runs lab fabrication, logistics, and data workflows across distributed dental practices in full HIPAA compliance — and regulated verticals like that are exactly where a customer's counsel eventually asks where, physically, the data lives. (That last inference is mine; the case study documents the compliance, not the question.)&lt;/p&gt;

&lt;p&gt;The carbon management platform I mentioned earlier is the cleanest example I've shipped. The client — Germany, working with 3 Devico full-stack devs since 2023 — required white-labeling with deployment on a separate host and superfast creation of distinct infrastructure per client, while moving from a basic app reliant on numerous third-party services to an independent solution with proprietary formulas and algorithms. The stack: TypeScript, React, NestJS, Prisma, PostgreSQL, and AWS Lambda, SNS, SQS, DynamoDB, and Cognito, defined with the AWS TypeScript CDK (plus SAM).&lt;/p&gt;

&lt;p&gt;The CDK is what makes "separate host" compatible with "one repo": infrastructure configurations deploy on whatever cluster fits the client's location. A tenant is a stack instantiation, not a branch:&lt;/p&gt;

&lt;p&gt;// one stack definition, N instantiations — the whole trick&lt;/p&gt;

&lt;p&gt;for (const tenant of tenants) {&lt;/p&gt;

&lt;p&gt;new PlatformStack(app, &lt;code&gt;platform-${tenant.id}&lt;/code&gt;, {&lt;/p&gt;

&lt;p&gt;env: { account: tenant.awsAccount, region: tenant.region },&lt;/p&gt;

&lt;p&gt;dataResidency: tenant.region, &amp;nbsp; &amp;nbsp; // eu-central-1 for the German clients&lt;/p&gt;

&lt;p&gt;cognitoPool: tenant.isolatedAuth, // per-tenant user pool if required&lt;/p&gt;

&lt;p&gt;});&lt;/p&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;Same application code, same serverless pipeline — on this platform, clients upload bulk CSV data that gets validated and processed through staged serverless services — but each customer gets their own host, their own data boundary, their own region. Residency, compliance, and noisy-neighbor requirements: satisfied. Fork: avoided. The client secured investment in the project's early stages; I'll claim no engineering metric beyond that, because none is published.&lt;/p&gt;

&lt;p&gt;Full disclosure on the cost, because this is where the model hurts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;N stacks means N migration runs, and migration N-3 &lt;em&gt;will&lt;/em&gt; fail differently than the others.&lt;/li&gt;
&lt;li&gt;N monitoring targets, N alert routes, N dashboards.&lt;/li&gt;
&lt;li&gt;N versions in the wild unless you enforce a deploy train — version drift between tenant stacks is the fork problem sneaking back in through the ops door.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Parameterized infrastructure is still a fleet. You've traded a code-merge problem for an ops problem. I take that trade every time, but it's a trade.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;When a fork is right&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;After all that: sometimes a fork is the correct call. I can defend exactly three cases.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The domain model diverges permanently.&lt;/strong&gt; Not a field or a workflow — the actual entities and their relationships stop being the same product. Config can't paper over a different domain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contractual isolation forbids shared code paths.&lt;/strong&gt; Some regulated deals require it in writing. You can't architect around a signature.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The account funds its own roadmap.&lt;/strong&gt; If one customer pays for a dedicated team and a divergent feature stream indefinitely, you don't have a customer — you have a second product. Staff it like one.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Even then, two conditions or you're just deferring the Acme story: the fork gets a &lt;strong&gt;named owner&lt;/strong&gt; whose job includes syncing shared fixes, and a &lt;strong&gt;sunset date&lt;/strong&gt; — a scheduled decision point to merge back, spin off, or kill. A fork without an owner and a date isn't a strategy. It's a slow leak with a branch name.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The cost of configurability&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Fair's fair: I've spent this whole article arguing against forks, so let me argue against my own medicine for a minute. Configuration isn't free — it just bills differently.&lt;/p&gt;

&lt;p&gt;Every switch you add multiplies the test matrix. Ten boolean tenant options is a theoretical 1,024 configurations; nobody tests that, and nobody should pretend to. Worse, tenant-specific bugs are miserable to repro: the bug lives in &lt;em&gt;one tenant's combination&lt;/em&gt; of theme, workflow config, and adapter selection, and your local environment has none of it.&lt;/p&gt;

&lt;p&gt;No vendor I know of publishes defect rates tied to configuration complexity, so I can't hand you a stat here — this is argued from scars, not data. What's kept the matrix survivable for me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Test the default tenant plus the two most divergent ones.&lt;/strong&gt; Not every tenant — the default and the extremes. The extremes are where the spread operators and fallbacks actually get exercised.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contract tests per adapter&lt;/strong&gt;, so every vendor implementation proves the same behavioral guarantees without a live tenant in the loop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Version tenant config as a reviewable artifact.&lt;/strong&gt; Config in a repo, changed by PR, deployed like code. A tenant's config diff should be as reviewable as a code diff, because it changes production behavior exactly like one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one matters more than it looks. If config lives in a database row edited by hand, you have all the risk of a fork with none of the git history.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Order of operations&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;If you're staring at your first "we need white-label" deal, this is the sequence I'd run:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Enumerate the variation layers for &lt;em&gt;this&lt;/em&gt; customer: branding, data boundary, workflow, integrations, infrastructure. Write them down; price them separately.&lt;/li&gt;
&lt;li&gt;Give branding runtime tokens with a default fallback — and refuse to let layout changes hide in the theming budget.&lt;/li&gt;
&lt;li&gt;Put every vendor behind one internal interface with per-tenant adapter selection, contract-tested.&lt;/li&gt;
&lt;li&gt;Parameterize infrastructure so isolation and residency are stack parameters, not repo branches.&lt;/li&gt;
&lt;li&gt;Only then, if the domain model itself is diverging, consider a fork — with a named owner and a sunset date attached before the branch is created.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Reach for this when you're a B2B SaaS with a growing roster of enterprise or reseller customers who each arrive with a logo, a compliance rider, and a favorite vendor. Skip the machinery when you have two customers and no divergence yet — speculative extension points rot as fast as forks do.&lt;/p&gt;

&lt;p&gt;And one operational habit, because config that nobody exercises is config that's silently broken: the tenant that breaks in production is always the one whose configuration nobody has rendered since onboarding. Boot every tenant's config in CI — default theme, adapter wiring, stack synth — even the quiet tenants. &lt;em&gt;Especially&lt;/em&gt; the quiet tenants.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>saas</category>
      <category>webdev</category>
      <category>devops</category>
    </item>
    <item>
      <title>How to Cut Regression Testing from Weeks to Days</title>
      <dc:creator>Vic Tekys</dc:creator>
      <pubDate>Tue, 29 Sep 2026 09:15:00 +0000</pubDate>
      <link>https://dev.to/devico-solutions/how-to-cut-regression-testing-from-weeks-to-days-48a0</link>
      <guid>https://dev.to/devico-solutions/how-to-cut-regression-testing-from-weeks-to-days-48a0</guid>
      <description>&lt;p&gt;Here's the fact that reframed how I think about regression speed. On an &lt;a href="https://devico.io/success-stories/e2ee-mobile-messenger" rel="noopener noreferrer"&gt;E2EE mobile messenger&lt;/a&gt; our team has worked on since 2020, the test suite grew from effectively nothing to over 700 automation scripts on iOS alone, with almost the same count on Android and up to 98% of test cases automated.&lt;/p&gt;

&lt;p&gt;The suite got dramatically bigger. And the regression cycle fell from about 20 days to up to 2.&lt;/p&gt;

&lt;p&gt;That combination breaks the usual explanation — "regression is slow because we have too many tests, so cut scope or automate harder." Test count went up sharply while duration collapsed by an order of magnitude. What changed wasn't how many tests existed. It was how they ran.&lt;/p&gt;

&lt;p&gt;Regression duration is dominated by serialization and waiting: environments that take days to get, single-threaded execution, manual re-verification across every browser and locale, and triage on failures nobody trusts. Attack those, and the suite can keep growing while the calendar shrinks.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Measure the cycle before optimizing it&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Before touching anything, time one full regression cycle end to end and split it into four buckets:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pure test execution&lt;/li&gt;
&lt;li&gt;Waiting for environments&lt;/li&gt;
&lt;li&gt;Triage of ambiguous failures&lt;/li&gt;
&lt;li&gt;Re-runs&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Most teams have never done this. They know the cycle is "three weeks" but not where the three weeks live. In my experience the middle two buckets — environment wait and triage — are where most of it sits.&lt;/p&gt;

&lt;p&gt;This matters because the fix depends on the diagnosis. A suite that executes in six hours but blocks for four days on a shared staging environment is an infrastructure problem. Making the tests 30% faster fixes nothing anyone will notice.&lt;/p&gt;

&lt;p&gt;Triage and re-runs deserve their own line item, because they compound. Every ambiguous failure means an engineer reproducing it by hand, and every "just re-run it" doubles the execution cost of whatever shard it lives in. A cycle with a 15% re-run rate is quietly carrying a sixth of its execution time as pure waste.&lt;/p&gt;

&lt;p&gt;We saw this shape clearly at &lt;a href="https://devico.io/success-stories/cydef" rel="noopener noreferrer"&gt;CYDEF&lt;/a&gt;, a Canadian cybersecurity company we've worked with since 2020. Infrastructure deployment for new clients took a lot of time and effort, and the process wasn't documented at all. No test report will ever show you that cost — it lives in Slack threads and calendar gaps, not in CI logs.&lt;/p&gt;

&lt;p&gt;Don't wait for proper instrumentation to start. Rough timestamps over two or three cycles are enough to find the dominant bucket. Precision comes later; direction comes now.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Automate the environment before the suite&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;If environment wait dominates, the highest-value automation work isn't a single test script. It's making environment provisioning boring.&lt;/p&gt;

&lt;p&gt;The pattern that works:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Declarative environment definitions, versioned next to the code&lt;/li&gt;
&lt;li&gt;Seeded test data, so a fresh environment is usable immediately&lt;/li&gt;
&lt;li&gt;Setup owned by the CI/CD pipeline, not by a person with tribal knowledge&lt;/li&gt;
&lt;li&gt;One environment per run instead of a queue for one shared staging box&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Shared-staging queueing is invisible in test reports and dominant in calendars — five teams waiting for the same environment is regression time, even though no test is running.&lt;/p&gt;

&lt;p&gt;At CYDEF that's exactly the order we worked in. The team automated infrastructure creation for new clients, wrote comprehensive deployment documentation, extended CI/CD coverage to components it didn't reach, and wired automated regression suites directly into the pipeline. Alongside that, database indexing and caching work removed a slow-request bottleneck that was quietly stretching every test run.&lt;/p&gt;

&lt;p&gt;The shape of a declarative environment doesn't need to be exotic:&lt;/p&gt;

&lt;h1&gt;
  
  
  ephemeral env per regression run, torn down after
&lt;/h1&gt;

&lt;p&gt;jobs:&lt;/p&gt;

&lt;p&gt;provision:&lt;/p&gt;

&lt;p&gt;steps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;run: terraform apply -auto-approve -var="env_id=reg-${{ github.run_id }}"&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;run: ./scripts/seed-data.sh reg-${{ github.run_id }}&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;regression:&lt;/p&gt;

&lt;p&gt;needs: provision&lt;/p&gt;

&lt;h1&gt;
  
  
  suite runs against its own env — nobody queues, nobody's data collides
&lt;/h1&gt;

&lt;p&gt;The point isn't the tool. It's that "get me a working environment with known data" becomes a pipeline step measured in minutes, not a favor measured in days.&lt;/p&gt;

&lt;p&gt;The honest counterargument: ephemeral environments cost real money and real engineering time. A small team on a stable monolith with one deploy target may get more from fixing and disciplining one shared environment than from building a provisioning system. Do the math on your own environment-wait bucket first.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Parallelism is the biggest lever — and it demands a deterministic suite&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Once environments stop being the bottleneck, execution time is next, and parallelism is the mechanism behind the largest reductions I've seen. Wall-clock time falls near-linearly with worker count — but only if the tests are actually independent.&lt;/p&gt;

&lt;p&gt;On the E2EE messenger, iOS and Android automation runs in parallel on real devices, on at least 3 threads. That's what makes 700+ scripts per platform compatible with a regression cycle of up to 2 days instead of 20. On &lt;a href="https://devico.io/success-stories/abbott-laboratories" rel="noopener noreferrer"&gt;Abbott's LibreView&lt;/a&gt;, the suite is over 1700 tests supporting 4 web browsers — and the browser matrix itself is the natural sharding axis.&lt;/p&gt;

&lt;p&gt;Independence is the price of entry. For every test in the parallel lane, that means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It owns its data — per-test setup, not shared fixtures&lt;/li&gt;
&lt;li&gt;It assumes nothing about execution order&lt;/li&gt;
&lt;li&gt;It cleans up after itself&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Shared state is how a green suite turns red the moment you add a second worker.&lt;/p&gt;

&lt;p&gt;Concretely: a test that needs a user creates its own user, with a unique identifier, through an API call in its setup — it doesn't reach for test_user_1 that forty other tests also mutate. That setup cost feels wasteful right up until you try running the suite on 16 shards.&lt;/p&gt;

&lt;p&gt;strategy:&lt;/p&gt;

&lt;p&gt;matrix:&lt;/p&gt;

&lt;p&gt;browser: [chrome, firefox, edge, safari]&lt;/p&gt;

&lt;p&gt;shard: [1, 2, 3, 4]&lt;/p&gt;

&lt;p&gt;steps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;run: npx playwright test --shard=${{ matrix.shard }}/4 --project=${{ matrix.browser }}&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keep an explicit serial lane for the handful of flows that genuinely can't parallelize — schema migrations, payment flows, anything mutating global state. Naming that lane out loud is better than pretending everything parallelizes and debugging the lie later.&lt;/p&gt;

&lt;p&gt;And know what you're signing up for: parallelism turns flakiness from an irritation into a blocker. Order-dependent and time-dependent tests that limped along in serial runs fail loudly under parallel execution. Parallel runs also multiply infrastructure spend. An unstable suite on 16 workers doesn't give you answers faster — it gives you wrong answers faster.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Tier the suite instead of running one monolith&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;A single "run everything" gate is what makes regression a calendar event. Nobody needs the full suite's verdict on every commit; everybody needs &lt;em&gt;some&lt;/em&gt; verdict fast.&lt;/p&gt;

&lt;p&gt;The tiering that works in practice:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Smoke&lt;/strong&gt; — a minutes-to-hours gate on every build&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Change-scoped selection&lt;/strong&gt; — full depth on what the change plausibly touches, mid-cycle&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full suite&lt;/strong&gt; — nightly or pre-release, as the safety net&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The numbers make the case. On the E2EE messenger, automated smoke testing takes up to 2 hours against a full regression of up to 2 days — a fast honest signal roughly 24x cheaper than the full answer. On Abbott, the time to perform a smoke test dropped from 7 days to 1 day, and smoke testing of individual countries got over 85% faster. (To be precise: those Abbott figures are smoke durations — the case doesn't publish a full-regression before/after.)&lt;/p&gt;

&lt;p&gt;Mid-cycle, risk-based selection earns its keep: run what the change plausibly touches, at full depth, immediately. But be honest about its blind spot — regressions love showing up in "unrelated" areas, because the coupling in real systems is never fully mapped. The periodic full run is the safety net that makes aggressive mid-cycle selection defensible. Tier the suite; don't quietly shrink it.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Kill the multiplier — configs, locales, platforms&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Here's the hidden cause behind most "regression takes weeks" stories: cycle length isn't test count. It's test count × configurations. Four browsers, a dozen locales, three platforms — a modest suite becomes a monster through multiplication alone, and every new market multiplies again.&lt;/p&gt;

&lt;p&gt;You attack the multiplier structurally. On Abbott, the team built the suite on a keyword-driven testing methodology with a focus on performance and flexibility — one scenario definition executes across the whole 4-browser matrix instead of existing as four hand-maintained copies. On a platform serving over 4 million users across many country configurations, that's the difference between linear and multiplicative maintenance, and it's part of how per-country smoke time came down by over 85%.&lt;/p&gt;

&lt;p&gt;Two more cuts at the same multiplier:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Verify translations as their own concern — string presence, formatting, layout — instead of re-running full behavioral suites per locale; behavior doesn't change because the button label is in Portuguese.&lt;/li&gt;
&lt;li&gt;Add devices or browsers to the matrix only where failure modes genuinely differ, not for symmetry.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Which is also the counterargument: not every configuration deserves parity. Give every locale and every device the full-matrix treatment "to be safe" and you've rebuilt the original problem with more YAML.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Speed you can't maintain isn't speed&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Everything above decays without ownership. Test automation is a development effort — it needs code review, refactoring, and debt management like any codebase. An unowned suite rots into a distrusted one, and a distrusted suite is worse than slow: people re-verify its results manually, and you're back to weeks with extra steps.&lt;/p&gt;

&lt;p&gt;The structural fixes are unglamorous:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Named ownership&lt;/strong&gt; for the suite — someone's job, not everyone's hobby&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stable locators&lt;/strong&gt; — dedicated test IDs, not brittle XPath — so UI churn doesn't shred half the tests&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A real quarantine policy&lt;/strong&gt; — a flaky test gets pulled from the gate immediately, then fixed or deleted on a deadline&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;"Retry until green" isn't a policy; it's a slow-motion way of teaching the team to ignore red.&lt;/p&gt;

&lt;p&gt;And QA embedded where the work happens, so regression is continuous rather than a phase. On &lt;a href="https://devico.io/success-stories/mint" rel="noopener noreferrer"&gt;MINT&lt;/a&gt;, an advertising resource management platform we've worked on since 2019, 14 development teams each include at least one quality assurance professional, teams run independently with their own sandboxes plus a dedicated UAT environment — and the platform ships several releases per day. Quality isn't a gate at the end of that pipeline; it's inside every team.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://devico.io/success-stories/tipalti" rel="noopener noreferrer"&gt;Tipalti&lt;/a&gt;, a payments platform we've supported since 2011, shows the same discipline at a different cadence: 16 QA engineers (up to 20 at peak), end-to-end test cases first, then sanity plus automated regression as the second control layer, releasing 25 times a year. That cadence is deliberately conservative — it's regulated payments. Fast regression enables frequent releases; it doesn't require them. The choice of cadence stays a product decision once regression stops making it for you.&lt;/p&gt;

&lt;h1&gt;
  
  
  &lt;strong&gt;To sum up&lt;/strong&gt;
&lt;/h1&gt;

&lt;p&gt;If your regression cycle is measured in weeks, here's the diagnostic order I'd follow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Measure where the cycle time actually sits&lt;/li&gt;
&lt;li&gt;Automate provisioning before adding tests&lt;/li&gt;
&lt;li&gt;Make the suite parallel-safe and shard it&lt;/li&gt;
&lt;li&gt;Tier by risk, with a periodic full run as the net&lt;/li&gt;
&lt;li&gt;Collapse the config multiplier&lt;/li&gt;
&lt;li&gt;Give the suite a named owner&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Notice what's not on the list: "automate more" as a goal in itself, and any particular tool. Every project above changed several variables at once — process, infrastructure, suite architecture, team shape. No single framework caused any of these results, and none of these numbers is a benchmark you can order off a menu.&lt;/p&gt;

&lt;p&gt;Reach for this when your gate is slow because of structure — queues, serial runs, config sprawl. Skip the heavy machinery when a small suite on a stable monolith just needs one shared environment fixed. And go in with both trade-offs priced: shorter feedback loops cost infrastructure spend and permanent maintenance, and a fast suite nobody trusts is worse than a slow one people do.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>automation</category>
      <category>devops</category>
      <category>qa</category>
    </item>
    <item>
      <title>How to Build a Resilient Integration Layer for Dozens of External APIs</title>
      <dc:creator>Vic Tekys</dc:creator>
      <pubDate>Tue, 22 Sep 2026 09:49:00 +0000</pubDate>
      <link>https://dev.to/devico-solutions/how-to-build-a-resilient-integration-layer-for-dozens-of-external-apis-nim</link>
      <guid>https://dev.to/devico-solutions/how-to-build-a-resilient-integration-layer-for-dozens-of-external-apis-nim</guid>
      <description>&lt;p&gt;A carrier's label API times out at ten seconds. Your HTTP client throws, your retry wrapper fires, the second request lands — and the vendor processed both. The customer now has two shipping labels on one parcel. Swap the carrier for a payment provider and it's two charges. Nothing in your dashboards looks wrong, because from your side every request eventually "succeeded."&lt;/p&gt;

&lt;p&gt;That incident is what a uniform retry policy buys you once third-party API integration becomes the product.&lt;/p&gt;

&lt;p&gt;Because integrations don't fail the same way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Some gate a flow and can't degrade — KYC checks, custody, payments.&lt;/li&gt;
&lt;li&gt;Some can serve stale data for an hour and nobody notices — prices, metadata, enrichment.&lt;/li&gt;
&lt;li&gt;Some cause things to happen in the physical world — labels print, locks open, drivers dispatch.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My thesis for this whole article: resilience in an integration layer comes from classifying each vendor by what breaks when it fails, then isolating it — its own adapter, its own data model, its own timeout budget, its own cached state — so the failure stays local. The thing that scales worst is the thing most teams build first: one canonical interface with one retry-and-circuit-breaker policy applied to everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Count integrations by blast radius, not by vendor&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;When someone says "we have 30 integrations," that number tells me nothing. The useful inventory is by blast radius, and in my experience it always sorts into three classes.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Critical-path, can't degrade.&lt;/strong&gt; On &lt;a href="https://devico.io/success-stories/mode-app" rel="noopener noreferrer"&gt;Mode&lt;/a&gt;, a UK digital finance app dealing in GBP, EUR and BTC, Onfido gates onboarding and Chainalysis and Sardine.ai gate compliance checks — if those calls fail, the flow stops, full stop. Coinbase and Modulr sit in the money movement path. There is no "serve stale" option for any of them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Degradable.&lt;/strong&gt; Mode's Twilio integration sends notifications; a delayed SMS is an annoyance, not an incident. Token prices, asset metadata, enrichment data — all of this can be served from cache while the vendor is down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Side-effecting on the physical world.&lt;/strong&gt; A shipping label, a door lock, a dispatch call. These are the scariest, because a duplicated call isn't a wasted request — it's a duplicated event you can't un-happen.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each class gets different rules. In practice that looks like config, not philosophy:&lt;/p&gt;

&lt;p&gt;const policies = {&lt;/p&gt;

&lt;p&gt;critical:&amp;nbsp; &amp;nbsp; { timeoutMs: 8000, retries: 2,&amp;nbsp; fallback: 'fail-closed' },&lt;/p&gt;

&lt;p&gt;degradable:&amp;nbsp; { timeoutMs: 1500, retries: 0,&amp;nbsp; fallback: 'serve-cached' },&lt;/p&gt;

&lt;p&gt;sideEffect:&amp;nbsp; { timeoutMs: 15000, retries: 0, fallback: 'park-and-reconcile' },&lt;/p&gt;

&lt;p&gt;} satisfies Record;&lt;/p&gt;

&lt;p&gt;Note the retries on the last two: zero. More on that below.&lt;/p&gt;

&lt;p&gt;The honest limitation: classes shift under your feet. A price feed is degradable while it renders a chart and critical the moment it prices a trade. The classification is per call site, not per vendor — and it needs re-checking every time a new feature reuses an old integration.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;One adapter per provider, one owner per data class&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The structural rule I hold to: every provider — or every data class, where that's the better seam — gets its own adapter that owns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the translation to and from vendor types,&lt;/li&gt;
&lt;li&gt;the auth and credential handling,&lt;/li&gt;
&lt;li&gt;the rate limits,&lt;/li&gt;
&lt;li&gt;the error taxonomy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nothing downstream ever sees a vendor's types. This is the &lt;a href="https://learn.microsoft.com/en-us/azure/architecture/patterns/anti-corruption-layer" rel="noopener noreferrer"&gt;anti-corruption layer&lt;/a&gt; pattern, and Microsoft's own writeup is upfront that it costs you latency and an extra service to maintain — it's still the cheapest line item in this article.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://devico.io/success-stories/defi-wallet" rel="noopener noreferrer"&gt;DEFI Wallet&lt;/a&gt;, a multichain Web3 wallet a 9-person Devico team has been building since 2021, is split exactly along data-class seams:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a Python service for token metadata,&lt;/li&gt;
&lt;li&gt;a Node.js/Nest.js service for asset data,&lt;/li&gt;
&lt;li&gt;a separate service for NFT balances,&lt;/li&gt;
&lt;li&gt;a GraphQL router on top, combining the service schemas.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The vendor list underneath is long — Infura, RPC nodes, DefiLlama, THORNode, Moralis, Covalent, CoinGecko, TrustWallet API — and no consumer of the graph touches any of them directly.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://devico.io/success-stories/good-shape" rel="noopener noreferrer"&gt;GoodShape&lt;/a&gt;, a UK wellness platform, supports 16 different fitness-tracker brands — an integration surface that became tractable after a migration out of a roughly 15-year-old monolith into microservices, where tracker data collection lives as its own service on RabbitMQ and AWS SQS.&lt;/p&gt;

&lt;p&gt;Per-vendor adapters also give you bulkheads for free: one slow vendor exhausts its own connection pool and its own worker budget, not the shared one your payments path is standing in.&lt;/p&gt;

&lt;p&gt;The cost is real, though. Dozens of adapters means dozens of deploy units, credential sets and dashboards. Don't pretend otherwise when you pitch this to your team.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Where a gateway helps — and where it hides failure&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;A router or gateway in front of the adapters earns its place by deciding what the caller sees: which fields compose into one response, what happens when a sub-service is down.&lt;/p&gt;

&lt;p&gt;That's also its failure mode. A GraphQL router combining schemas is a coupling point where a partial outage flattens into a null field — the NFT service is down, the wallet renders an empty tab, and nobody pages, because every layer "handled" the error.&lt;/p&gt;

&lt;p&gt;A gateway decides what the caller sees. It does not make the underlying call resilient. If your resilience story is "we have a gateway," you have a single point of failure with good manners.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Reads: get vendors off the request path&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The highest-leverage change I know for read traffic: stop proxying. Every synchronous pass-through call donates your vendor's latency and downtime directly to your user.&lt;/p&gt;

&lt;p&gt;The alternative is to ingest — pull vendor data into your own store on your own schedule, serve every user request from that store, refresh asynchronously. Cache, materialized view, indexer, full pipeline: the mechanism varies, the principle doesn't. The vendor's availability stops being your availability.&lt;/p&gt;

&lt;p&gt;DEFI Wallet takes this to the logical end: rather than reading chains per request, the team built blockchain indexers for real-time transaction processing, so the wallet reads its own indexed state.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://devico.io/success-stories/sway" rel="noopener noreferrer"&gt;Sway&lt;/a&gt;, a US reverse-logistics company (ex-Returnmates), does the same for reporting: a pipeline from Node.js through Google Cloud Storage into Snowflake, surfaced in Sigma — not live queries against operational systems. On the operational side, PostgreSQL indexing and materialized views were part of a rework that Sway reports cut scan times from 3–5 seconds to 1–2 seconds and improved query speed by at least 40%. Those figures come from the case study, and they cover a bundle of work — the mobile app, DB indexing, refactored parcel creation, queues — so I won't pretend they isolate the read-path change. The architecture, though, is exactly the shape I'm describing.&lt;/p&gt;

&lt;p&gt;What you inherit in exchange:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;staleness windows you now have to define per data class,&lt;/li&gt;
&lt;li&gt;backfills for whenever ingestion falls behind,&lt;/li&gt;
&lt;li&gt;a reconciliation job that never quite finishes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For prices and metadata, stale is fine. For balances, compliance states and anything money-shaped, stale isn't "old data" — it's wrong data, and this pattern doesn't apply. That's the classification from section one doing its job.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Writes: idempotency before retries&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Here's the assumption I most want to break: that retry logic is where write-path resilience starts. It's where it ends.&lt;/p&gt;

&lt;p&gt;Order of construction on any side-effecting integration:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;An idempotency key on every outbound write.&lt;/li&gt;
&lt;li&gt;A persisted intent record.&lt;/li&gt;
&lt;li&gt;A reconciliation job.&lt;/li&gt;
&lt;li&gt;Then retries.&lt;/li&gt;
&lt;li&gt;Only then a circuit breaker.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Without the first three, a retry converts a lost response into a duplicate action.&lt;/p&gt;

&lt;p&gt;Stripe's API is the reference design worth copying: the client sends an &lt;a href="https://docs.stripe.com/api/idempotent_requests" rel="noopener noreferrer"&gt;idempotency key&lt;/a&gt;, the server saves the status code and body of the first request for that key — success or failure — and returns the same result on every replay, pruning keys after at least 24 hours. That contract is what makes a retry safe: the vendor guarantees the second request can't act twice.&lt;/p&gt;

&lt;p&gt;Your side of the contract is the intent record — persist what you're about to do before you do it:&lt;/p&gt;

&lt;p&gt;INSERT INTO outbound_intents (idempotency_key, vendor, action, payload, status)&lt;/p&gt;

&lt;p&gt;VALUES ('parcel-19f3-label', 'carrier-x', 'create_label', $1, 'pending');&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;- call the vendor with the same key; mark 'confirmed' only on response.&lt;/li&gt;
&lt;li&gt;- A reconciliation job sweeps 'pending' rows and ASKS the vendor&lt;/li&gt;
&lt;li&gt;- what happened before anything retries.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The row that stays pending after a timeout is precisely the case from this article's first paragraph: you don't know what happened. Reconciliation — query the vendor, then decide — is the only honest answer. Blind resend is a coin flip.&lt;/p&gt;

&lt;p&gt;Webhooks force the same discipline from the other direction. Stripe's docs state plainly that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;endpoints &lt;a href="https://docs.stripe.com/webhooks" rel="noopener noreferrer"&gt;might receive the same event more than once&lt;/a&gt;,&lt;/li&gt;
&lt;li&gt;event order isn't guaranteed,&lt;/li&gt;
&lt;li&gt;delivery retries run for up to three days with exponential backoff.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Their recommended defense is tracking processed event IDs — idempotency again, inbound this time. And with a three-day retry window, "we'll have processed the earlier event by then" is not a safe assumption at any point.&lt;/p&gt;

&lt;p&gt;The stakes scale with how physical the side effect is. Sway generates carrier labels and dynamic ZPL for Zebra printers across &lt;a href="https://devico.io/success-stories/sway" rel="noopener noreferrer"&gt;FedEx, ClearJet, Million, T-Force, SmartKargo and UDS&lt;/a&gt; — six carriers, each a place where a duplicated call is a real label on a real box. Space Plus, a staffless self-storage operator, issues digital keys through Sensorberg's IoT platform — a retried unlock is a door opening. To be clear about attribution: those case studies document the integrations, not the idempotency machinery; the machinery is my recommendation for anyone operating that class of integration.&lt;/p&gt;

&lt;p&gt;The gap you'll hit: plenty of vendors offer no idempotency key at all. Then your dedupe needs a natural key — order ID plus carrier plus day, say — and natural keys are ambiguous in exactly the edge cases that matter, like a legitimate second label for a split shipment. Budget design time for that key; it's harder than the plumbing.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;When the retry is the bug&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Retries also fail at the aggregate level, and there's good data on how. Google's SRE book &lt;a href="https://sre.google/sre-book/addressing-cascading-failures/" rel="noopener noreferrer"&gt;works through the arithmetic&lt;/a&gt;: a backend at capacity starts rejecting requests, clients retry, and the retries themselves push the backend further past capacity — in their worked example a 10,000 QPS service ends up receiving 10,200 QPS precisely because it's failing. Their guidance:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Randomized exponential backoff, always.&lt;/li&gt;
&lt;li&gt;A hard cap on retries per request.&lt;/li&gt;
&lt;li&gt;A server-wide retry budget — their example is 60 retries per minute, after which requests just fail.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The multiplier that catches microservices teams: retries stack per layer. Three attempts at each of four layers is up to 64 attempts hitting the bottom, &lt;a href="https://sre.google/sre-book/addressing-cascading-failures/" rel="noopener noreferrer"&gt;per the same chapter&lt;/a&gt;. If your adapter retries, your gateway must not.&lt;/p&gt;

&lt;p&gt;That's why retry policy is a per-write-path decision, not a framework default. A blanket "3 retries with backoff" in your HTTP client is the single config line most likely to charge a customer twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Aggregators vs. direct integrations&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;At some vendor count, someone proposes an aggregator: one contract, one API, many providers behind it. It's usually decided by reflex — either "obviously yes, less code" or "obviously no, we control our stack." Both reflexes skip the actual trade.&lt;/p&gt;

&lt;p&gt;What an aggregator buys you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one contract and one integration surface,&lt;/li&gt;
&lt;li&gt;someone else maintaining the long tail of providers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What it costs you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one failure domain where you used to have several,&lt;/li&gt;
&lt;li&gt;one shared rate limit,&lt;/li&gt;
&lt;li&gt;someone else's semantics for everything behind it — their error taxonomy, their timeout behavior, their idea of what a "declined" means.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Space Plus is a reasonable picture of the pragmatic middle: payments route through Adyen, alongside PayPal, AmazonPay and Sofort, plus a Store365 CRM integration — a setup the case study describes as a hybrid of bespoke build and off-the-shelf integration. Aggregate where the aggregator's domain is mature; go direct where the vendor is load-bearing enough to deserve its own adapter.&lt;/p&gt;

&lt;p&gt;The overlapping-provider version of this problem is subtler. DEFI Wallet pulls asset and market data from several providers whose coverage overlaps — Moralis, Covalent, CoinGecko, DefiLlama and others. I'll flag what the source does and doesn't say: it lists the integrations; it doesn't describe a failover strategy, and I won't invent one. But anyone who's run overlapping providers knows the operational truth: two providers of "the same" data return different numbers, and deciding which one your product displays is a product decision wearing an engineering costume.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Vendor drift is an operational problem&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Everything so far is architecture. The failures that actually page you a year in are operational: integrations rot silently.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A vendor tightens a rate limit without an announcement.&lt;/li&gt;
&lt;li&gt;A deprecated field starts returning null.&lt;/li&gt;
&lt;li&gt;A schema gains a field your parser chokes on.&lt;/li&gt;
&lt;li&gt;Semantics move under a stable version number.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this throws a 5xx, so none of it trips a circuit breaker — your integration is "up" and wrong.&lt;/p&gt;

&lt;p&gt;What catches it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;per-vendor contract tests running against recorded responses,&lt;/li&gt;
&lt;li&gt;alerts on response shape and volume anomalies, not only on error rates,&lt;/li&gt;
&lt;li&gt;a habit of treating a vendor changelog like a dependency upgrade.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A breaker knows the vendor is down. It cannot know the vendor is lying.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Sandboxes, UAT and release cadence at hundreds of integrations&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Past a certain integration count, keeping validation running is a team-topology problem, not a tooling one. &lt;a href="https://devico.io/success-stories/mint" rel="noopener noreferrer"&gt;MINT&lt;/a&gt;, an adtech resource-management platform, operates 16 channels and 350+ vendor integrations; the working setup there was organizational:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;14 independent teams, each with its own sandboxes,&lt;/li&gt;
&lt;li&gt;QA embedded in the teams rather than centralized,&lt;/li&gt;
&lt;li&gt;a dedicated UAT environment.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That replaced release-approval bottlenecks with multiple releases a day. The relevant lesson is about process: at that vendor count, a centralized release gate is itself the outage.&lt;/p&gt;

&lt;p&gt;One warning from hard experience: sandboxes lie. Plenty of vendor test environments differ from production in latency, rate limits, and occasionally behavior. A green sandbox suite tells you your code matches the vendor's documentation — production tells you whether the documentation was true.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;What this costs&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;None of this is free, and I'd rather you hear it from me than from your own on-call rotation.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Per-vendor isolation multiplies code, deploy units, credentials, dashboards and alert routes.&lt;/li&gt;
&lt;li&gt;Caching reads buys you a reconciliation problem in perpetuity.&lt;/li&gt;
&lt;li&gt;Idempotency adds a storage table, a dedupe path and a sweeper job to everything that writes.&lt;/li&gt;
&lt;li&gt;Contract tests are a suite someone has to own forever.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Below roughly five integrations, most of this is overhead — a well-tested direct integration with sane timeouts beats a prematurely general integration layer. The flip point is easy to name because you'll feel it: the first time one vendor's outage takes down a feature that had nothing to do with that vendor, you've outgrown the shared plumbing.&lt;/p&gt;

&lt;p&gt;So the order of effort, when you get there:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Classify every integration by blast radius.&lt;/li&gt;
&lt;li&gt;Isolate each vendor behind its own adapter.&lt;/li&gt;
&lt;li&gt;Move reads off the request path.&lt;/li&gt;
&lt;li&gt;Make writes idempotent.&lt;/li&gt;
&lt;li&gt;Only then add the retries and circuit breakers everyone starts with.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each step makes the next one safe; run in reverse order, the list is a duplicate-payment generator.&lt;/p&gt;

&lt;p&gt;And keep the last dollar for operations. The integration that breaks in production is rarely the one that went down — it's the one that changed on the vendor's side six weeks ago, while nobody was watching the response shape.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>backend</category>
      <category>api</category>
      <category>microservices</category>
    </item>
    <item>
      <title>How to Build a Scalable File-Processing Pipeline: Validation, Metadata Extraction, Queues, and Workers</title>
      <dc:creator>Vic Tekys</dc:creator>
      <pubDate>Tue, 15 Sep 2026 09:35:00 +0000</pubDate>
      <link>https://dev.to/devico-solutions/how-to-build-a-scalable-file-processing-pipeline-validation-metadata-extraction-queues-and-3p33</link>
      <guid>https://dev.to/devico-solutions/how-to-build-a-scalable-file-processing-pipeline-validation-metadata-extraction-queues-and-3p33</guid>
      <description>&lt;p&gt;Here's a failure I've seen more than once on upload-heavy products: the endpoint returns 200, the user closes the tab, and twenty minutes later there's no order in the system because a worker choked on a vendor's proprietary container format. Nobody told the user. Nobody told the worker's author either, because the retry policy quietly redelivered the file until it hit a dead-letter queue nobody monitored.&lt;/p&gt;

&lt;p&gt;The standard advice — "put a queue in front of it" — doesn't prevent any of this. The queue was never the hard part.&lt;/p&gt;

&lt;p&gt;After building ingestion for dental CAD files, construction models, bulk carbon data, and business documents, I've settled on a different frame: a file pipeline is four stages with different latency budgets and different failure semantics. Throughput is decided by how early you reject bad input and how much derived work leaves the request path — not by which broker you pick.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Four stages, not one queue&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The four stages are gate, ingest, extract, derive.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Gate&lt;/strong&gt; is synchronous and cheap: auth, quota, size, extension, magic-byte sniffing. It runs while the user is still watching and its budget is milliseconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ingest&lt;/strong&gt; moves bytes into object storage and writes the metadata record. It should touch your API process as little as possible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extract&lt;/strong&gt; is asynchronous, per-format parsing: opening the container, pulling metadata, validating structure. This is where the real engineering lives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Derive&lt;/strong&gt; produces the artifacts your product actually reads — thumbnails, previews, normalized records, optimized model formats. Also async, and unlike extract, usually cacheable and re-runnable.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The reason to name the stages is that the boundaries between them are where you retry, cache, and shed load. A parse failure shouldn't re-run the upload. A thumbnail failure shouldn't re-run the parse. When everything is one "process file" job, every retry repeats all the work and every failure is opaque.&lt;/p&gt;

&lt;p&gt;On the &lt;a href="https://devico.io/success-stories/carbon-management-solution" rel="noopener noreferrer"&gt;Carbon Management Solution&lt;/a&gt; project — bulk emissions data, where a user uploads a single file containing any amount of data that then gets validated and processed — we ran this on serverless AWS with Lambda, SNS, SQS, and DynamoDB, structured so that a certain service is responsible for a certain stage of the process. Stage isolation was the design, not an accident of the stack.&lt;/p&gt;

&lt;p&gt;Honest caveat: service-per-stage multiplies your operational surface. Four services means four sets of alarms, four deploy pipelines, four things to reason about at 2 a.m. At low volume, one worker pool consuming from a couple of queues is the right call. Draw the stage boundaries in code first; split the infrastructure when a stage actually needs independent scaling.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Validation is a two-tier problem&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The all-async reflex — "accept everything, validate in the worker" — is how you get the silent-failure story from the intro. But the opposite reflex, deep validation in the request handler, is how upload endpoints time out. Validation is two tiers, and the split is not negotiable.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;What has to fail while the user is still watching&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Anything a user can fix by picking a different file must fail synchronously: wrong extension, oversized file, empty file, exhausted quota, a MIME type that doesn't match the bytes. If the user learns about these hours later from an email, your gate failed at its one job.&lt;/p&gt;

&lt;p&gt;The gate checks are cheap because they read a few bytes, not the structure:&lt;/p&gt;

&lt;p&gt;import { fileTypeFromBuffer } from "file-type";&lt;/p&gt;

&lt;p&gt;const ALLOWED = new Set(["model/stl", "application/zip", "application/pdf"]);&lt;/p&gt;

&lt;p&gt;const MAX_BYTES = 500 * 1024 * 1024;&lt;/p&gt;

&lt;p&gt;export async function gate(head: Buffer, declaredSize: number, user: User) {&lt;/p&gt;

&lt;p&gt;if (declaredSize === 0 || declaredSize &amp;gt; MAX_BYTES) {&lt;/p&gt;

&lt;p&gt;throw new UploadError("size_rejected");&lt;/p&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;if (await quotaExceeded(user, declaredSize)) {&lt;/p&gt;

&lt;p&gt;throw new UploadError("quota_exceeded");&lt;/p&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;// Sniff real content type from magic bytes — never trust the filename&lt;/p&gt;

&lt;p&gt;const sniffed = await fileTypeFromBuffer(head);&lt;/p&gt;

&lt;p&gt;if (!sniffed || !ALLOWED.has(sniffed.mime)) {&lt;/p&gt;

&lt;p&gt;throw new UploadError("type_rejected");&lt;/p&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;The line to look at is the sniffing: content type comes from the bytes, not the filename. Renamed .exe files and mislabeled archives are a when, not an if.&lt;/p&gt;

&lt;p&gt;On &lt;a href="https://devico.io/success-stories/bego" rel="noopener noreferrer"&gt;BEGO&lt;/a&gt;, a German dental CAD/CAM company, we went further and put scenario detection in the order wizard itself: the upload step auto-detects whether the user dropped 3Shape output, exocad output, standalone STLs, or a mixed set, and routes the flow accordingly. That's still gate-tier work — it reads signatures and file lists, not full structure — and it means the user is corrected before the order exists, not after.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;What can only fail inside a worker&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Structural correctness of a real-world format is unknowable until you parse it. Whether a proprietary archive decrypts, whether the XML inside references parts that exist, whether an STL mesh is watertight — none of that fits a request budget, and none of it should.&lt;/p&gt;

&lt;p&gt;So the contract with the user changes shape: the synchronous response means "your file is accepted for processing," never "your file is valid." The pipeline then needs a way to deliver bad news — a per-file status the UI polls or subscribes to, flipping from processing to failed with a reason a human can act on. On BEGO, the order platform tracks per-file status through queue processing for exactly this reason.&lt;/p&gt;

&lt;p&gt;The failure mode to design against: sync checks can be spoofed and deep checks can't be rushed. Any team that tries to collapse the two tiers into one ends up with either a slow gate or a lying one.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Metadata extraction is format work disguised as infrastructure&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Generic pipeline tutorials treat "extract metadata" as one box in the diagram. In practice it's the majority of the engineering, because it's per-format parsing, and formats are hostile.&lt;/p&gt;

&lt;p&gt;On BEGO the extract stage deep-parses laboratory formats — .3ox, .dentalProject, .constructionInfo, .modelInfo— plus encrypted .bego/.begostl archives with decryption and retry logic, pulling out patient and order metadata, materials, colors, and tooth mapping. None of that generalizes. Every one of those parsers is bespoke work against a vendor format that ships no public spec and changes without notice.&lt;/p&gt;

&lt;p&gt;On &lt;a href="https://devico.io/success-stories/memomeister" rel="noopener noreferrer"&gt;MemoMeister&lt;/a&gt;, a document-management SaaS we built from scratch for the German market, extraction went a step further into classification: workers analyzed the keywords, data formats, and other characteristics of an upload to determine the document type and route it to the right user or group. The file's type came from its content, not its name.&lt;/p&gt;

&lt;p&gt;Two lessons from shipping that kind of extraction. First, content-derived classification is probabilistic — you will guess wrong, so the design must include a manual-correction path, not just a confidence score. Second, budget for vendor formats changing silently underneath you; a parser that worked for a year is not a parser that works.&lt;/p&gt;

&lt;p&gt;This is why "which queue should I use" is usually the wrong first question. The broker is a commodity. The .dentalProject parser is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Get bytes out of your API process&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;One flat infrastructure rule: file bytes should not flow through your API. The client uploads directly to object storage; the API's job is to issue the upload authorization, write the metadata record, and enqueue the job.&lt;/p&gt;

&lt;p&gt;BEGO uploads via pre-signed URLs to cloud storage. MemoMeister stored documents on Amazon S3, with workers picking files up after a successful upload. The pattern is the same either way: the API hands out a scoped, expiring write permission and steps out of the data path.&lt;/p&gt;

&lt;p&gt;The shape of it, with a commit step:&lt;/p&gt;

&lt;p&gt;// 1. API issues the upload — after gate checks, including quota&lt;/p&gt;

&lt;p&gt;const key = &lt;code&gt;uploads/${user.id}/${randomUUID()}&lt;/code&gt;;&lt;/p&gt;

&lt;p&gt;const url = await getSignedUrl(&lt;/p&gt;

&lt;p&gt;s3,&lt;/p&gt;

&lt;p&gt;new PutObjectCommand({&lt;/p&gt;

&lt;p&gt;Bucket: BUCKET,&lt;/p&gt;

&lt;p&gt;Key: key,&lt;/p&gt;

&lt;p&gt;ContentLength: declaredSize, // cap what the URL can write&lt;/p&gt;

&lt;p&gt;}),&lt;/p&gt;

&lt;p&gt;{ expiresIn: 300 },&lt;/p&gt;

&lt;p&gt;);&lt;/p&gt;

&lt;p&gt;await db.file.create({ key, userId: user.id, status: "pending_upload" });&lt;/p&gt;

&lt;p&gt;// 2. Client PUTs bytes straight to storage&lt;/p&gt;

&lt;p&gt;// 3. Client confirms; API verifies the object exists, then enqueues&lt;/p&gt;

&lt;p&gt;export async function commit(key: string) {&lt;/p&gt;

&lt;p&gt;const head = await s3.send(new HeadObjectCommand({ Bucket: BUCKET, Key: key }));&lt;/p&gt;

&lt;p&gt;await db.file.update({ key }, { status: "uploaded", etag: head.ETag });&lt;/p&gt;

&lt;p&gt;await queue.send({ key, etag: head.ETag });&lt;/p&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;The part to look at is step 3. Without an explicit commit, you have no moment at which the upload is known-complete, and your extract stage starts firing on half-written objects.&lt;/p&gt;

&lt;p&gt;Be honest about what this pattern costs: validation now happens after the write. A client can obtain a URL and never call commit, so you need a sweeper for orphaned objects. Quota has to be enforced before the URL is issued, on the declared size, and re-checked at commit. Direct-to-storage upload is still the right default — it just isn't free.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The queue decisions that actually change throughput&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Now the section everyone expects, argued honestly: broker choice matters far less than the policies around it.&lt;/p&gt;

&lt;p&gt;BEGO runs its workflow processing on Redis-backed queues under NestJS and Kubernetes. MemoMeister ran RabbitMQ, with some workers calling third-party services like Google Vision and others monitoring database changes to update document state. Carbon Management Solution used SNS and SQS. Three stacks, one set of decisions that mattered:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Queue per stage, not one queue for everything.&lt;/strong&gt; Extraction backing up shouldn't delay thumbnail generation for files already parsed. Separate queues give you separate backpressure and separate scaling knobs — and keep CPU-bound parsing workers apart from IO-bound workers waiting on third-party APIs, which want completely different concurrency settings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Visibility timeout longer than your worst file.&lt;/strong&gt; On SQS-style brokers, a message picked up but not deleted &lt;a href="https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-visibility-timeout.html" rel="noopener noreferrer"&gt;becomes visible again&lt;/a&gt; after the timeout. Set it shorter than your slowest legitimate parse and you get two workers processing the same giant file, doubling load exactly when the system is already struggling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A dead-letter queue you actually watch.&lt;/strong&gt; Bounded retries, then park the message. An unmonitored DLQ is the silent-failure story again, one hop removed.&lt;/p&gt;

&lt;p&gt;One warning on MemoMeister's DB-watching workers: they worked, but they couple the pipeline to your schema — every migration becomes a pipeline change. I'd reach for explicit events first and treat DB-watching as a legacy-integration tool.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Idempotency, retries, and poison files&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Standard queues are &lt;a href="https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-standard-queues.html" rel="noopener noreferrer"&gt;at-least-once delivery&lt;/a&gt;: the broker guarantees a message arrives, not that it arrives once. Operationally that means every worker you write will eventually run twice for the same file. Plan for it or debug it.&lt;/p&gt;

&lt;p&gt;The fix is idempotency keyed on the stored object — bucket, key, and ETag — checked before the expensive work:&lt;/p&gt;

&lt;p&gt;async function handle(msg: { key: string; etag: string }) {&lt;/p&gt;

&lt;p&gt;const claimed = await db.extraction.tryClaim(msg.key, msg.etag); // unique index&lt;/p&gt;

&lt;p&gt;if (!claimed) return; // duplicate delivery or a lost race — done&lt;/p&gt;

&lt;p&gt;const meta = await parse(msg.key); // the expensive part&lt;/p&gt;

&lt;p&gt;await db.extraction.complete(msg.key, msg.etag, meta);&lt;/p&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;Keying on the ETag rather than just the key means a re-uploaded file with new bytes is new work, while a redelivered message for the same bytes is a no-op.&lt;/p&gt;

&lt;p&gt;Then there are poison files — the input that kills the worker itself. The classic is the model that OOMs the process: the worker dies before it can nack or delete, the visibility timeout expires, the message comes back, and the next worker dies too. Your fleet is now a crash loop with one file as the ignition. Defenses are boring and essential: a max-receive count routing to the DLQ, memory limits that fail the job instead of the pod, and a size ceiling in the gate so the 9 GB file never enters the pipeline at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Derived artifacts are the product&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Here's the second assumption worth breaking: the pipeline doesn't exist to store files. It exists to produce cheap-to-read outputs. If the artifact your product serves is still expensive to consume, you optimized the wrong half.&lt;/p&gt;

&lt;p&gt;BEGO generates STL thumbnails at extract time so the order UI never touches a mesh. And the clearest evidence I have is &lt;a href="https://devico.io/success-stories/vitus" rel="noopener noreferrer"&gt;Vitus&lt;/a&gt;, a Danish constructability platform handling .ifc, .nwd, .rvt, .3dm, and other CAD formats for construction companies like Munck, Femern A/S, VINCI, and COWI. Their viewer struggled not because ingestion was slow, but because the format being loaded was heavy.&lt;/p&gt;

&lt;p&gt;The fix was a bundle of read-side changes — migrating models to SVF2, parallel loading, web workers off the main thread, list virtualization, IndexedDB caching. Together they cut &lt;a href="https://devico.io/success-stories/vitus" rel="noopener noreferrer"&gt;model size by 5x and loading time by roughly 2.5x, with multi-model loading 5x faster&lt;/a&gt;. To be precise about what those numbers are: client-side model-loading gains from the whole bundle of changes, not a server-side pipeline benchmark, and no single change gets the credit. But that's exactly the point — the wins came from changing the data format and the loading strategy. Worker count had nothing to do with it.&lt;/p&gt;

&lt;p&gt;The tax on derived artifacts: they need versioning, and a format migration is a backfill, not a deploy. When Vitus moved to SVF2, every existing model needed re-derivation. Stamp every artifact with the version of the deriver that produced it from day one, so "re-derive everything older than v3" is a query and a queue-fill instead of a forensic project.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Constraints that beat the ideal design&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Everything above assumes you get to choose where processing runs. Sometimes you don't, and residency, encryption, and hardware outrank performance.&lt;/p&gt;

&lt;p&gt;MemoMeister was restricted to third-party services that stored and processed data within Germany. That single compliance line forced a self-hosted OnlyOffice deployment instead of a managed document service — an architectural decision made by a regulation, not a benchmark.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://devico.io/success-stories/visbion" rel="noopener noreferrer"&gt;Visbion&lt;/a&gt; is the sharper example: their Image Cube compresses and encrypts DICOM 3.0 medical images on dedicated routing hardware inside mobile scanning trailers, serving NHS Breast Screening Services as the UK's largest installed base of dedicated imaging routing hardware. The heaviest processing happens at the edge, on a device in a trailer, before any cloud pipeline sees a byte — because bandwidth from a mobile trailer and encryption requirements for medical imaging say so. Our work there was consulting on fleet-deployment automation, compression and encryption refinement, and a modular redesign — tuning that constraint-driven architecture, not replacing it with a textbook one.&lt;/p&gt;

&lt;p&gt;The trade is real: edge and self-hosted processing give up elasticity and easy observability for compliance and bandwidth. Check these constraints before drawing the architecture, because they don't negotiate afterwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Where to spend the effort, in order&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;If I'm starting an ingestion system today, the order is: a cheap synchronous gate that rejects everything a user can fix on the spot; bytes going straight to object storage with an explicit commit; idempotent workers with a DLQ someone watches; and only then worker scaling and broker tuning. The per-format parsers will take longer than all of the infrastructure combined — budget accordingly.&lt;/p&gt;

&lt;p&gt;Reach for the full four-stage split when files are big, formats are hostile, and derived artifacts are what users actually consume. Skip the ceremony when you're thumbnailing avatars — a gate check and one background job is a complete architecture at that size.&lt;/p&gt;

&lt;p&gt;And check the read side last, every time. If the artifact coming out of the pipeline is still expensive to consume, no amount of queue work will save you — you've optimized the half of the system your users never touch.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>architecture</category>
      <category>node</category>
      <category>aws</category>
    </item>
    <item>
      <title>How to Modernize a Legacy SaaS Without Freezing Feature Delivery: Refactor, Replatform, or Rewrite?</title>
      <dc:creator>Vic Tekys</dc:creator>
      <pubDate>Wed, 09 Sep 2026 14:30:07 +0000</pubDate>
      <link>https://dev.to/devico-solutions/how-to-modernize-a-legacy-saas-without-freezing-feature-delivery-refactor-replatform-or-rewrite-eeg</link>
      <guid>https://dev.to/devico-solutions/how-to-modernize-a-legacy-saas-without-freezing-feature-delivery-refactor-replatform-or-rewrite-eeg</guid>
      <description>&lt;p&gt;The moment arrives on every aging SaaS. Sales has commitments on the roadmap, and the codebase is telling you it can't carry them — every feature ships with a regression tax, the framework stopped getting patches years ago, and nobody fully understands the billing module anymore.&lt;/p&gt;

&lt;p&gt;The reflex answer is always the same: freeze the roadmap for two quarters, build "v2" on the side, migrate everyone over, resume shipping. I've sat in that meeting more than once.&lt;/p&gt;

&lt;p&gt;That reflex is wrong twice over. First, no revenue-generating SaaS actually gets a two-quarter freeze — customers keep filing tickets, competitors keep shipping, and the freeze quietly becomes four quarters. Second, it treats "modernize" as one decision you make once, for the whole system.&lt;/p&gt;

&lt;p&gt;It isn't. Refactor, replatform, or rewrite is a per-component verdict. And the way you avoid the freeze is to stop running modernization as a separate project and make it one backlog with feature delivery: build new capabilities as new services, refactor the code you're already touching, and rewrite only what genuinely can't be saved.&lt;/p&gt;

&lt;p&gt;The stakes of getting this wrong are documented. In a &lt;a href="https://vfunction.com/blog/survey-why-application-modernization-projects-fail/" rel="noopener noreferrer"&gt;2022 Wakefield Research survey&lt;/a&gt; of 250 senior IT leaders at large enterprises, 79% of app modernization projects failed, at an average cost of $1.5 million and a 16-month timeline. Those numbers describe big-bang programs at big companies — but the failure mode they capture is exactly the "pause everything, modernize, resume" shape most teams reach for by default.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The false trilemma: you don't choose once for the whole system&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Most refactor-vs-rewrite articles frame this as a single fork in the road. Pick a verb, apply it to the platform, done.&lt;/p&gt;

&lt;p&gt;Every real modernization I've been close to mixed the verbs — sometimes all three, over years.&lt;/p&gt;

&lt;p&gt;On &lt;a href="https://devico.io/success-stories/good-shape" rel="noopener noreferrer"&gt;GoodShape&lt;/a&gt;, a wellness platform that had been in production for about 15 years, we ran a microservices migration and heavy in-place refactoring at the same time: new capabilities went out as new services while hundreds of bugs got fixed and thousands of code smells got eliminated in the monolith that was still serving traffic.&lt;/p&gt;

&lt;p&gt;On &lt;a href="https://devico.io/success-stories/sunlayar" rel="noopener noreferrer"&gt;Sunlayar&lt;/a&gt;, the engagement started around the legacy QMPV solar-design platform — and only after working with it did the honest assessment land: continued maintenance would cost more than a rewrite, so a rewrite is what happened.&lt;/p&gt;

&lt;p&gt;Same company doing the work, opposite verdicts. Because the verdict belongs to the component and its situation, not to a philosophy.&lt;/p&gt;

&lt;p&gt;One warning before you adopt per-component thinking, though: it needs a whole-system owner. Somebody has to hold the target architecture in their head and decide which extraction happens next. Without that, "decide per component" degrades into a half-migrated estate that stays half-migrated forever — the worst of both worlds, permanently.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Diagnose what's actually failing before picking a verb&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;"Legacy" is not a diagnosis. It's a bucket for at least four different diseases that get treated very differently:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An obsolete stack — frameworks and runtimes past end-of-life.&lt;/li&gt;
&lt;li&gt;Missing documentation — the system's behavior lives in people's heads, some of whom left.&lt;/li&gt;
&lt;li&gt;Inconsistent code — written by different developers across different eras, no common standards.&lt;/li&gt;
&lt;li&gt;An architecture at its scalability ceiling — the structure itself can't take more load or more change.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first three are painful, but they're curable in place. Only the fourth justifies structural change — replatforming or rewriting.&lt;/p&gt;

&lt;p&gt;This distinction matters because tech debt pain is nearly universal and therefore nearly useless as a signal. In the &lt;a href="https://survey.stackoverflow.co/2024/professional-developers" rel="noopener noreferrer"&gt;2024 Stack Overflow survey&lt;/a&gt;, 62.4% of professional developers named technical debt as their top frustration — the number one answer among both ICs and managers. And McKinsey &lt;a href="https://www.mckinsey.com/capabilities/tech-and-ai/our-insights/tech-debt-reclaiming-tech-equity" rel="noopener noreferrer"&gt;estimated back in 2020&lt;/a&gt; that tech debt amounts to 20–40% of the value of a company's entire technology estate. If frustration were the trigger for a rewrite, everything would get rewritten. The trigger has to be sharper than that.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Architecture at its ceiling vs. code that's merely ugly&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;The question I actually ask: &lt;strong&gt;can this architecture carry the next two years of roadmap?&lt;/strong&gt; Not "is this code nice." Not "would we build it this way today." Nobody would build any five-year-old system the way they'd build it today; that's not a finding.&lt;/p&gt;

&lt;p&gt;GoodShape had all four diseases at once — no documentation, no common coding standards, an obsolete stack, &lt;em&gt;and&lt;/em&gt; a monolith that had nearly reached peak performance, with further scaling becoming impossible. That last item is what earned structural change. The other three would have been refactor-shaped on their own.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://devico.io/success-stories/medneo" rel="noopener noreferrer"&gt;medneo&lt;/a&gt;, a radiology-as-a-service provider operating across Germany and the UK, looked superficially similar — a Radiology Information System degrading under a growing patient load, with regressions creeping in as features accreted. But the architecture could still carry the roadmap. The diseases were slow code, inefficient database access, and outdated frameworks. Refactor-shaped, all of it.&lt;/p&gt;

&lt;p&gt;One more bias to name: inside teams overwhelmingly want the rewrite. It's more fun, and it promises escape from code they resent. But fifteen years of production code encodes fifteen years of edge cases, regulatory quirks, and 3 a.m. fixes that nobody remembers making. Joel Spolsky called the full rewrite &lt;a href="https://www.joelonsoftware.com/2000/04/06/things-you-should-never-do-part-i/" rel="noopener noreferrer"&gt;"the single worst strategic mistake"&lt;/a&gt; back in 2000, and the reason he gave — old code has been &lt;em&gt;used&lt;/em&gt;, and using code shakes bugs out of it — hasn't aged a day.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Refactor in place — when the foundation still holds&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Refactoring is the least glamorous verdict and the one I end up defending most often.&lt;/p&gt;

&lt;p&gt;On medneo, the work was exactly as unglamorous as it sounds: optimize hot paths, eliminate unnecessary database calls, upgrade legacy frameworks and libraries to supported versions, and stand up a CI/CD pipeline. Eight engineers, from 2019 to 2022.&lt;/p&gt;

&lt;p&gt;The outcomes — significantly reduced average load times, fewer outages, and new rollouts that stopped degrading performance — are qualitative, because the client doesn't publish before/after numbers. I'd rather tell you that plainly than invent a percentage. But the strategic point is quantitative enough: three years of platform improvement happened &lt;em&gt;while the RIS kept serving hospitals&lt;/em&gt;, with zero freeze, because none of that work required stopping feature delivery. Refactoring almost never does.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://devico.io/success-stories/soapbox" rel="noopener noreferrer"&gt;Soapbox&lt;/a&gt;, an e-commerce fulfillment platform, ran the same play at a much earlier stage: a React Native warehouse app that had shipped as a prototype — poorly structured code, plenty of bugs, no separation between dev, staging, and production. The team did a global refactoring of the core logic, put a linter in place, set up GitLab and CodePush, and split the environments — and shipped more than 15 releases along the way, including entirely new features like stocktaking, warehouse audit, and barcode scanning. Cleanup and delivery, one backlog, four people.&lt;/p&gt;

&lt;p&gt;The limitation is the one from the diagnosis section, and it's hard: refactoring cannot raise an architectural ceiling. If the structure itself is the disease, no amount of in-place cleanup cures it. You'll just have beautiful code that still can't scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Replatform incrementally — migration under live traffic&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The middle verdict is the one that actually answers this article's title, so it gets the most detail.&lt;/p&gt;

&lt;p&gt;GoodShape is the fullest version of it I've worked near. The platform had been in production about 15 years, serving 200+ UK employers — including NHS organizations, Philips, and Heathrow Express — with 24/7 nurse support riding on it. You do not take that offline for a rebuild. The verdict on the monolith was replatform: migrate toward microservices, incrementally, under live traffic. A team of 7, working since 2018, on Java 17 and Spring Boot with React/Redux up front.&lt;/p&gt;

&lt;p&gt;The mechanism is what matters, because it's the anti-freeze tactic in concrete form:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;New capabilities ship as new services.&lt;/strong&gt; Push notifications and fitness-tracker data collection — integrating 16 different tracker brands — were built as standalone microservices from day one. The roadmap item &lt;em&gt;was&lt;/em&gt; the migration step. Nobody had to choose between them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code you're already touching gets refactored.&lt;/strong&gt; A few hundred bugs fixed and thousands of code smells eliminated — not as a dedicated cleanup phase, but woven through feature work in the monolith.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Documentation gets written as you go&lt;/strong&gt;, capability by capability, instead of as a doomed up-front archaeology project.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The frontend got split into subprojects&lt;/strong&gt; and refactored with updated React components — same incremental logic, applied to the UI.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Even purely manual pain got folded in: monthly client reporting had been a hand-built 50-slide PowerPoint; it became an automated reporting feature. Roadmap value and modernization, again the same line item.&lt;/p&gt;

&lt;p&gt;If you want the underlying pattern with a name, it's Martin Fowler's &lt;a href="https://martinfowler.com/bliki/StranglerFigApplication.html" rel="noopener noreferrer"&gt;strangler fig&lt;/a&gt;: new growth wraps the old system until the old system is no longer load-bearing. Stripped to its skeleton, the runtime side is just routing — carve one capability's traffic away from the monolith at the edge:&lt;/p&gt;

&lt;h1&gt;
  
  
  Everything still goes to the monolith...
&lt;/h1&gt;

&lt;p&gt;location / {&lt;/p&gt;

&lt;p&gt;proxy_pass &lt;a href="http://legacy-monolith" rel="noopener noreferrer"&gt;http://legacy-monolith&lt;/a&gt;;&lt;/p&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;h1&gt;
  
  
  ...except the capabilities that have been extracted.
&lt;/h1&gt;

&lt;p&gt;location /api/notifications/ {&lt;/p&gt;

&lt;p&gt;proxy_pass &lt;a href="http://notification-service" rel="noopener noreferrer"&gt;http://notification-service&lt;/a&gt;;&lt;/p&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;location /api/tracker-data/ {&lt;/p&gt;

&lt;p&gt;proxy_pass &lt;a href="http://tracker-service" rel="noopener noreferrer"&gt;http://tracker-service&lt;/a&gt;;&lt;/p&gt;

&lt;p&gt;}&lt;/p&gt;

&lt;p&gt;The routing is the easy part. The discipline is the hard part, and it's the limitation to be honest about: this is a multi-year &lt;em&gt;posture&lt;/em&gt;, not a project with an end date — GoodShape's has been running since 2018. You need that whole-system owner deciding which extractions the roadmap actually justifies, or you'll extract whatever's fun instead of whatever's next.&lt;/p&gt;

&lt;p&gt;One more honesty note, since it's tempting to oversell: GoodShape won gold at the 2020 ECCCSA awards for application of technology while all this was mid-flight. I'm not claiming the migration caused that — the sources don't, so I won't. The claim I &lt;em&gt;am&lt;/em&gt; making is narrower and more useful: the business never had to stop operating, competing, or shipping while its 15-year-old core was being restructured underneath it. And the scalability gains came from the migration &lt;em&gt;plus*the refactoring *plus&lt;/em&gt; the process changes — not from microservices as a magic word.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Rewrite — the expensive verdict, and what earns it&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Sometimes the honest answer is that the patient doesn't recover in place.&lt;/p&gt;

&lt;p&gt;The threshold I use: rewrite when maintaining the old platform costs more than replacing it, &lt;em&gt;and&lt;/em&gt; little of it is worth preserving. Both conditions. The first alone describes half the software in production; it's the second that earns the verdict.&lt;/p&gt;

&lt;p&gt;Sunlayar met both. The QMPV platform for planning rooftop solar projects ran on obsolete technologies, and as complexity grew it became impossible to add new business functions quickly — the assessment concluded a rewrite would beat continued maintenance. So it was rebuilt as a new platform on Node.js, TypeScript, React, and MongoDB, by a team of 2 developers and 2 QA engineers, in an engagement running since 2014. The rewritten platform outlived its original owner: QMPV was later acquired by Esdec, and the platform is still in production today as EcoFasten's Design Assistant Project Calculator. (Again — the acquisition is a fact of the timeline, not a result I'm attributing to the rewrite.)&lt;/p&gt;

&lt;p&gt;&lt;a href="https://devico.io/success-stories/bego" rel="noopener noreferrer"&gt;BEGO&lt;/a&gt;, a German dental company, is the other flavor: not one obsolete platform but several independent portals, with an architecture where every new feature had to work around structural limitations. The verdict was rebuild-as-one-platform — React, TypeScript, NestJS, PostgreSQL, Kubernetes — delivered by a team of about six, with 14 major features, cooperation running since 2025, and a high daily order volume from launch.&lt;/p&gt;

&lt;p&gt;But here's the detail that matters most for this article: even BEGO wasn't a pure greenfield rewrite. The proven backend file-processing components were carefully reused. "Rewrite" as a verdict doesn't mean "salvage nothing" — it means salvage &lt;em&gt;selectively&lt;/em&gt;, keeping the parts that have earned their production scars.&lt;/p&gt;

&lt;p&gt;The limitation: rewrites only look fast in retrospect. Sunlayar's engagement spans over a decade; nothing about these timelines is quick. If someone pitches you a rewrite with a confident six-month schedule, that schedule is the first bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The process layer that keeps features shipping&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;None of the above works without an unglamorous substrate, and it shows up in every one of these cases.&lt;/p&gt;

&lt;p&gt;Environment separation, so modernization work and feature work can land in the same codebase without stepping on each other — Soapbox literally could not do both safely until dev, staging, and prod were split. CI/CD, so the cost of shipping small stays near zero — medneo's pipeline is what let optimization work roll out continuously instead of batching into risky releases. Automated linting, so a refactored module and a legacy module stop diverging in style the day after cleanup. Documentation as a habit, which is what GoodShape used to stop the no-docs disease from re-infecting every new service.&lt;/p&gt;

&lt;p&gt;The trap: process work reads as slowdown first. It's the line item that gets cut when the roadmap pressure is highest — which is exactly the moment you need it, because it's the only thing that lets one team serve two masters in one codebase.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The question to write down first&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The three verdicts aren't symmetric, and choosing between them gets easier once you say the asymmetry out loud.&lt;/p&gt;

&lt;p&gt;Refactoring is cheap and reversible — if it turns out to be insufficient, you've lost little and the code is better for whatever comes next. Incremental replatforming trades speed for continuity — slower than the big-bang fantasy, but the platform never stops earning. Rewriting is a one-way door — occasionally still the correct door, as Sunlayar and BEGO show, but you don't walk through it because the old code is annoying. You walk through it when maintenance costs more than replacement and almost nothing is worth saving — and even then, you carry the proven parts with you.&lt;/p&gt;

&lt;p&gt;So before picking a verb, write down the answer to one question, per component: &lt;strong&gt;can this architecture carry the next two years of roadmap?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Where yes — refactor what you touch, and keep shipping. Where no, but the capability boundaries are extractable — replatform incrementally, and let the roadmap decide the extraction order. Where no, and nothing's worth saving — rewrite, salvage selectively, and be honest with everyone about the years, not months, you just signed up for.&lt;/p&gt;

&lt;p&gt;The one answer that's never right is the one that sounds safest in the meeting: freeze everything and build v2 on the side. That's not a modernization strategy. That's the 79%.&lt;/p&gt;

</description>
      <category>saas</category>
      <category>softwareengineering</category>
      <category>programming</category>
      <category>architecture</category>
    </item>
    <item>
      <title>How to Keep a Data-Heavy React App Fast</title>
      <dc:creator>Vic Tekys</dc:creator>
      <pubDate>Mon, 31 Aug 2026 13:18:24 +0000</pubDate>
      <link>https://dev.to/devico-solutions/how-to-keep-a-data-heavy-react-app-fast-n1</link>
      <guid>https://dev.to/devico-solutions/how-to-keep-a-data-heavy-react-app-fast-n1</guid>
      <description>&lt;p&gt;A React dashboard that feels perfectly responsive with 200 rows may become almost unusable at 20,000. Memoization is often the first proposed fix, but React.memo cannot help much when the browser is laying out thousands of DOM nodes or a large data transformation is blocking the main thread.&lt;/p&gt;

&lt;p&gt;Those problems may produce the same visible symptom—a slow interface—but they have different causes. Rendering too much calls for virtualization. Expensive JavaScript may belong in a Web Worker. Oversized or repeatedly downloaded datasets point toward pagination, caching, or IndexedDB. Media introduces another set of costs.&lt;/p&gt;

&lt;p&gt;Getting the diagnosis right matters because users experience far more than the initial page load. Google estimates that roughly &lt;a href="https://web.dev/articles/inp" rel="noopener noreferrer"&gt;90%&lt;/a&gt; of a user's time on a page is spent after it has loaded, when responsiveness depends on how quickly the application handles input, updates data, and paints the next frame.&lt;/p&gt;

&lt;h2&gt;
  
  
  Find the bottleneck before choosing the optimization
&lt;/h2&gt;

&lt;p&gt;Most performance problems in data-heavy React applications fall into four categories.&lt;/p&gt;

&lt;p&gt;Rendering cost grows with the number and complexity of DOM nodes React creates, reconciles, and updates. A table containing 10,000 visible rows is expensive even if every component is neatly organized.&lt;/p&gt;

&lt;p&gt;Computation cost comes from JavaScript occupying the main thread. Sorting large arrays, parsing CSV files, filtering records, aggregating chart data, and transforming API responses can all delay keyboard input, scrolling, and rendering.&lt;/p&gt;

&lt;p&gt;Network cost usually appears as an oversized response or a chain of dependent requests. A 4 MB JSON payload is one kind of problem; twelve sequential API calls are another.&lt;/p&gt;

&lt;p&gt;Media cost becomes significant when an application loads large images, videos, or 3D assets before they are needed—or serves them at a higher resolution than the interface can display.&lt;/p&gt;

&lt;p&gt;Memoization has a narrower role than it is often given. React.memo and useCallback can prevent avoidable component updates, while useMemo can prevent the same calculation from being repeated when its dependencies have not changed. None of them makes an unavoidable 400 ms transformation cheap, reduces a large API response, or removes thousands of DOM nodes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with the React Profiler
&lt;/h2&gt;

&lt;p&gt;Record the interaction that feels slow: changing a filter, sorting a table, opening a tab, or selecting a chart range.&lt;/p&gt;

&lt;p&gt;The React DevTools Profiler shows which components rendered, how long the commit took, and what triggered the update. A wide flame graph containing hundreds of component renders usually points to a rendering problem. Depending on what the trace shows, the answer may be virtualization, more stable props, narrower state subscriptions, or selective memoization.&lt;/p&gt;

&lt;p&gt;The same profiling model applies in React 18 and 19, so upgrading React does not change the basic diagnostic workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check what is blocking the main thread
&lt;/h2&gt;

&lt;p&gt;Record the same interaction in the Chrome DevTools Performance panel. Look for long tasks—work that occupies the main thread for &lt;a href="https://developer.chrome.com/docs/web-platform/long-animation-frames" rel="noopener noreferrer"&gt;50 ms or more&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;A long JavaScript block during a calculation, without a corresponding burst of React rendering, suggests a computation bottleneck. Virtualizing a list will not fix it. The calculation may need to be reduced, scheduled differently, or moved to a Web Worker.&lt;/p&gt;

&lt;p&gt;Chrome's &lt;a href="https://developer.chrome.com/blog/loaf-has-shipped" rel="noopener noreferrer"&gt;Long Animation Frames API&lt;/a&gt;, available since Chrome 123, adds more detail by attributing slow frames to the scripts responsible for them. That makes it useful when investigating poor Interaction to Next Paint results.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read the request waterfall
&lt;/h2&gt;

&lt;p&gt;The Network panel answers two different questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How much data is being transferred?&lt;/li&gt;
&lt;li&gt;Are requests happening in parallel or waiting unnecessarily for one another?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A single 3 MB response calls for pagination, compression, or a smaller field selection. A diagonal sequence of independent requests suggests a waterfall that could be flattened. Client-side caching may improve repeat navigation, but it does not make the first oversized response smaller.&lt;/p&gt;

&lt;p&gt;The central distinction is simple: are there too many elements to render, or is one piece of JavaScript preventing the browser from rendering at all? The React Profiler is best suited to the first question; the Performance panel helps answer the second.&lt;/p&gt;

&lt;h2&gt;
  
  
  Match the symptom to the fix
&lt;/h2&gt;

&lt;p&gt;The techniques in this article are complementary, but they are not interchangeable.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If the page janks while scrolling through a long table or list, the likely cause is rendering overload created by thousands of DOM nodes. Start with list or table virtualization using react-window, TanStack Virtual, or React Virtuoso. Keep in mind that off-screen content will not be present in the DOM, so accessibility, find-in-page, and SEO may require additional work.&lt;/li&gt;
&lt;li&gt;If the interface freezes during sorting, filtering, parsing, or aggregation, heavy computation is probably blocking the main thread. Try reducing the amount of work or moving the calculation to a Web Worker. Workers add serialization overhead, cannot access the DOM, and require additional coordination code.&lt;/li&gt;
&lt;li&gt;If a large dataset is repeatedly downloaded or kept in memory, consider persisting suitable data in IndexedDB with Dexie.js, idb, or RxDB. This reduces repeated loading and memory pressure, but the application must handle browser eviction, synchronization, schema changes, and stale data.&lt;/li&gt;
&lt;li&gt;If the same requests run repeatedly or form a network waterfall, use TanStack Query or SWR together with data prefetching and parallel requests. The trade-off is that caching introduces questions around invalidation, staleness, and memory consumption.&lt;/li&gt;
&lt;li&gt;If images, videos, or 3D assets delay interaction, start with compression, right-sizing, lazy loading, and intent-based preloading. Placeholders require additional UX work, while excessive prefetching can waste bandwidth.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A complex application may need several of these changes. A large 3D product viewer, for instance, might use a Worker for calculations, IndexedDB for model data, virtualization for property lists, and preloading for media. The important part is that each technique addresses a measured cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Virtualization, pagination, or both?
&lt;/h2&gt;

&lt;p&gt;Virtualization and pagination both limit how much work the browser performs, but they change the product in different ways.&lt;/p&gt;

&lt;p&gt;Virtualization maintains the experience of one continuous dataset. It renders only the rows inside or near the viewport and reuses DOM nodes as the user scrolls. A table may contain 50,000 records while keeping only a few dozen row elements mounted.&lt;/p&gt;

&lt;p&gt;That makes virtualization a natural fit for authenticated dashboards, chat histories, log viewers, data grids, and infinite feeds. The compromise is that off-screen items do not exist in the DOM. Browser find-in-page cannot discover them, crawlers cannot index them, and some assistive-technology workflows require deliberate focus and navigation handling.&lt;/p&gt;

&lt;p&gt;Pagination places a fixed number of items on each page and provides controls for moving between pages. It is usually easier to make crawlable, linkable, and accessible. Public search results, product categories, article indexes, and other SEO-relevant listings generally benefit from this model.&lt;/p&gt;

&lt;p&gt;A hybrid approach is often the practical answer. The server returns a bounded page of records, and the client virtualizes that page if it is still large. Payload sizes remain controlled, while the browser avoids mounting more rows than the user can see.&lt;/p&gt;

&lt;p&gt;Remember that grids can require two forms of virtualization. Row virtualization helps when there are thousands of records. Column virtualization matters when a financial or analytical grid contains dozens of columns. A library that handles simple lists well may not be equally capable with two-dimensional grids, sticky columns, variable row heights, and keyboard navigation.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Virtualization lets the user scroll through one continuous list while keeping only the visible rows in the DOM. It works well for dashboards, feeds, logs, chat histories, and authenticated applications. The downside is that off-screen content may not be indexed by search engines, while accessibility and browser find-in-page require additional work.&lt;/li&gt;
&lt;li&gt;Pagination divides results into separate pages and keeps the number of DOM elements under control. Pages can be indexed, linked, and shared individually. It is usually the better choice for public listings, product directories, article indexes, and search results.&lt;/li&gt;
&lt;li&gt;Server pagination combined with client-side virtualization provides a middle ground. The server returns a limited page of data, while the client renders only the visible part of that page. This works well for large authenticated datasets and complex data grids, although it requires managing both pagination and virtualization.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For libraries, react-window remains a focused option for straightforward lists and is &lt;a href="https://www.npmjs.com/package/react-window" rel="noopener noreferrer"&gt;actively maintained&lt;/a&gt;. Its older sibling, &lt;a href="https://www.npmjs.com/package/react-virtualized" rel="noopener noreferrer"&gt;react-virtualized&lt;/a&gt;, is rarely the first choice for a new implementation.&lt;/p&gt;

&lt;p&gt;TanStack Virtual is headless, which suits teams that want complete control over markup and styling or already use the TanStack ecosystem. React Virtuoso provides more behavior out of the box and is particularly useful for variable-height content.&lt;/p&gt;

&lt;p&gt;The library decision should follow the interface requirements. A basic fixed-height list does not need the same machinery as an editable grid with pinned columns, dynamic heights, grouped rows, and accessible keyboard navigation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Move expensive calculations to Web Workers
&lt;/h2&gt;

&lt;p&gt;When a trace shows a long calculation without a corresponding rendering spike, moving that work off the main thread can restore responsiveness. A Web Worker can sort, parse, aggregate, or transform data while the browser continues to process input and paint frames.&lt;/p&gt;

&lt;p&gt;Workers have firm boundaries. They cannot access the DOM, window, or localStorage. They can use fetch, WebSockets, and IndexedDB, which means a worker can retrieve, transform, and persist a large dataset without routing every step through the UI thread.&lt;/p&gt;

&lt;p&gt;Messages between the main thread and a worker normally use the structured-clone algorithm. Large values must be copied, and that serialization cost can erase part of the expected performance gain.&lt;br&gt;
Transferable objects avoid that copy for resources such as ArrayBuffer. Ownership moves from one thread to the other, leaving the original buffer unusable on the sending side. For binary files, large typed arrays, and some data-processing pipelines, this can be considerably cheaper than cloning.&lt;/p&gt;

&lt;p&gt;SharedArrayBuffer allows genuine shared memory, but it introduces stricter deployment requirements. The page must be &lt;a href="https://web.dev/articles/coop-coep" rel="noopener noreferrer"&gt;cross-origin isolated&lt;/a&gt; using COOP and COEP headers, which may conflict with embedded third-party content. Most applications can use transferable objects without taking on that constraint.&lt;/p&gt;

&lt;p&gt;The native postMessage interface is manageable for one or two worker operations but becomes tedious as the API grows. &lt;a href="https://github.com/GoogleChromeLabs/comlink" rel="noopener noreferrer"&gt;Comlink&lt;/a&gt;, a small library from Google Chrome Labs, wraps worker communication in a proxy so asynchronous worker functions can be called more like ordinary methods.&lt;/p&gt;

&lt;p&gt;A worker pool can help when the application runs many independent jobs. Instead of creating a worker for every task, the application maintains a small number of workers and feeds them work from a queue. The pool size still needs restraint: saturating every available core may speed up the calculation while competing with the browser for resources.&lt;/p&gt;

&lt;p&gt;Workers do not reduce layout or painting cost. A page that mounts 10,000 rows will remain expensive after its data transformation moves off-thread. Workers address computation; virtualization addresses rendering.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use IndexedDB when the browser needs a real local data store
&lt;/h2&gt;

&lt;p&gt;React state is not persistent storage, and localStorage is poorly suited to large datasets. It stores strings, operates synchronously, and commonly offers only a few megabytes per origin. Reading or parsing a large value can block the main thread before the application renders anything useful.&lt;/p&gt;

&lt;p&gt;IndexedDB is asynchronous and transactional, with a practical capacity far beyond localStorage, depending on the browser, device, and available disk space. It can store objects, typed arrays, Blob values, and files without manually converting everything to JSON.&lt;/p&gt;

&lt;p&gt;That makes it useful for offline-first and local-first products, large working sets that must survive a reload, and expensive data that can be reconstructed but should not be fetched on every visit.&lt;/p&gt;

&lt;p&gt;The native IndexedDB API is verbose and event-driven, so most React applications use a wrapper:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Dexie.js provides a promise-based database and query layer.&lt;/li&gt;
&lt;li&gt;idb stays close to the native API while replacing the event-based interface with promises.&lt;/li&gt;
&lt;li&gt;RxDB adds reactive queries and replication for applications that need live synchronization.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Choosing IndexedDB changes more than storage. Once a working copy lives in the browser, the application needs rules for refreshing it, resolving conflicts, migrating schemas, and distinguishing fresh data from stale data. A local-first architecture is a data-model decision, not a larger version of a cache.&lt;/p&gt;

&lt;p&gt;Eviction also has to be expected. Browsers may remove storage under pressure. Safari applies a particularly strict tracking-prevention policy under which script-writable storage, including IndexedDB, can be deleted after seven days without user interaction with the site.&lt;/p&gt;

&lt;p&gt;Unless the product explicitly guarantees durable browser storage, treat IndexedDB as a local copy that can be rebuilt. The server—or another controlled persistence layer—should remain the source of truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cache repeat work and remove network waterfalls
&lt;/h2&gt;

&lt;p&gt;After rendering and computation have been addressed, repeated network work often becomes the next visible delay.&lt;/p&gt;

&lt;p&gt;TanStack Query and SWR solve several problems that otherwise end up scattered through components: request deduplication, cached results, background refreshes, retry behavior, and stale-data handling. They can return an existing result immediately during repeat navigation instead of showing another loading state for data the user has already seen.&lt;/p&gt;

&lt;p&gt;TanStack Query's defaults deserve attention. It considers fetched data &lt;a href="https://tanstack.com/query/v5/docs/framework/react/guides/important-defaults" rel="noopener noreferrer"&gt;stale immediately&lt;/a&gt; by default, while unused cache entries are garbage-collected after five minutes. Those settings are safe but can produce unnecessary refetches when the underlying data changes infrequently. staleTime should reflect how quickly each resource can become outdated, not one global guess for the entire application.&lt;/p&gt;

&lt;p&gt;Both libraries support the stale-while-revalidate pattern: return cached data first, verify it in the background, and update the interface if the response has changed. It improves perceived performance because repeat visits do not begin with an empty screen.&lt;br&gt;
Prefetching can remove the remaining wait from predictable navigation. Route data can be requested when the user hovers over a link, focuses a control, or reaches a point where the next action is likely. Prefetch too aggressively, however, and the application downloads data the user never requests.&lt;/p&gt;

&lt;p&gt;Request structure matters just as much as caching. Independent calls should begin together through Promise.all, parallel queries, or a backend endpoint designed to aggregate them. A five-request waterfall cannot be corrected by memoizing the component that waits for it.&lt;br&gt;
Media benefits from the same discipline. A gallery, 3D viewer, or image-heavy customer interface can preload the next likely asset, retain previously fetched media, and lazy-load everything outside the active view. Service-worker caching through Workbox can extend the approach to unreliable connections and offline use.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measure whether the change helped
&lt;/h2&gt;

&lt;p&gt;A performance change is only useful if it improves the interaction users actually perform.&lt;/p&gt;

&lt;p&gt;Interaction to Next Paint (INP) replaced First Input Delay as a Core Web Vital in March 2024. Unlike FID, which evaluated only the first interaction, INP considers interactions across the visit. A result of 200 ms or less at the 75th percentile is considered good.&lt;/p&gt;

&lt;p&gt;That makes INP relevant to data-heavy React products, where roughly 90% of a user's time may occur after the initial load. A dashboard can achieve an excellent Largest Contentful Paint score and still feel slow every time someone filters a table.&lt;/p&gt;

&lt;p&gt;Core Web Vitals will not capture every product-specific operation. “Time to render 10,000 rows,” “time to display the first interactive chart,” and “duration of a CSV import” require custom instrumentation.&lt;/p&gt;

&lt;p&gt;The Performance API provides the necessary primitives:&lt;br&gt;
performance.mark("filter-start");&lt;/p&gt;

&lt;p&gt;// Run the operation.&lt;/p&gt;

&lt;p&gt;performance.mark("filter-end");&lt;br&gt;
performance.measure("filter-duration", "filter-start", "filter-end");&lt;/p&gt;

&lt;p&gt;Benchmark before and after under the same conditions: identical dataset, device profile, browser version, and network throttling. Comparing a developer laptop with a production user's older machine produces an impressive number, but not a useful one.&lt;/p&gt;

&lt;p&gt;Lab and field measurements answer different questions. Lighthouse and local traces provide controlled, repeatable feedback while engineers iterate. Real-user monitoring, CrUX, and the web-vitals library show what happens across the hardware and network conditions users actually have. Lab data helps isolate the change; field data confirms that the improvement survived production.&lt;/p&gt;

&lt;h2&gt;
  
  
  How these techniques combine in production
&lt;/h2&gt;

&lt;p&gt;The strongest implementations rarely depend on one optimization.&lt;/p&gt;

&lt;p&gt;Vitus, a construction-intelligence platform that works with several large 3D models, combined Web Workers, list virtualization, and IndexedDB. Model calculations moved off the main thread. React Virtuoso handled long property and data lists because pagination did not fit the product requirements. Dexie.js stored large model data locally instead of keeping the entire working set in React state.&lt;/p&gt;

&lt;p&gt;A separate change—from the previous model format to SVF2 with parallel loading—cut model size roughly &lt;a href="https://devico.io/success-stories/vitus" rel="noopener noreferrer"&gt;5× and load time about 2.5×&lt;/a&gt;. That result came from addressing several layers: computation, rendering, storage, and network transfer.&lt;/p&gt;

&lt;p&gt;Datasport, a sports-event platform with chart- and table-heavy dashboards, needed a different combination. Large tables were &lt;a href="https://devico.io/success-stories/datasport" rel="noopener noreferrer"&gt;virtualized&lt;/a&gt; because pagination was not suitable, GET requests were cached, and component memoization reduced unnecessary rendering. React Context replaced Redux in areas where a smaller state layer was sufficient. Web Workers were not part of the solution because main-thread computation was not the defining bottleneck.&lt;/p&gt;

&lt;p&gt;The Sales Platform, a media-heavy fashion-tech interface, paired virtualization with a dedicated media layer. Virtualized rendering reduced the number of mounted elements by about &lt;a href="https://devico.io/success-stories/sales-platform-for-seamless-customer-interactions" rel="noopener noreferrer"&gt;70%&lt;/a&gt;, while preloading and caching reduced wait times by 50%. SWR managed data caching, and Workbox supported service-worker caching and offline behavior.&lt;/p&gt;

&lt;p&gt;The same constraints appear in analytics products. &lt;a href="https://devico.io/success-stories/gaze-health" rel="noopener noreferrer"&gt;GazeHealth&lt;/a&gt;, a mental-health analytics platform, renders a custom D3 Sankey diagram alongside roughly fifteen data visualizations over large datasets. &lt;a href="https://devico.io/success-stories/good-shape" rel="noopener noreferrer"&gt;GoodShape&lt;/a&gt;, an employee-wellness platform, combines complex charts with AG Grid tables and optimized large-data processing.&lt;/p&gt;

&lt;p&gt;The projects use related tools, but not identical stacks. That is the useful lesson: performance work begins with the bottleneck, not with a preferred library.&lt;/p&gt;

&lt;h2&gt;
  
  
  When specialized performance engineering makes sense
&lt;/h2&gt;

&lt;p&gt;One slow table or blocking filter is usually a bounded problem. An experienced engineer can profile the interaction, identify the expensive work, and implement a focused fix without redesigning the application.&lt;/p&gt;

&lt;p&gt;The decision changes when performance problems cross architectural boundaries. Worker APIs, client-side databases, synchronization rules, data-fetching layers, and two-dimensional virtualization all create decisions that other parts of the product will depend on. Those changes need ongoing ownership.&lt;/p&gt;

&lt;p&gt;Capacity matters as well. A team may understand the problem but lack time to investigate it without delaying product work. Engineers who have already implemented virtualized grids, worker pipelines, and IndexedDB-backed data layers can often recognize the performance signature faster than a team encountering it for the first time.&lt;/p&gt;

&lt;p&gt;External help is most useful when the scope is explicit. A short engagement can address a measured bottleneck. A dedicated team is more appropriate when performance work affects the application's data architecture and will continue across multiple releases.&lt;/p&gt;

</description>
      <category>react</category>
      <category>webperf</category>
      <category>javascript</category>
      <category>performance</category>
    </item>
  </channel>
</rss>
