This document covers all scaling triggers, limits, cooldown periods, and operational runbooks for each supported deployment platform.
The ClipCash frontend is a stateless Next.js App Router application. Scaling it horizontally is safe as long as Redis is configured — job state lives in Redis, not in process memory.
Users → Load Balancer → N × Next.js instances → Redis (job store)
→ S3 (file storage)
→ AI Backend (async)
Statefulness requirements before scaling beyond 1 instance:
REDIS_URLmust be set. Without it each instance uses an in-memory store that is not shared.- Redis connection pooling is automatically configured for optimal performance in serverless environments.
- The system automatically falls back to in-memory storage if Redis is unavailable (logs warnings).
- The AI backend callback (
POST /api/jobs/[id]/callback) can land on any instance; all instances read/write the same Redis key, so this is safe. - SSE streams (
GET /api/jobs/[id]/stream) poll Redis every 1 s — they work correctly across instances.
Redis health monitoring:
- Automatic health checks every 30 seconds (configurable via
REDIS_HEALTH_CHECK_INTERVAL) - Health endpoint at
/api/health/redisfor monitoring - Connection pooling with automatic reconnection on failure
- See
docs/REDIS_SESSION_SHARING.mdfor full configuration details
All scalers consume GET /api/metrics.
| Format | How to request |
|---|---|
| Prometheus text | Default (Accept: text/plain or no header) |
| JSON | Accept: application/json |
Authentication: Authorization: Bearer <METRICS_TOKEN>
Rate limit: 120 req/min per IP.
Key metrics exposed:
| Metric | Type | Description |
|---|---|---|
clipcash_jobs_queued |
gauge | Jobs waiting for AI dispatch |
clipcash_jobs_active |
gauge | Jobs currently processing |
clipcash_jobs_failed |
gauge | Jobs in error terminal state |
clipcash_jobs_complete |
gauge | Jobs completed successfully |
clipcash_process_uptime_seconds |
gauge | Process uptime |
clipcash_process_memory_rss_bytes |
gauge | RSS memory |
clipcash_process_memory_heap_bytes |
gauge | V8 heap used |
clipcash_circuit_breaker_state{service} |
gauge | 0=CLOSED, 1=HALF_OPEN, 2=OPEN |
clipcash_circuit_breaker_failures{service} |
gauge | Consecutive failure count |
clipcash_circuit_breaker_total_calls{service} |
counter | Total calls through breaker |
clipcash_circuit_breaker_total_fallbacks{service} |
counter | Total fallback invocations |
Vercel scales functions automatically — there is no replica count to manage. Configuration focuses on per-function resource limits, cron jobs, and preventing runaway execution.
| Function | Memory | Max duration | Reason |
|---|---|---|---|
/api/upload |
3008 MB | 60 s | Holds full video buffer in memory during virus scan |
/api/jobs/[id]/stream |
256 MB | 300 s | SSE streams stay open for the full processing duration |
/api/jobs/[id]/callback |
256 MB | 10 s | Fast write, no heavy computation |
/api/health |
128 MB | 5 s | Liveness — should never need more |
/api/health/ready |
256 MB | 15 s | Probes Redis + S3 + AI backend concurrently |
/api/metrics |
256 MB | 15 s | Reads all jobs from Redis |
Vercel scales invocations to zero between requests and spins up new instances on demand. There are no user-configurable scale-out thresholds.
What you control:
- Concurrency per region — set via
regionsinvercel.json. Add regions to reduce latency for users outsideiad1. - Cold-start mitigation — enable Vercel's "Fluid compute" or set a minimum instance count in project settings (Pro/Enterprise plans).
| Endpoint | Schedule | Purpose |
|---|---|---|
/api/cron/requeue-stalled-jobs |
Every 5 min | Re-dispatches jobs stuck in queued > 10 min |
/api/cron/cleanup-old-jobs |
Daily 03:00 UTC | Deletes terminal jobs older than 7 days |
Both endpoints require Authorization: Bearer <CRON_SECRET>. Vercel injects this automatically when the cron is triggered via vercel.json.
Tunable env vars:
| Variable | Default | Effect |
|---|---|---|
STALLED_JOB_THRESHOLD_MS |
600000 |
Age (ms) before a queued job is considered stalled |
REQUEUE_MAX_JOBS |
20 |
Max jobs re-dispatched per cron run |
CLEANUP_JOB_AGE_MS |
604800000 |
Age (ms) before a terminal job is deleted (7 days) |
CLEANUP_MAX_JOBS |
500 |
Max jobs deleted per cleanup run |
All manifests are in deploy/k8s/. Apply in this order:
kubectl apply -f deploy/k8s/namespace.yaml
kubectl apply -f deploy/k8s/secret.yaml # fill in real values first
kubectl apply -f deploy/k8s/configmap.yaml
kubectl apply -f deploy/k8s/deployment.yaml
kubectl apply -f deploy/k8s/service.yaml
kubectl apply -f deploy/k8s/pdb.yaml
kubectl apply -f deploy/k8s/hpa-cpu-memory.yaml
kubectl apply -f deploy/k8s/hpa-queue-depth.yaml # requires KEDA
kubectl apply -f deploy/k8s/cronjob-requeue.yaml
kubectl apply -f deploy/k8s/cronjob-cleanup.yaml
kubectl apply -f deploy/k8s/servicemonitor.yaml # requires prometheus-operator| Parameter | Value | Rationale |
|---|---|---|
| Min replicas | 2 | HA across zones; supports zero-downtime rolling updates |
| Max replicas | 10 | Hard cap against runaway scaling |
| CPU scale-out | 60% of 250m request | Triggers before Node.js event loop saturation (~80%) |
| Memory scale-out | 70% of 512Mi request | ~358 Mi; buffer before OOMKill |
| Scale-up stabilisation | 30 s | Reacts quickly to traffic spikes |
| Scale-up policy | Max(+2 pods/30s, +100%/60s) | Aggressive during spikes |
| Scale-down stabilisation | 300 s (5 min) | Prevents thrash on brief lulls |
| Scale-down policy | Min(−1 pod/120s) | Slow removal keeps SSE streams alive |
Install KEDA:
helm repo add kedacore https://kedacore.github.io/charts
helm install keda kedacore/keda --namespace keda --create-namespaceCreate the metrics token secret before applying:
kubectl create secret generic clipcash-metrics-token \
--from-literal=token=<METRICS_TOKEN> \
-n clipcash| Parameter | Value | Rationale |
|---|---|---|
| Min replicas | 2 | Same floor as CPU/memory HPA |
| Max replicas | 10 | Same ceiling |
| Poll interval | 30 s | Matches Prometheus scrape interval |
| Cooldown period | 300 s | 5 min idle before scaling to min |
| Queued jobs target | 5 per replica | 20 queued jobs → 4 replicas |
| Active jobs target | 8 per replica | 24 active jobs → 3 replicas |
| Scale-up stabilisation | 30 s | Mirrors CPU/memory HPA |
| Scale-down stabilisation | 300 s | Mirrors CPU/memory HPA |
Kubernetes takes the maximum desired replica count across all active HPAs, so whichever trigger calls for more replicas wins.
pdb.yaml ensures at least 1 pod stays running during node drains, cluster upgrades, and rolling deployments. With minReplicas: 2 this means at most 1 pod is evicted at a time.
The Kubernetes CronJob resources in cronjob-requeue.yaml and cronjob-cleanup.yaml use a curlimages/curl container to call the same cron endpoints as Vercel. Both use concurrencyPolicy: Forbid so overlapping runs are never started.
If you are not using the Prometheus Operator, add this to prometheus.yml:
scrape_configs:
- job_name: clipcash-frontend
scrape_interval: 30s
scrape_timeout: 10s
bearer_token: <METRICS_TOKEN>
static_configs:
- targets: ['clipcash-frontend.clipcash.svc.cluster.local:80']
metrics_path: /api/metricsConfiguration is in fly.toml. Deploy with:
fly launch # first time
fly deploy # subsequent deploysSet secrets before first deploy:
fly secrets set \
NEXTAUTH_SECRET=<value> \
NEXTAUTH_URL=https://<app-name>.fly.dev \
REDIS_URL=<value> \
AI_BACKEND_CALLBACK_SECRET=<value> \
AI_BACKEND_SECRET=<value> \
AWS_ACCESS_KEY_ID=<value> \
AWS_SECRET_ACCESS_KEY=<value> \
CLOUD_STORAGE_BUCKET=<value> \
CRON_SECRET=<value> \
METRICS_TOKEN=<value> \
NEXT_PUBLIC_SENTRY_DSN=<value>| Trigger | Value | Effect |
|---|---|---|
| Concurrency soft limit | 50 requests | Fly starts a new machine when a machine handles 50 concurrent requests |
| Concurrency hard limit | 75 requests | Requests above 75 are rejected with 503 |
| Auto-stop idle timeout | 10 min | Machine stopped after 10 min of no traffic |
| Min machines running | 1 | Always keep 1 machine warm to avoid cold starts |
Fly does not have an explicit scale-down cooldown like Kubernetes HPA. Instead:
- The
idle_timeout = "10m"setting acts as the cooldown — a machine that handled traffic recently is not stopped until 10 minutes of inactivity. - SSE stream connections (up to 5 min) keep a machine active for their full duration.
min_machines_running = 1guarantees at least one machine is always ready.
Uncomment the [[regions]] sections in fly.toml to add geographic regions. Each region maintains its own machine pool with the same auto-stop/start rules.
fly.toml configures the built-in Prometheus scrape:
[metrics]
port = 3000
path = "/api/metrics"Metrics appear in the Fly.io dashboard under Metrics → Custom. You can also connect an external Prometheus instance using Fly's Prometheus endpoint.
Import deploy/grafana/scaling-dashboard.json into Grafana:
Via UI:
- Dashboards → Import → Upload JSON file → select
scaling-dashboard.json - Select your Prometheus datasource when prompted
Via provisioning (Kubernetes):
# Create a ConfigMap from the dashboard JSON
kubectl create configmap clipcash-grafana-dashboard \
--from-file=scaling-dashboard.json=deploy/grafana/scaling-dashboard.json \
-n monitoring
# Mount it in your Grafana deployment under:
# /var/lib/grafana/dashboards/clipcash/scaling-dashboard.json
# and copy deploy/grafana/provisioning/dashboards.yaml to:
# /etc/grafana/provisioning/dashboards/clipcash.yaml| Row | Panels |
|---|---|
| Job Queue | Queued stat, Active stat, Failed stat, Queue depth time-series, Total pending gauge |
| Process Resources | Memory RSS+heap time-series, Uptime stat, RSS gauge (1 GiB threshold) |
| Circuit Breakers | State per service (colour-coded), Consecutive failures time-series, Fallback rate/5m time-series |
| Scaling (K8s only) | HPA current/desired/min/max replicas, Pod phase (Running/Pending), HPA summary table |
Auto-refreshes every 30 seconds. Default time range: last 1 hour.
| Variable | Default | Description |
|---|---|---|
METRICS_TOKEN |
— | Bearer token required to scrape /api/metrics. Required in production. |
CRON_SECRET |
— | Bearer token for cron endpoint authentication. Required in production. |
REDIS_URL |
— | Required for multi-instance deployments. Format: redis:// or rediss:// for TLS. |
REDIS_MAX_RETRIES |
3 |
Max command retry attempts |
REDIS_RETRY_DELAY |
1000 |
Delay between retries in ms |
REDIS_CONNECT_TIMEOUT |
10000 |
Connection timeout in ms |
REDIS_COMMAND_TIMEOUT |
5000 |
Command execution timeout in ms |
REDIS_KEEP_ALIVE |
30000 |
TCP keep-alive interval in ms |
REDIS_MAX_RECONNECT_ATTEMPTS |
10 |
Max reconnection attempts before fallback |
REDIS_HEALTH_CHECK_INTERVAL |
30000 |
Health check interval in ms |
STALLED_JOB_THRESHOLD_MS |
600000 |
Age before a queued job is re-dispatched (10 min) |
REQUEUE_MAX_JOBS |
20 |
Max stalled jobs re-dispatched per cron run |
CLEANUP_JOB_AGE_MS |
604800000 |
Age before a terminal job is deleted (7 days) |
CLEANUP_MAX_JOBS |
500 |
Max terminal jobs deleted per cleanup run |
| Platform | Min instances | Max instances | Scale-out trigger | Scale-in cooldown |
|---|---|---|---|---|
| Vercel | 0 (serverless) | Unlimited (plan-gated) | Each inbound request | Immediate (idle) |
| Kubernetes (CPU/mem) | 2 | 10 | CPU > 60% or memory > 70% | 5 min (300 s stabilisation) |
| Kubernetes (queue) | 2 | 10 | 5 queued jobs per replica | 5 min (300 s cooldown) |
| Fly.io | 1 (warm) | Unlimited (concurrency-driven) | 50 concurrent requests/machine | 10 min idle timeout |
- Check
/api/health/circuit-breakers— isaiBackendOPEN? - Check
/api/health/ready— is the AI backend reachable? - If the AI backend is down, jobs accumulate in
queuedstate. They will be automatically re-dispatched by the cron job every 5 minutes once the backend recovers. - To manually trigger re-dispatch:
curl -H "Authorization: Bearer $CRON_SECRET" https://<host>/api/cron/requeue-stalled-jobs - On Kubernetes, force an immediate cron run:
kubectl create job --from=cronjob/clipcash-requeue-stalled-jobs manual-requeue -n clipcash
- Check the Grafana "Memory RSS" panel for the trend.
- If RSS is above 900 Mi on Kubernetes, the HPA should already be scaling out. Verify with
kubectl get hpa -n clipcash. - If not scaling: check that the metrics-server is running (
kubectl top pods -n clipcash). - Emergency: manually scale up:
kubectl scale deployment clipcash-frontend --replicas=5 -n clipcash - Investigate the root cause — a memory leak is likely if RSS grows monotonically even at low job volume.
- The HPA max (10) is a hard cap. If you consistently need more, raise
maxReplicasin both HPA files and re-apply. - Before raising the cap, check whether the bottleneck is actually Redis or the AI backend — more frontend replicas won't help if Redis is saturated.
- Redis connection pool saturation: Connection pooling is automatically configured. Each Next.js instance maintains a persistent connection to Redis. Check Redis server metrics to ensure it can handle the connection load.
- Monitor Redis health via
/api/health/redisendpoint to verify connectivity across all instances. - If Redis is the bottleneck, consider:
- Upgrading your Redis plan for more connections
- Using Redis Cluster for horizontal scaling
- Implementing read replicas for read-heavy workloads
- This is expected if
min_machines_running = 0(not the default). The default is1. - If cold starts are unacceptable, set
min_machines_running = 2infly.tomland redeploy. - Check machine status:
fly machines list - Force a machine to start:
fly machines start <machine-id>
Vercel: Check the Cron tab in the Vercel dashboard. If the job shows as failed, inspect the function logs for that invocation.
Kubernetes: kubectl get jobs -n clipcash and kubectl logs job/<job-name> -n clipcash
Fly.io: Fly cron is in beta. If it fails, trigger manually:
curl -sf \
-H "Authorization: Bearer $CRON_SECRET" \
https://<app-name>.fly.dev/api/cron/requeue-stalled-jobsSymptoms:
- Logs show "Redis not available" or "Using in-memory storage as fallback"
/api/health/redisreturns 503- Job state inconsistency across instances
Diagnosis:
- Check Redis health:
curl https://<host>/api/health/redis - Review Redis connection logs for error messages
- Verify
REDIS_URLis correctly set in all environments - Test Redis connectivity:
redis-cli -u $REDIS_URL ping
Solutions:
- Connection timeout: Increase
REDIS_CONNECT_TIMEOUTandREDIS_COMMAND_TIMEOUT - Network issues: Check firewall rules and security groups
- TLS mismatch: Ensure URL uses
rediss://for TLS connections - Max connections: Verify Redis server allows enough connections for your instance count
- Temporary outage: System automatically falls back to in-memory storage and reconnects automatically
- Persistent failure: Check Redis provider status page and connection credentials
Monitoring:
- Set up alerts for
/api/health/redisreturning non-200 status - Monitor
redis.reconnectAttemptsin health metrics - Track
fallback.activestatus to detect when in-memory fallback is used
A circuit breaker opening is a signal of an external service failure, not an application capacity problem. Scaling up more replicas will not fix an open circuit breaker. Follow the runbook in FALLBACK_BEHAVIORS.md for the affected service.
- Verify
METRICS_TOKENis set in your environment / secrets. - In development with no token set, the endpoint accepts unauthenticated requests (logged as a warning).
- In production with no token set, all requests are denied. Set the token and redeploy.