Loading...

Every system inside aimlcompanion.ai in production

A case study of one specific product, not a universal template, built by me over the last 5+ months (including content curation) and still iterating. The full stack running aimlcompanion.ai today, plus the larger-scale upgrades I have not made. Then an honest take on which of those upgrades would be worth it for this product and which would only slow shipping.

A case study of aimlcompanion.ai specifically, not a template for every production app. The visible product of a content-heavy learning platform is the modules, the visualisations, the narrated walkthroughs, the ML mini-games, and the progress analytics. The invisible product is the production stack wrapping all of that. Caching, auth, deploys, observability, and the layered safeguards that turn a working app into a service real users keep paying for. This post walks through every one of those layers as it runs behind one live AI and ML learning app. It takes a clear-eyed look at where larger-scale engineering would add value and where it would only slow shipping.

Why this post exists

The visible product of a content-heavy learning platform is what people come for. That means the modules, the visualisations, the narrated walkthroughs in three languages, deep dive insights, the code examples, real world projects, the ML mini-games, and the per-learner progress analytics.

The invisible product is the production stack wrapping all of that. The caching that keeps it fast. The auth that powers every learner identity, free and paid, along with their progress, bookmarks, and tier-aware access. The deploy pipeline that ships content updates without breaking live sessions. The observability that catches problems before users do, and the layered safeguards that turn a working app into a service real users keep paying for.

This post is a high level tour of the important systems running in production behind one specific live AI and ML learning platform, aimlcompanion.ai. Built and shipped solo, drawing on 16+ years of production engineering experience on systems that have processed billions of telemetry events per hour. The last several years have been focused on traditional AI/ML and, more recently, on GenAI and Agentic AI. The catalogue today spans tens of tracks, hundreds of modules, thousands of narration audio files across three languages, and hundreds of prerendered SEO routes.

At each layer I also call out, honestly, what a larger-scale version of that same layer would look like, and which of those upgrades would be premature for this product at this stage. Those larger-scale upgrades will be made as and when the user base grows past the threshold that actually justifies each one, not earlier as a default. If you are building anything past a demo, the post is a map of one set of trade-offs, not a checklist that applies to every app.

How to read the diagram

This is a case study, not a universal template. Every architectural decision below is a trade-off that was right for aimlcompanion.ai given its current scale, content-heavy shape, and solo-engineer constraint. A B2B SaaS with enterprise compliance needs, a high-frequency trading platform, or a consumer social app would each make different calls at almost every layer. Read it as one product explaining the choices it made and why, not as how every production app should be built.

The stack is replaceable. The content compounds. Read the rest of the post with that frame in mind. Each architectural decision below was chosen deliberately. Most came directly from production incidents that taught me why the easy default would have been wrong. Together they form a stack that ships content fast without breaking what is already live.

Infrastructure

The stack is AWS, kept deliberately boring.

The whole production footprint costs almost nothing per month, and that is the point. Boring infrastructure is a feature when one person owns every layer.

The current setup already runs multi-AZ within a single region, EC2 behind an ALB across two availability zones, RDS multi-AZ, and ElastiCache multi-AZ replication for Redis. The next genuine jump is multi-region, not multi-AZ, and that brings a different shape of work. Multi-region active-active so a regional AWS outage does not take the site down. Aurora with cross-region read replicas instead of standard RDS. Transit gateways and dedicated VPC peering when multiple services need to share data privately. Private subnets behind NAT gateways for tightened egress control. Each is correct at higher scale and wrong for a current scale product today. Most of them introduce more failure modes than they remove until traffic genuinely justifies the complexity.

Frontend

The app is React plus Vite. It is also a PWA, installable on mobile and desktop, with full offline behaviour for previously visited content.

What goes into making that actually work in production

The seven-layer stale chunk recovery

The most defensive piece of the frontend is a layered stale-chunk recovery system that took multiple production incidents to fully build out. The problem looks like this. A deploy ships, the user's browser still has the old index.html cached, and that cached HTML points at chunk filenames that no longer exist on the server. Without recovery, the user lands on a blank error page and the only way out is for them to hard-refresh, which most users never do.

The seven layers cover every place a chunk load can fail. They include React.lazy wrappers, the ErrorBoundary, the Service Worker controller-change handler, and an inline script in index.html that fires before React itself loads. All seven share a 30-second guard in sessionStorage so the recovery cannot loop into an infinite reload if something deeper is broken.

A larger-scale version of the frontend goes further with server-side rendering at the edge, React Server Components, streaming HTML, and an islands architecture for partial hydration. Each is a real upgrade at scale, and each adds operational complexity that a solo team cannot reasonably maintain alongside content production and customer support.

SEO without a CMS

Single Page Apps are notoriously bad at SEO. Google crawls them inconsistently, and social previews are usually broken because the page returns an empty shell before JavaScript runs.

The app sidesteps that by generating fully prerendered HTML for every public route at build time. Every route gets a unique title tag, a unique meta description, a canonical URL, Open Graph tags for social sharing, and a sitemap.xml entry. The total build artefact is small enough to live entirely in S3.

A CloudFront Function at the CDN edge rewrites SPA paths to those prerendered files so Google sees real HTML, not an empty shell. Auth-only pages fall back to the SPA shell instead, so private content never gets prerendered into a publicly cacheable file.

Here is the small detail that makes this trickier than it looks. CloudFront has a built-in option called custom error responses that lets you send any not-found URL to /index.html instead. On the surface, that is exactly what the SPA rewrite needs to do. The problem is that this option applies to every URL going through CloudFront, with no way to scope it to just the public pages. The backend uses similar error codes when, for example, a free user tries to open premium content, and the same setting would intercept those too. The user would land on /index.html as if the page did not exist, and the paywall would silently break. CloudFront Functions are different. Each one is attached to a specific URL pattern, so the SPA rewrite only fires on the public pages and the backend stays untouched. Both options can do the rewrite. Only one of them does it without accidentally breaking the rest of the site.

At scale this gets more elaborate, adding ISR (incremental static regeneration), on-demand revalidation, hreflang for international SEO, automated structured data generation, and a dedicated SEO team. Most of that is unnecessary if your content changes on the order of weeks, not minutes.

Caching, layered from edge to database

Caching is the part of the stack I enjoy most because every layer here was added in response to something that actually broke in production. The order below is the standard one, starting at the edge (closest to the user) and moving inward toward the database. Four of the six are true caches in the textbook sense. They store the result of expensive work so the next request can skip that work. Those four are the CDN at the edge, the Service Worker running in the browser, a short-lived in-process cache on the server, and a hashed lookup table in PostgreSQL.

The other two rows are not caches by themselves. Redis stores active sessions, not derived data, so it behaves like a cache but is really a session store. And per-file content hashing is a naming trick that puts a hash in every asset URL, which is what makes the top two caches safe to cache aggressively across deploys. Both rows are listed because the caching story would be incomplete without them.

LayerWhere it livesWhat it cachesInvalidation
1. CDN edgeCloudFrontindex.html, static assets, signed video URLsPath-specific behaviours, invalidation on deploy
2. Service WorkerBrowser (Workbox)Pages, JS, data, images, CSS, audioNetworkFirst with timeout, status-filtered, cleared on SW update
3. RedisApplication serverSessions onlyEphemeral, graceful degrade if down
4. PostgreSQL content cacheDatabaseELI5 simplificationsAuto-invalidate by MD5(source text)
5. In-memory TTLExpress processPremium access checks30 second TTL
6. Per-file content hashingBuild outputNarration audio filesHash changes only if file content changes

Why caching at every layer pays

CloudFront has six path-specific behaviours. index.html sits behind a short cache. /api/* and /auth/* have caching disabled because they are authenticated, and /assets/* has caching disabled because premium chunks are auth-gated by Express. /sw.js gets its own behaviour to bypass the 24-hour legacy default that traps most teams on their first PWA, and /videos/premium/* uses signed URLs with a one-year cache. A CloudFront Function at the edge handles domain redirects and SPA rewrites with zero round-trip to the origin.

The Service Worker has multiple runtime caches split by content type (pages, JS, data, images, CSS, audio). Every cache uses cacheableResponse with a 200 status filter so error responses never persist. The audio cache is the largest and longest-lived. The pages cache uses NetworkFirst with a short timeout, so the user always gets fresh HTML when the network is up and falls back to cache only when the network is slow or offline.

Redis stores sessions only. When Redis goes down, the app degrades gracefully via a connection flag. Public content keeps working, authenticated requests return 401, and the health endpoint returns 200 with a degraded status so the deploy pipeline does not block on a Redis outage.

The PostgreSQL content cache stores ELI5 simplifications keyed by a content hash of the source insight text. Editing the source insight changes the hash, which invalidates the cache automatically with no manual purge step. The same pattern shows up one more time, just in memory. A short-lived in-process cache on premium-access checks means the database is not queried on every ELI5 request, which adds up quickly when one learner browses through many modules in a session.

The audio layer is the most operationally significant one. Each MP3 carries a content hash in its URL, so only changed files re-download after a deploy. Before this, a global build version invalidated every audio URL on every release, which meant learners on slow connections would re-download hundreds of megabytes of identical narration after a minor frontend update.

A larger-scale version of this caching stack looks different. Lambda@Edge routes free and premium chunks separately from a single distribution, so global users do not pay the round-trip latency to a single region. Multi-region cache replication keeps reads from crossing continents, and prefetching gets driven by ML. All of it is correct at higher scale, and the wrong place to spend a solo engineer's month at this one.

Payments

Razorpay is the primary gateway and handles both INR and USD, so a single account covers domestic and international transactions without standing up a second processor.

What sits around the gateway

At higher transaction volumes this becomes a primary gateway plus a hot secondary with automated failover, an automated chargeback dispute workflow, and ML fraud scoring on transaction patterns. All useful past a certain volume, and all overkill at the scale of a focused single-product company.

Security and compliance

Security posture

Compliance

GDPR consent is gated to EU, EEA, and UK users via geolocation, not shown globally. The privacy policy and terms of service were rewritten from scratch rather than copied from a template. IP addresses are hashed at the application layer, so raw IPs are never stored. Retention cleanup jobs run on a schedule. A documented DMCA process and a breach runbook live in the same playbook directory as the deploy pipeline.

DPDPA (India's data protection law) support is implemented and waiting for a one-line flag flip when the rules are formally notified. CCPA / CPRA compliance for California residents is also in place. The footer carries a Your Privacy Choices link, the privacy policy includes the California-specific disclosures the law requires, and right-to-access and right-to-delete requests route through the same admin tooling that serves GDPR requests.

An enterprise-grade posture adds SOC 2 Type II, ISO 27001, a dedicated Data Protection Officer, formal Records of Processing Activities, vendor risk management, an external red team program, and a Security Operations Center. That posture is necessary for enterprise sales and overkill for consumer subscriptions today.

Inside every learning module

Every learning module is a layered package, not a single block of text. Most layers are hand-curated content. A small AI-assisted layer handles simplification and narration at content-edit time, never on the request path.

The narration pipeline uses Google Cloud TTS. The audio files are versioned by content hash so the Service Worker can cache them aggressively while still serving updates when the underlying script changes.

The bottleneck in this pipeline is never compute or cost. It is the quality work that turns each layer into something a learner actually understands. The audit step that catches narration drifting from the visual it is supposed to teach. The iteration on an interactive visualisation until a parameter slider becomes a moment of insight. The code example that gets shortened until it actually teaches the point. The quiz question that gets rewritten until it tests understanding instead of recall. Generating thousands of robotic narrations is fast. Producing the educator-quality version of every layer for every module is months of careful work, and that gap is most of the actual product.

Past a certain scale this gets richer. Streaming TTS for interactive responses, voice cloning for personalised narrators, and real-time adaptive difficulty driven by engagement signals. Then multi-modal content generation pipelines that draft visualisations and code examples for human review. The last two are genuinely interesting for the long term, the first two are mostly cosmetic at this stage.

Observability, email, reliability, and deploys

Observability

Pino emits structured logs that flow into CloudWatch alongside metrics and alarms. OpenTelemetry instrumentation produces distributed traces across the full request path. A slow page traces back to the specific database query, cache miss, or downstream call that caused it, instead of being guessed at from logs alone. A single health endpoint probes both PostgreSQL and Redis on each invocation, and a separate cron heartbeat endpoint catches scheduled jobs that miss their window. The client ships its own error reporter that posts to the same backend, slow query logs are captured at the database, and PM2 monitors the cluster of Node processes.

Email

Transactional and broadcast email runs through AWS SES over SMTP via nodemailer from EC2. Two verified senders sit behind the application, one for transactional sends (welcome, receipts, account events) and one for broadcast sends, both on the verified domain. The transactional send fires asynchronously after a successful payment and is wrapped in try/catch so a failure never blocks the upgrade flow itself. SES is in production mode with quota well above current send volume. Inbound replies are forwarded through a third-party inbox forwarder into a single mailbox for support handling.

Reliability patterns under the hood

A few patterns show up across the production code wherever they pay off. Idempotency and deduplication on side-effecting paths, so a duplicate payment-gateway webhook (their default retry behaviour) never double-grants premium access or double-issues a refund. The dedup key is the gateway's event ID with a composite fallback, persisted so the request consults it before doing any work.

Circuit breaker plus retry with exponential backoff on external API calls, so realtime features keep their live feeds alive when an upstream source flaps. A small number of retries on 429 and 5xx responses with increasing delays, then the circuit opens and the channel publishes a degraded status to connected clients instead of hammering the upstream. Postgres advisory locks on the critical state-mutation paths so two concurrent webhooks for the same user serialise rather than racing each other into inconsistent state.

Deploys

Deploys run through GitHub Actions using a blue-green strategy on EC2. The pipeline lints, tests, and builds. It stages the new release in a parallel directory on the server, the green build sitting alongside the live blue build, and runs migrations. Then an atomic symlink flip from blue to green, a PM2 reload in cluster mode for zero downtime, and a health check. If anything fails after the flip, the symlink swaps back to the previous (blue) build and PM2 reloads again, so the user sees no downtime and the bad release is gone within seconds. Only after a successful flip does S3 sync the new static assets and CloudFront invalidate.

At higher scale this becomes formal SLO dashboards with alerting, and chaos engineering as a standing practice. Progressive rollouts use feature flags as deploy gates, and canary deployments route a small percentage of traffic to the new version before a full switchover. Each is a real upgrade at the right traffic level.

What larger-scale engineering adds

Putting the per-section gaps together, here is what larger-scale production engineering adds.

Every one of those is the right answer at a certain scale, and not one of them is the right answer at the stage of a focused single-product company today. Building them prematurely slows shipping without improving outcomes, and most of them quietly add new failure modes that have to be staffed against.

Premature scale-out architecture is the most common failure mode I see in engineering-led startups. Founders read a published architecture post from a large engineering org and implement the multi-region, event-sourced, microservice version of what should be a monolith on one box. The customer never sees the engineering; they only see how long it took to ship.

How the system handles its Non-functional requirements (NFRs)

For readers who skim instead of read, here is the same architecture compressed into a single Non-functional requirements (NFRs) view. Each row is one sentence and points back to the section in the post where the topic is covered in depth.

Non-functional requirementHow the system handles it today
AvailabilitySingle region with multi-AZ for app, cache, and database. EC2 behind an ALB across two availability zones. ElastiCache and RDS both multi-AZ. Health check with auto-rollback on every deploy.
PerformanceCaching layered from edge to database (CDN, browser Service Worker, in-process TTL, content-hash memoisation in PostgreSQL). Brotli plus gzip compression. Prerendered HTML for every public route. Service Worker for offline reads.
ScalabilityHorizontal scaling on EC2 via PM2 cluster mode behind the ALB. Stateless application instances. Multi-region active-active is the next jump and is deferred until traffic justifies the cost.
SecurityCSP with per-request nonces, CSRF tokens bound to the session, and session regeneration on auth state changes. HSTS, OAuth with auto-link, tiered rate limiting per route, anti-scraping detection on content endpoints, IP hashing, and AWS Shield on the CDN.
ComplianceGDPR consent gated by geolocation (EU, EEA, UK only). CCPA / CPRA Your Privacy Choices footer link with California-specific disclosures in the privacy policy. DPDPA-ready with a one-flag flip. Hand-written privacy policy and terms of service. Retention cleanup jobs on a schedule. Documented DMCA process and breach runbook.
ObservabilityPino structured logs and OpenTelemetry distributed traces in CloudWatch. A health endpoint that probes PostgreSQL and Redis. A cron heartbeat endpoint that catches jobs missing their window. Slow query logs at the database. Client-side error reporter.
Reliability patternsIdempotency on every side-effecting path. Webhook deduplication keyed on the gateway's event ID. Circuit breaker plus exponential backoff retry on external API calls. Postgres advisory locks on critical state-mutation paths. Graceful degradation when Redis is unreachable.
MaintainabilityBlue-green deploy pipeline with lint, test, atomic flip, health gate, and auto-rollback. Versioned database migrations applied on every deploy. Secrets in SSM Parameter Store. Boring, well-understood AWS services across the stack.
Disaster recoveryRDS automated daily backups with point-in-time recovery. Stateless EC2 instances, so failure recovery is booting another one. Multi-AZ replication for Redis and the database. CDN keeps serving the previous build if a health check fails after deploy.
Cost efficiencySingle AWS account on a small EC2 instance with managed services only where they actually save engineering hours. The monthly bill is negligible compared to the engineering hours it saves.

The table is a summary, not a substitute. Every row above hides trade-offs and incident-driven decisions that the post documents in depth. For a working understanding of why the stack looks like this, the narrative sections are still the answer. The table is for the skimmer and for the reader returning months later who needs to find a specific dimension fast.

What actually matters

If you are building anything past a tutorial app, the lesson is the same. Spend just enough on infrastructure to not embarrass yourself, and spend everything else on the thing that compounds.

Every architectural choice in this post is downstream of that principle. The six caching layers exist because shipping content updates without breaking the deployed app is worth defending in code. Chunk recovery has seven layers because the alternative is scheduling downtime windows, which fragments writing time. The AWS stack stays deliberately boring because every hour spent on exotic infrastructure is an hour not spent writing modules.