DEV Community

Cover image for How to Scale a SaaS Platform 10 Without a Full Rewrite
Vic Tekys for Devico Solutions

Posted on

How to Scale a SaaS Platform 10 Without a Full Rewrite

A barcode scan took 3–5 seconds. One scan per package. Hundreds of thousands of packages.

That was the state of Sway, 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.

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.

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 2022 Wakefield Research survey 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: 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.The architecture usually survives. The hot paths don't.

Define the unit before you name the bottleneck

"We need to scale 10×" is not a requirement until you say 10× of what. 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.

Across the projects I'll use as evidence here, the unit was different every time:

  • Sway: the package. Every package gets scanned, routed, stored, and reported on.
  • CYDEF, a Canadian cybersecurity company whose analysts review 100% of endpoint activity: the telemetry message, arriving at hundreds of gigabytes per day.
  • medneo, 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.
  • MINT, an Italian AdTech platform managing media investment across 16 channels and 350+ vendor integrations: the release.

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.

Why a 3-second delay counts as a scaling cost and not a cosmetic one: Jakob Nielsen's response-time research 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.

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.

Profile the hot path, then change architecture around it

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

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.

The mental model I use is Brendan Gregg's USE method: 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.

If your SaaS runs on PostgreSQL you're in the majority: in the 2025 Stack Overflow Developer Survey, 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 PostgreSQL docs 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:

SELECT

calls,

round(total_exec_time::numeric, 0)  AS total_ms,

round(mean_exec_time::numeric, 2)   AS mean_ms,

rows,

left(query, 80)                     AS query

FROM pg_stat_statements

ORDER BY total_exec_time DESC

LIMIT 15;

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.

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.

The data layer carries most of the 10×

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.

Indexes and materialized views vs. more instances

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.

The "just buy more instances" reflex also has a bill attached that most engineering teams never see. In Flexera's 2025 State of the Cloud report, 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.

What we shipped on the data side of Sway:

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

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.

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:

CREATE MATERIALIZED VIEW daily_parcel_stats AS

SELECT

date_trunc('day', created_at) AS day,

hub_id,

status,

count(*)                      AS parcels

FROM parcels

GROUP BY 1, 2, 3;

  • - CONCURRENTLY requires a unique index covering all rows

CREATE UNIQUE INDEX ON daily_parcel_stats (day, hub_id, status);

  • - On a schedule (cron, pg_cron, a Lambda):

REFRESH MATERIALIZED VIEW CONCURRENTLY daily_parcel_stats;

The PostgreSQL docs 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.

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.

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.

Store less: queues, retention, and the cost of keeping everything

The second data-layer lever is the one teams skip because it feels like deleting value: stop storing everything.

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.

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.

A checklist I now run on any table over a few hundred million rows:

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

Get the firehose off the request path

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.

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.

The changes:

  1. Ingestion moved to Azure IoT Hub, which the case study reports receiving, processing, and storing millions of messages. For scale, the IoT Hub pricing tiers 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.
  2. 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.
  3. An internal device-monitoring tool replaced the manual, labor-intensive process of watching devices and resolving issues.

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.

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.

Split the work, not just the code

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

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.

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.

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:

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

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.

For context on what "several releases per day" means: in the 2024 DORA report, as summarized by Octopus Deploy, 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.

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.

Keeping the ceiling from coming back

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.

The price of not doing that is measurable. In the Uptime Institute's 2024 Annual Outage Analysis, 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.

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.

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.

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.

When incremental scaling stops being honest

Everything above is an argument against the rewrite reflex. It's also an argument against its mirror image: the belief that tuning is infinite.

Incremental scaling ends in two places I've seen:

  1. Where the infrastructure is manual, undocumented, and per-client. 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.
  2. Where ingestion can't be batched. 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.

The diagnostic is simple to state and hard to fake. After you've optimized, plot cost per unit against volume:

  • Flat 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.
  • Rising 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.

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.

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.

Top comments (0)