DEV Community

Tristan Kilhwan Chai
Tristan Kilhwan Chai

Posted on Edited on

[Homelab AI Project] Ep.6 - Aiming for a 0MB Memory Footprint on Frontend Servers with Astro and React Islands

1. Background: "Spring Boot is Great, but My 16GB RAM is Priceless"

After wrapping up the frontend optimization with Astro and React (Episode 6), it was time to design the backend services.

When designing multi-module architectures, the default choice is often Java and Spring Boot (Gradle Multi-Module). With strong typing, mature DI containers, and structured layering, it is undeniably a solid framework.

However, my mini-PC homelab (Ryzen 5500U, 16GB RAM) runs under strict resource constraints. The core database and utility containers already consume a substantial portion of memory:

  • PostgreSQL 16 + pgvector (Capped at 4GB for caching vector indexing structures)
  • Redis 7.2 Alpine (Capped at 1GB for queuing and caching layers)
  • Prometheus + Grafana + Loki (~1GB for the APM and logging stack)
  • Gitea + Actions Runner (~200MB for CI/CD pipelines)

In this environment, booting separate Spring Boot instances for the news collector, AI processor, and mail sender would instantly swallow 300MB to 500MB of RAM per instance. Running three JVM processes would eat up 1.5GB of memory before processing a single news article, inviting OS OOM (Out Of Memory) crashes during compute-intensive vector operations.

Furthermore, implementing RAG processing requires Python library bindings (like numpy or sentence-transformers). Choosing Spring Boot meant I would eventually have to launch a separate Python runner anyway, doubling the memory footprint.

Therefore, I chose a resource-optimized stack (ADR-002):

  1. Node.js (TypeScript) Workspaces: Running high-concurrency async I/O jobs under 50MB of RAM per instance.
  2. Python RAG Core: Constructing the embedding calculations and pgvector pipelines directly in Python.
  3. Monorepo Structure: Housing both languages inside a unified repository to keep pipeline configurations clean.

2. The Solution: Node.js & Python Monorepo Layout

My multi-module layout (ADR-002) is organized as follows:

# Monorepo directory structure
AI_News/
├── packages/
│   ├── core/           # [Node.js/TS] Shared database configurations, entities, and schemas
│   ├── collector/      # [Node.js] Asynchronous RSS news collector (batch job)
│   ├── ai-engine/      # [Python] pgvector + OpenRouter RAG summarizer
│   ├── sender/         # [Node.js] Redis Queue based mass email sender
│   └── frontend/       # [Astro/React] Frontend analytics dashboard
├── infra/              # Docker Compose configurations and environment files (.env)
├── docs/               # Architecture Decision Records (ADRs) and blog drafts
├── package.json        # Root npm workspaces configuration
└── tsconfig.json       # Shared compiler options for TypeScript modules
Enter fullscreen mode Exit fullscreen mode

The core component is packages/core. Since the collector, sender, and frontend dashboard all belong to the Node.js/TypeScript stack, sharing common database entities, DTO schemas, and environment validators inside core keeps code duplication to absolute zero.


3. Implementation: Root Setup & Module Scaffolding

(1) Root Configuration (package.json)

I configured npm workspaces in the root JSON file to manage local dependency paths:

// package.json (root)
{
  "name": "@ai-news/root",
  "private": true,
  "workspaces": [
    "packages/core",
    "packages/collector",
    "packages/sender",
    "packages/frontend"
  ],
  "scripts": {
    "dev:frontend": "npm run dev -w @ai-news/frontend",
    "build:frontend": "npm run build -w @ai-news/frontend",
    "start:collector": "npm start -w @ai-news/collector",
    "start:sender": "npm start -w @ai-news/sender"
  }
}
Enter fullscreen mode Exit fullscreen mode

(2) Shared TypeScript Rules (tsconfig.json)

I established standard compiler options at the root, allowing submodules to extend the configuration:

// tsconfig.json (root)
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "esModuleInterop": true,
    "strict": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "declaration": true,
    "sourceMap": true
  }
}
Enter fullscreen mode Exit fullscreen mode

(3) The Core Package (packages/core)

Below is the scaffolding setup for the shared TypeScript package:

// packages/core/package.json
{
  "name": "@ai-news/core",
  "version": "0.1.0",
  "private": true,
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "scripts": {
    "build": "tsc"
  }
}
Enter fullscreen mode Exit fullscreen mode
// packages/core/src/types/news.ts
export interface NewsItemDTO {
  id?: string;
  title: string;
  link: string;
  source: string;
  rawContent: string;
  publishedAt: Date;
}

export interface SummaryDTO {
  newsId: string;
  summaryText: string;
  modelUsed: string;
  costIncurred: number;
  processedAt: Date;
}
Enter fullscreen mode Exit fullscreen mode

(4) Frontend (Astro) Deployment Design: 0MB RAM Saving via Nginx Docker Multi-Stage Build

For the portfolio dashboard (to be introduced in Episode 6), running a persistent Node.js web process is unnecessary. We designed a lightweight Astro Nginx Container that builds static files and serves them via Nginx inside our docker-compose stack.

First, we structure packages/frontend/Dockerfile using multi-stage builds:

# Stage 1: Build static assets
FROM node:22-slim AS builder
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build

# Stage 2: Serve via Nginx
FROM nginx:alpine
COPY --from=builder /usr/src/app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Enter fullscreen mode Exit fullscreen mode

By mapping the service inside infra/docker-compose.yml and configuring Gitea Actions to rebuild both the collector and frontend services concurrently (docker compose up -d --build), we enforce a single-click deployment structure with minimal system overhead.


4. Troubleshooting & Debugging Logs

🚨 Issue 1: npm Workspaces Hoisting and Missing Local Build Files

After running npm install, trying to boot sub-packages threw compile-time errors: Module not found when referencing @ai-news/core.

  • Cause: npm workspaces hoist third-party packages to the root node directory. Because the local core package @ai-news/core was not built into compiled .js assets (dist/), referencing submodules crashed during runtime resolve steps.
  • Resolution: I configured package pipeline triggers inside the root script runner, guaranteeing @ai-news/core is built prior to booting any dependent workspaces:
  // package.json (root)
  "scripts": {
    "build:core": "npm run build -w @ai-news/core",
    "predev:frontend": "npm run build:core",
    "prestart:collector": "npm run build:core"
  }
Enter fullscreen mode Exit fullscreen mode

🚨 Issue 2: Docker Bridge DNS Resolving Failures (Connection Refused)

Routing API calls from the Node.js collector to the Python AI engine via http://localhost:8000/summarize resulted in crash errors: ECONNREFUSED inside container task runners.

  • Cause: In containerized spaces, localhost points to the isolated loopback interface of that specific container. To communicate with external containers, processes must resolve network domains using Docker's internal bridge DNS resolver.
  • Resolution: I targeted the Docker Compose service name (ainews-ai-engine) as the domain destination, mapping host targets to http://ainews-ai-engine:8000 to establish container-to-container connections.

🚨 Issue 3: Monorepo Dependency Resolution Failures during Individual Docker Builds

Building docker images with the context set directly to the subdirectory (packages/collector) failed with ENOENT errors because the build container could not resolve dependencies or reference the shared @ai-news/core package.

  • Cause: In an npm workspaces monorepo, dependencies and shared modules are hoisted to the root directory. If the build context is restricted to the package folder, the Docker daemon cannot see files in parent or sibling directories.
  • Resolution: We adjusted the Docker Compose build configuration to set the build context to the repository root (..) instead of the individual package directories. We then structured the Dockerfiles to copy the root package.json and package-lock.json first, install dependencies, and then selectively copy package sources, resolving the hoisting boundary issue.

5. Results & Metrics: Spring Boot vs. Node.js/Python

I measured the server memory footprint (RSS) against a simulated Java/Spring Boot multi-module deployment under idle conditions:

Metric Spring Boot (JVM, 3 instances) Node.js (2 instances) + Python (1 instance) Improvement
Constant RAM Footprint ~720 MiB ~124 MiB (Node: 44MB, Python: 80MB) -82.7% (RAM Saved)
Cold Start Duration ~8.2s ~0.9s -89.0%
16GB Host RAM Saved ~95.5% conserved ~99.2% conserved (600MB preserved) +3.7% headroom

By deploying lightweight Node.js script runtimes for collectors and senders, I freed up 600MB of RAM. This memory buffer was funneled into the pgvector similarity index caches, enabling faster semantic processing pipelines.


6. Next Up

With the Monorepo skeleton verified, Episode 8 will dive into our data harvesting layers: Building a Node.js RSS News Collector Scheduler — Pipeline Steps and DB storage.

Top comments (0)