# Scaling Down Before Scaling Up: Why Bigger Servers Don’t Fix Bad Architecture

## **Why Scaling Infrastructure Wasn’t the Answer**

When an application becomes slow, the first instinct is often predictable: add more memory, upgrade the server, increase database capacity, or scale vertically.

Sometimes, that works until it doesn’t.

We experienced this firsthand when an application that had been running smoothly on a 64 GB server started crashing after being moved to a basic 2 GB instance.

What made the situation unusual was the workload. There were only 4–5 active users, a limited dataset, and no major traffic spikes. Yet the application experienced database connection spikes, high API latency, frequent request timeouts, and unstable performance.

**This wasn’t a scaling problem. It was an architecture problem.**

## **The Problem: When a 64 GB Server Hid the Real Issue**

### **What we observed**

The infrastructure changed, but the workload did not.

![](https://cdn.hashnode.com/uploads/covers/5fae473f8f0a02655347989e/963f4751-5cee-4218-a82a-9f079fb3e818.png align="center")

Our initial assumption was that the smaller server lacked sufficient resources. But the connection behavior and latency suggested something deeper.

- Database connections were spiking despite low traffic.
- API latency remained high.
- Requests frequently timed out.
- Connection pooling stabilized the number of connections but did not resolve the latency issue.

## **The Architecture Before Refactoring**

The application used Hasura, [**<u>Node.js services</u>**](https://geekyants.com/hire-nodejs-developers), PostgreSQL, and multiple [**<u>Dockerized services</u>**](https://geekyants.com/blog/streamlining-devops-with-ansible). The biggest issue was in the request path for Hasura Actions.

### **The problematic request lifecycle**

The Node.js service was calling back through Hasura instead of using a direct database path where appropriate.

![](https://cdn.hashnode.com/uploads/covers/5fae473f8f0a02655347989e/1823877d-2b5c-4d5e-9b63-c21dd1741fca.png align="center")

This created a circular dependency between Hasura and Node.js. A single request could therefore involve multiple network hops, [**<u>GraphQL</u>**](https://geekyants.com/hire-graphql-api-developers) execution layers, serialization and parsing, and additional database connection usage.

**Hasura → Node.js → Hasura → PostgreSQL was doing more work than the request actually required.**

## **Why Connection Pooling Wasn’t the Final Fix**

### **Symptom vs. root cause**

![](https://cdn.hashnode.com/uploads/covers/5fae473f8f0a02655347989e/a12ada67-e48d-4b78-8358-daa1fa31af80.png align="center")

PgPool/PgBouncer helped control the number of active database connections, but it did not reduce the amount of work being performed per request.

That distinction mattered. We had solved connection management, not query efficiency or architectural overhead.

**Connection pooling controls concurrency; it does not automatically make inefficient work efficient.**

## **Breaking the Loop: Rethinking the Data Access Layer**

### **The simplified architecture**

For straightforward database operations, remove unnecessary internal round trips.

### **Where Node.js remains valuable**

![](https://cdn.hashnode.com/uploads/covers/5fae473f8f0a02655347989e/9f78585d-673b-4889-8afa-731307f36885.png align="center")

We reduced unnecessary Hasura Actions and removed circular Hasura → Node.js → Hasura flows where direct database access was more appropriate.

Hasura continued to handle client-facing GraphQL access, CRUD, filtering, and aggregations. Node.js remained responsible for business logic that genuinely required an application service, authentication flows, external integrations, and asynchronous workflows.

**The goal was not to remove Hasura or Node.js. It was to give every layer a clear responsibility.**

## **The Cost of Over-Abstraction**

GraphQL, [**<u>microservices</u>**](https://geekyants.com/blog/microservices-architecture-from-theory-to-practice), Hasura Actions, and Node.js each solve real problems. The issue occurs when every request is forced through every abstraction.

- More network hops
- More serialization/deserialization
- Higher latency
- Duplicate responsibilities
- More database connection churn
- Harder debugging and observability

**Abstraction should reduce complexity—not increase the number of steps required to complete a request.**

## **Database Optimization: Reducing Work at the Source**

### **1\. Eliminate Redundant Queries**

#### **Redundant request pattern**

![](https://cdn.hashnode.com/uploads/covers/5fae473f8f0a02655347989e/6fbbfaa2-67b6-45f4-a4a6-22119fbcdcfe.png align="center")

#### **Optimized request pattern**

<img src="https://geekyants.com/_next/image?url=https%3A%2F%2Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%2Fmedia%2F2026%2F09%2Fa2c3ce1d-8a89-4e83-9b79-d642f8c28106.png&amp;w=3840&amp;q=75" alt="One structured database query returns only the data required by the API" width="1920" height="440" />

We consolidated repeated reads into fewer, better-structured queries. This reduced query count, database pressure, network traffic, and response time.

### **2\. Solve N+1 Query Patterns**

#### **N+1 pattern**

<img src="https://geekyants.com/_next/image?url=https%3A%2F%2Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%2Fmedia%2F2026%2F09%2Fa2c47153-d444-410c-aa54-df6dcf10364f.png&amp;w=3840&amp;q=75" alt="N+1 query pattern fetching parents first and then querying each child separately" width="1920" height="680" />

#### **Optimized pattern**

<img src="https://geekyants.com/_next/image?url=https%3A%2F%2Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%2Fmedia%2F2026%2F09%2Fa2c3ce88-f00d-4863-8ec0-397b379a77c2.png&amp;w=3840&amp;q=75" alt="JOIN, CTE, or database function returns parents and related data in one result" width="1920" height="480" />

Instead of repeatedly fetching related entities, we used joins, CTEs, and [**<u>PostgreSQL</u>**](https://geekyants.com/hire-postgresql-developers) functions where appropriate.

### **3\. Indexing Strategy**

#### **Indexing decision flow**

<img src="https://geekyants.com/_next/image?url=https%3A%2F%2Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%2Fmedia%2F2026%2F09%2Fa2c471a6-9098-4c02-97ca-03a1eae67bea.png&amp;w=3840&amp;q=75" alt="Indexing workflow from real query patterns through EXPLAIN ANALYZE and index validation" width="1920" height="560" />

We added indexes based on actual access patterns rather than indexing fields indiscriminately. Foreign keys, join columns, filtering fields, sorting fields, and pagination patterns were the primary candidates.

The goal was predictable query performance. Every index also carries storage and write-maintenance costs.

### **4\. Schema Design and Constraints**

- Use clear and consistent naming conventions.
- Define foreign-key relationships explicitly.
- Use NOT NULL, UNIQUE, and CHECK constraints where the data model requires them.
- Keep relationships normalized where appropriate.
- Review RBAC policies for both correctness and query efficiency.

Database constraints document the data model and enforce important data rules at the source.

## **PostgreSQL Functions and Triggers: Put the Right Work in the Database**

### **PostgreSQL Functions**

#### **When is a PostgreSQL function useful**

<img src="https://geekyants.com/_next/image?url=https%3A%2F%2Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%2Fmedia%2F2026%2F09%2Fa2c471f1-769e-4045-8b22-96c8c674f536.png&amp;w=3840&amp;q=75" alt="PostgreSQL function combines joins, filters, and aggregation into a pre-shaped API result" width="1920" height="480" />

Functions are useful for complex database-oriented fetching, reusable calculations, aggregations, and returning data in a form that reduces application-layer processing.

### **Database Triggers**

#### **Trigger use case**

<img src="https://geekyants.com/_next/image?url=https%3A%2F%2Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%2Fmedia%2F2026%2F09%2Fa2c47222-eb20-42a6-bb0b-aa964a6bdea1.png&amp;w=3840&amp;q=75" alt="Database trigger updates a counter, derived value, or audit record during a transaction" width="1920" height="520" />

Triggers are appropriate for deterministic database responsibilities such as maintaining counters, derived values, timestamps, audit records, or data-integrity behavior.

**Use triggers for deterministic database responsibilities—not as a hidden replacement for the entire application layer.**

## **Database Events vs. External Workflows**

### **Keep external work out of the critical transaction**

<img src="https://geekyants.com/_next/image?url=https%3A%2F%2Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%2Fmedia%2F2026%2F09%2Fa2c47c36-9082-4d5b-9c86-b6a86d3a354d.png&amp;w=3840&amp;q=75" alt="Committed database change queues asynchronous email, notification, or external API work" width="1920" height="520" />

Email delivery, push notifications, and other external calls should be handled asynchronously when the user-facing database transaction does not need to wait for the external system.

## **Views and Materialized Views**

### **Normal Views**

#### **Reusable read model**

<img src="https://geekyants.com/_next/image?url=https%3A%2F%2Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%2Fmedia%2F2026%2F09%2Fa2c47c61-c90d-415d-9045-913b71adb8e9.png&amp;w=3840&amp;q=75" alt="Database view provides a reusable read model over underlying tables" width="1920" height="440" />

Normal views are useful when you want to encapsulate reusable joins and filtering logic while still reading the current underlying data.

### **Materialized Views**

#### **Precomputed analytics path**\

<img src="https://geekyants.com/_next/image?url=https%3A%2F%2Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%2Fmedia%2F2026%2F09%2Fa2c47c8d-604d-49d2-8cf4-ae4f400cc3ed.png&amp;w=3840&amp;q=75" alt="Materialized view precomputes heavy joins and aggregations for an analytics API" width="1920" height="480" />

Materialized views are useful when expensive aggregations do not need to be recalculated for every request.

For appropriate workloads, REFRESH MATERIALIZED VIEW CONCURRENTLY can reduce disruption during refreshes. A suitable unique index is required for concurrent refreshes.

#### **Safe refresh strategy**

<img src="https://geekyants.com/_next/image?url=https%3A%2F%2Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%2Fmedia%2F2026%2F09%2Fa2c47cb2-8dde-4018-b50a-d89683f6e30c.png&amp;w=3840&amp;q=75" alt="Scheduled concurrent materialized-view refresh keeps analytics reads available" width="1920" height="520" />

## **Optimizing Analytics APIs**

### **Avoid repeated runtime calculations**

<img src="https://geekyants.com/_next/image?url=https%3A%2F%2Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%2Fmedia%2F2026%2F09%2Fa2c47cdf-1236-4181-8f62-69a738c48b23.png&amp;w=3840&amp;q=75" alt="Analytics API reads precomputed summaries and optionally uses Redis caching" width="1920" height="480" />

Analytics endpoints often perform expensive joins and aggregations over transactional data. We reduced this work by pre-aggregating data, using materialized views, caching suitable results, and minimizing on-the-fly calculations.

**If an expensive value does not need second-by-second freshness, don’t calculate it from scratch on every request.**

## **API Optimization Principles**

### **Pagination and Filtering**

#### **Efficient list API**

<img src="https://geekyants.com/_next/image?url=https%3A%2F%2Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%2Fmedia%2F2026%2F09%2Fa2c47d15-92ff-41b4-93b0-4a3f6d39bd53.png&amp;w=3840&amp;q=75" alt="Efficient list API applies database filters, cursor sorting, limits, and a minimal payload" width="1920" height="480" />

Pagination and filtering should happen as close to the database as possible. Cursor-based pagination can be useful for large or frequently changing datasets.

### **Reduce Over-Fetching**

#### **Minimal response path**

<img src="https://geekyants.com/_next/image?url=https%3A%2F%2Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%2Fmedia%2F2026%2F09%2Fa2c47d4f-b841-4c83-a55c-fa9f9b542f12.png&amp;w=3840&amp;q=75" alt="API selects required fields and serializes a minimal response payload" width="1920" height="480" />

Avoid SELECT \* and fields that the client does not need. Smaller payloads reduce database work, serialization, network transfer, and client-side processing.

### **Computed Fields**

#### **Avoid repeated runtime computation**

<img src="https://geekyants.com/_next/image?url=https%3A%2F%2Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%2Fmedia%2F2026%2F09%2Fa2c47dda-4913-4a30-bd6d-26febf207075.png&amp;w=3840&amp;q=75" alt="API reuses a precomputed or cached value instead of calculating it for every request" width="1920" height="480" />

Expensive computed fields can introduce latency when evaluated for every record. Where appropriate, use precomputed values, summary tables, materialized views, or caching.

## **GraphQL: Powerful, but Give It Guardrails**

- Avoid unnecessarily deep nested queries.
- Control expensive computed fields.
- Watch for N+1 patterns.
- Limit excessive Hasura Actions.
- Avoid using GraphQL as an internal service-to-service communication protocol when a more direct mechanism is appropriate.
- Return only the data required by the client.

### **Healthy boundary**

<img src="https://geekyants.com/_next/image?url=https%3A%2F%2Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%2Fmedia%2F2026%2F09%2Fa2c3d129-a023-465b-9bca-d44ff0bf5592.png&amp;w=3840&amp;q=75" alt="Healthy GraphQL boundary connects the frontend through Hasura to controlled database access" width="1920" height="440" />

### **Service responsibility**

<img src="https://geekyants.com/_next/image?url=https%3A%2F%2Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%2Fmedia%2F2026%2F09%2Fa2c47f43-2e1f-4966-bed0-be82c28ae761.png&amp;w=3840&amp;q=75" alt="Node.js handles auth, external APIs, async work, and PostgreSQL access where appropriate" width="1920" height="480" />

## **Redis: Cache What Doesn’t Need to Be Recomputed**

### **Read-through cache**

<img src="https://geekyants.com/_next/image?url=https%3A%2F%2Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%2Fmedia%2F2026%2F09%2Fa2c3ef6f-7a93-4f2a-8f92-71b8670a5a34.png&amp;w=3840&amp;q=75" alt="Redis returns cached results on a hit and stores PostgreSQL results after a miss" width="1920" height="560" />

Redis is useful for read-heavy endpoints, configuration data, analytics results, frequently requested reference data, and suitable computed counters.

Every cache should have a clear TTL, invalidation, refresh, and consistency strategy.

**Effective caching targets data that benefits from reuse and has a defined consistency strategy.**

## **Batch Processing: Email and Notifications**

### **Synchronous anti-pattern**

<img src="https://geekyants.com/_next/image?url=https%3A%2F%2Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%2Fmedia%2F2026%2F09%2Fa2c47fca-47ac-4d00-ab4d-99aa4d19ca89.png&amp;w=3840&amp;q=75" alt="Request waits for record creation, email, and notification delivery before responding" width="1920" height="480" />

### **Asynchronous pattern**

<img src="https://geekyants.com/_next/image?url=https%3A%2F%2Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%2Fmedia%2F2026%2F09%2Fa2c480cd-924a-4425-a759-7acce2552c5e.png&amp;w=3840&amp;q=75" alt="Request creates a record, queues a background job, and returns immediately" width="1920" height="440" />

### **Background processing**

<img src="https://geekyants.com/_next/image?url=https%3A%2F%2Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%2Fmedia%2F2026%2F09%2Fa2c4811a-3537-4b13-81cb-3e347ab2aaf3.png&amp;w=3840&amp;q=75" alt="Worker batches queued email and push jobs with retries for failed items" width="1920" height="480" />

This keeps external service latency out of the user-facing request and allows retries, batching, and controlled failure handling.

## **Data Lifecycle Management**

### **Scheduled cleanup**

<img src="https://geekyants.com/_next/image?url=https%3A%2F%2Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%2Fmedia%2F2026%2F09%2Fa2c4814f-c7a5-429c-9e13-6df580ffa55b.png&amp;w=3840&amp;q=75" alt="Scheduled cleanup batches stale data for deletion or archiving and monitors the result" width="1920" height="480" />

Expired sessions, temporary records, old notifications, logs, stale tokens, and other disposable data should have an explicit lifecycle.

Large cleanup operations should be batched and controlled so that maintenance itself does not become a resource spike.

## **Observability: Measure Before You Optimize**

### **Performance investigation loop**

<img src="https://geekyants.com/_next/image?url=https%3A%2F%2Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%2Fmedia%2F2026%2F09%2Fa2c48199-003f-4262-8a64-0e223d0e8e82.png&amp;w=3840&amp;q=75" alt="Performance investigation moves from monitoring and query inspection to optimization and remeasurement" width="1920" height="760" />

- Query logging to identify slow SQL statements
- pg_stat_activity to inspect active database sessions
- EXPLAIN ANALYZE to inspect query execution plans
- API timing to separate application, database, external-service, and network time
- GraphQL resolver timing to identify expensive fields

**Without observability, you can spend weeks optimizing the wrong layer.**

## **Reducing Backend Downtime**

### **Reliability loop**

<img src="https://geekyants.com/_next/image?url=https%3A%2F%2Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%2Fmedia%2F2026%2F09%2Fa2c3efbc-f2df-4c62-be8c-f6c6d399cb69.png&amp;w=3840&amp;q=75" alt="Reliability loop uses health checks, monitoring, graceful degradation, retries, and recovery" width="1920" height="520" />

Performance and availability are connected. Health checks, monitoring, structured logs, graceful degradation, controlled retries, and automated recovery help prevent resource pressure from becoming prolonged downtime.

**Retries should recover from failures without amplifying them.**

## **Do We Really Need That Microservice?**

### **Microservice decision**

<img src="https://geekyants.com/_next/image?url=https%3A%2F%2Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%2Fmedia%2F2026%2F09%2Fa2c3efe6-1a05-4c58-b48b-153a6eb02058.png&amp;w=3840&amp;q=75" alt="Microservice decision checks independent scaling, deployment, and boundaries before splitting a service" width="1920" height="1410" />

Every service adds container memory, network communication, database connections, deployment complexity, monitoring, and operational overhead.

If multiple services are tightly coupled, communicate constantly, share the same database, and always scale together, consolidation can produce a simpler and more efficient architecture.

## **The Anti-Patterns We Discovered**

- Hasura → Node.js → Hasura circular API calls
- Using GraphQL as a universal internal communication layer
- Over-splitting logic across microservices
- Distributing one business flow across too many layers
- Treating PostgreSQL as pure storage
- Using infrastructure capacity to hide inefficient request paths

**Each decision looked manageable in isolation. Together, they created system-wide overhead.**

## **When 2 GB Became an Advantage**

### **What the smaller environment exposed**

<img src="https://geekyants.com/_next/image?url=https%3A%2F%2Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%2Fmedia%2F2026%2F09%2Fa2c3f0b9-d410-448e-a7ee-088026ad9ca3.png&amp;w=3840&amp;q=75" alt="A 2 GB constraint exposes the cost of database connections, queries, network hops, and architecture" width="1920" height="640" />

The 2 GB environment didn’t create all of these problems. It exposed them. The constrained environment forced us to question every connection, query, network hop, container, and runtime calculation.

**The 2 GB environment didn’t create all of these problems. It exposed them.**

## **What Changed After the Refactor?**

### **Outcome**

<img src="https://geekyants.com/_next/image?url=https%3A%2F%2Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%2Fmedia%2F2026%2F09%2Fa2c3f0ea-6d32-4211-8863-0e5002ad2673.png&amp;w=3840&amp;q=75" alt="Simpler architecture reduces database calls, connection churn, API latency, and resource use" width="1920" height="1240" />

- More controlled database connections
- Lower API latency
- Fewer unnecessary database calls
- Reduced network hops
- Less application-layer processing
- More efficient analytics queries
- Better separation of responsibilities
- Reduced container overhead
- More asynchronous background processing
- Lower infrastructure requirements

## **The Bigger Lesson: Scale Down Before You Scale Up**

When an application becomes slow, the instinct is to scale. But adding infrastructure to an inefficient architecture can increase its cost without addressing the underlying problem.

If every request makes unnecessary network calls, adding more servers does not eliminate those calls. If every API performs expensive queries, increasing RAM does not make those queries logically simpler. If every service opens unnecessary database connections, increasing database capacity does not fix the request flow.

### **The optimization mindset**

<img src="https://geekyants.com/_next/image?url=https%3A%2F%2Fgeekyants-v5-media.sgp1.cdn.digitaloceanspaces.com%2Fmedia%2F2026%2F09%2Fa2c3f128-0a63-441c-b67c-0e54664b7097.png&amp;w=3840&amp;q=75" alt="Optimization loop observes bottlenecks, removes waste, tunes the API and database, then scales" width="1920" height="1240" />

In our case, the answer was to simplify the request path, remove circular Hasura–Node.js communication, reduce unnecessary Actions, move appropriate operations closer to PostgreSQL, optimize queries and indexes, address N+1 patterns, use pagination and database-level filtering, precompute analytics, cache read-heavy workloads, move external work to background processing, and reduce unnecessary services.

**The 64 GB server made the system comfortable. The 2 GB server made it honest.**

Before scaling infrastructure, reduce unnecessary work across the architecture, request path, and database. Then scale based on measured demand.

## **Build for Scale at the Architecture Level**

Scaling infrastructure works best when the architecture is ready to support it. Clear service boundaries, optimized database access, efficient request paths, caching, and asynchronous processing can reduce resource pressure before additional capacity enters the equation. If your application is facing performance bottlenecks or rising infrastructure demands, explore how [**backend engineering services**](https://geekyants.com/engineering/backend) can help strengthen the system before the next stage of scale.
