<?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: Vic Tekys</title>
    <description>The latest articles on DEV Community by Vic Tekys (@vic-tekys).</description>
    <link>https://dev.to/vic-tekys</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4102466%2F14e825e0-2390-4f08-989f-64fb9c9d2003.jpeg</url>
      <title>DEV Community: Vic Tekys</title>
      <link>https://dev.to/vic-tekys</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vic-tekys"/>
    <language>en</language>
    <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>
