Optimizing TypeScript Full‑Stack Development with Postgres, JWT Authentication, and LLM‑Based Automation for Cost‑Effective Deployments
- Keyword
- TypeScript full-stack optimization
- Length
- 3051 words
- Read
- 14 min
I keep the stack small: a Next.js API written in TypeScript, a Neon Postgres database, and a Render worker that runs the CI/CD pipeline generated by an LLM. The result is a deploy that fits under a free tier, wakes in under a second, and stays within the rate limits of the services I use.
How to integrate LLM‑driven CI/CD into a TypeScript stack?

In a real project the CI pipeline is the first thing that breaks when you add a new dependency or change the DB schema. An LLM can generate the YAML for you, but you still need to verify the steps and guard against rate‑limit errors from the provider.
- Prompt the LLM for a baseline workflow – I use Groq’s
llama‑3.1‑70bwith a prompt that includes the exact Node version, thepnpm installcommand, and the test script. - Save the output to
.github/workflows/ci.yml– keep the file under version control so you can review changes. - Add a “guard” job that checks the size of the
node_modulesfolder – free tiers on Render and GitHub Actions have a 2 GB artifact limit; a sudden increase usually means a stray dev dependency. - Run a cheap “smoke test” against a Neon read‑only replica – this catches connection‑string mistakes before they hit production.
- Fail fast on 429 responses – both Render and Neon return
429 Too Many Requestswhen you exceed the free‑tier quota. The guard job should abort the pipeline and send a Slack webhook.
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm run lint
- run: pnpm run test
guard:
needs: build
runs-on: ubuntu-latest
steps:
- name: Check node_modules size
run: |
size=$(du -s node_modules | cut -f1)
if [ "$size" -gt 2000000 ]; then
echo "node_modules too large ($size KB)"
exit 1
fi
- name: Smoke test DB connection
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: |
npx ts-node -e "import { Client } from 'pg'; \
const c = new Client({ connectionString: process.env.DATABASE_URL }); \
c.connect().then(() => console.log('OK')).catch(e => { console.error(e); process.exit(1); })"
Failure modes to watch
- Rate limits – Neon free tier allows 20 connections per second. If the guard job runs in parallel on many PRs you’ll see
429. Throttle the job withconcurrency: group: ci-${{ github.ref }}. - Cold starts – Render’s free workers spin down after 15 minutes of inactivity. The first request after a spin‑down adds ~800 ms latency; I mitigate this by pinging the
/healthendpoint from a GitHub Action every hour. - Unexpected invoices – Some LLM providers charge per token after a free quota. I cap the token usage in the prompt and log the cost to a private repo.
What are the best practices for secure JWT authentication with Postgres?

JWTs are convenient, but they become a security liability when you store them forever or use the same secret across environments. Here’s the pattern I use for a TypeScript API that talks to Neon.
- Short‑lived access tokens (5 min) – store only the user ID and a
roleclaim. - Refresh tokens in an HttpOnly cookie – the cookie contains a UUID that points to a row in a
refresh_tokenstable. - Rotate refresh tokens on each use – delete the old row and insert a new one; this prevents replay attacks.
- Store the JWT secret in Render’s encrypted environment variables – never commit it.
- Validate the token against the DB only when the
roleclaim is missing – most endpoints can trust the JWT alone, saving a DB round‑trip.
// src/auth.ts
import { sign, verify, JwtPayload } from 'jsonwebtoken';
import { Pool } from 'pg';
import { randomUUID } from 'crypto';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const ACCESS_TTL = '5m';
const REFRESH_TTL = 60 * 60 * 24 * 30; // 30 days
export async function createTokens(userId: string, role: string) {
const access = sign({ sub: userId, role }, process.env.JWT_SECRET!, { expiresIn: ACCESS_TTL });
const refreshId = randomUUID();
await pool.query(
`INSERT INTO refresh_tokens (id, user_id, expires_at) VALUES ($1, $2, now() + interval '${REFRESH_TTL}s')`,
[refreshId, userId],
);
const refresh = sign({ jti: refreshId }, process.env.JWT_SECRET!, { expiresIn: `${REFRESH_TTL}s` });
return { access, refresh };
}
export async function verifyRefresh(token: string) {
const payload = verify(token, process.env.JWT_SECRET!) as JwtPayload;
const { rows } = await pool.query(
`DELETE FROM refresh_tokens WHERE id = $1 AND expires_at > now() RETURNING user_id`,
[payload.jti],
);
if (!rows.length) throw new Error('Invalid or expired refresh token');
return rows[0].user_id;
}
Failure modes to watch
- Stale cookies – browsers keep HttpOnly cookies even after the server restarts. I clear them on a 401 response and force a re‑login.
- DB connection exhaustion – each token verification that falls back to the DB consumes a connection. On Neon’s free tier I keep the pool size at 1 and use
await pool.connect()only when necessary. - Token leakage – never log the raw JWT; mask it in logs (
token.slice(0,6)+'…').
Which cheap hosting providers support scalable TypeScript applications?

Free or low‑cost tiers impose three hard constraints: sleep after inactivity, connection limits, and hard caps on outbound bandwidth. I’ve run the same Next.js API on three providers and kept the monthly bill under $0.
| Provider | Sleep timeout | Free‑tier DB limit | Notes |
|---|---|---|---|
| Render (Free Web Service) | 15 min | Neon (20 conn) | Auto‑HTTPS, easy env‑var UI |
| Cloudflare Pages (Workers) | No sleep (edge) | Cloudflare KV for small data, Neon for relational | Ideal for static front‑ends + API Workers |
| Railway (Free) | 30 min | 1 GB Postgres, 500 MB storage | Simple “Deploy from GitHub” button |
My preferred stack
- Frontend – Astro on Cloudflare Pages. The HTML is static, so there is no cold start.
- API – Next.js API routes deployed as a Render worker. The worker sleeps after 15 minutes, which is fine for a personal blog or a side‑project dashboard.
- Database – Neon Postgres with the
scale‑to‑zerooption. When the API is idle the DB also sleeps, keeping the connection count at zero.
Deploy checklist
- Set
NODE_ENV=productionandNEXT_TELEMETRY_DISABLED=1to avoid unwanted telemetry pings. - Add a health endpoint (
/api/health) that returns200only after a successful DB ping; Render’s health checks use this to decide whether to restart the container. - Enable Render’s “Auto‑Deploy on Push” but limit the concurrency to 1 to stay under the 20‑connection limit of Neon.
- Pin the Node version in
render.yamlto avoid accidental upgrades that break native modules.
# render.yaml
services:
- type: web
name: api
env: node
plan: free
buildCommand: pnpm install && pnpm run build
startCommand: pnpm start
envVars:
- key: DATABASE_URL
fromSecret: neon_url
- key: JWT_SECRET
fromSecret: jwt_secret
healthCheckPath: /api/health
autoDeploy: true
concurrency: 1
Failure modes to watch
- Unexpected spin‑down – Render may kill a container after a failed health check. I keep the health endpoint lightweight (just
SELECT 1) to avoid false positives. - Neon connection spikes – a burst of parallel requests can exceed the 20‑connection limit, causing
ECONNREFUSED. I wrap DB calls in a retry with exponential back‑off and return a503if retries fail. - Bandwidth throttling – Cloudflare Workers have a 100 MB/day free egress limit. I store images in an S3‑compatible bucket and serve them via signed URLs, keeping the Worker’s egress low.
What I would do on a Monday

- Pull the latest LLM‑generated CI file, run the guard job locally, and merge it if the size check passes.
- Rotate the JWT secret in Render’s secret store, invalidate all refresh tokens, and send a one‑time email to existing users.
- Trigger a cold‑start ping (
curl https://my‑api.onrender.com/api/health) to wake the Render worker and verify that Neon reconnects without hitting the 20‑connection ceiling. - Review the GitHub Action logs for any
429entries from Neon; if I see more than two in the last 24 h, I’ll add a back‑off step to the pipeline.
That’s the whole workflow I rely on for a cost‑effective, production‑ready TypeScript full‑stack app. No fluff, just the bits that keep the service alive under free‑tier constraints.
Related reading
- Scaling a TypeScript Full‑Stack with Postgres, JWT Auth, and LLM‑Driven CI/CD on a Budget
- Building a Low‑Cost Full‑Stack TypeScript App with Postgres, JWT Auth, and LLM‑Powered Automation
Managing Connection Pools on Neon without Hitting the 20‑Connection Ceiling

Neon’s free tier gives me 20 simultaneous connections. If I let an ORM open a pool of 10 per instance and I run three Render workers, I’m already at the limit and will see ECONNREFUSED spikes during traffic bursts.
1. Use a single‑purpose pool per service
// src/db.ts
import { Pool } from 'pg';
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
// Keep the pool tiny; Render workers are short‑lived.
max: 3, // three concurrent queries is enough for our API
idleTimeoutMillis: 5_000,
// Neon recommends `application_name` for debugging.
application_name: 'api-worker',
});
export async function query<T>(text: string, params?: any[]): Promise<T[]> {
const client = await pool.connect();
try {
const res = await client.query<T>(text, params);
return res.rows;
} finally {
client.release();
}
}
Why this works: A max: 3 pool means each container can never open more than three sockets. With three containers (API, worker, admin UI) I stay under 9 connections, leaving a safety buffer for background jobs and the occasional admin console session.
2. Detect and back‑off on too many connections
// src/middleware/connectionGuard.ts
import type { Request, Response, NextFunction } from 'express';
import { query } from './db';
export async function connectionGuard(
req: Request,
_res: Response,
next: NextFunction,
) {
try {
// A cheap ping that also forces the pool to allocate a client.
await query('SELECT 1');
next();
} catch (err: any) {
if (err.code === '57P03') { // too_many_connections
// Return 503 so the client retries later.
_res.status(503).json({ error: 'Service overloaded, try again.' });
} else {
next(err);
}
}
}
Add the guard early in the middleware chain so every request fails fast if the DB is saturated.
3. Graceful shutdown to release sockets
// src/server.ts
import http from 'http';
import app from './app';
import { pool } from './db';
const server = http.createServer(app);
process.on('SIGTERM', async () => {
console.log('SIGTERM received – closing HTTP server and DB pool');
server.close(() => console.log('HTTP server closed'));
await pool.end(); // closes all idle connections
process.exit(0);
});
When Render stops a container, the pool is torn down cleanly, preventing “ghost” connections that linger in Neon’s count.
Failure modes to watch
| Symptom | Cause | Mitigation |
|---|---|---|
ECONNREFUSED after a traffic spike |
All 20 connections used by other containers or admin UI | Reduce max per pool, add a dedicated “DB‑router” service that serializes heavy queries |
57P03 (too many connections) on a cold start |
Render spins up multiple workers simultaneously | Add a start‑up delay (await new Promise(r => setTimeout(r, 2_000))) before the first DB query |
| Idle connections linger after a crash | Process exits without pool.end() |
Use process.on('uncaughtException') to call pool.end() before exiting |
Implementing Refresh‑Token Rotation with Minimal State

JWTs are great for stateless auth, but a long‑lived access token is a risk. I keep a short‑lived access token (5 min) and a refresh token that I rotate on every use. The rotation state lives in Postgres, so I never need a separate Redis cache.
1. Schema for refresh tokens
-- migrations/2024-09-01-create-refresh-token.sql
CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL,
issued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
UNIQUE (user_id, token_hash)
);
I store a hash of the token, not the raw value, to avoid leaking secrets if the DB is compromised.
2. Issue a refresh token
// src/auth/refresh.ts
import crypto from 'crypto';
import { query } from '../db';
import { signJwt } from './jwt';
export async function issueRefreshToken(userId: string) {
const raw = crypto.randomBytes(48).toString('base64url');
const hash = crypto.createHash('sha256').update(raw).digest('hex');
const expires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
await query(`
INSERT INTO refresh_tokens (user_id, token_hash, expires_at)
VALUES ($1, $2, $3)
`, [userId, hash, expires]);
// Return the raw token to the client (it never touches the DB)
return raw;
}
3. Rotate on use
// src/auth/rotate.ts
import { query } from '../db';
import { signJwt } from './jwt';
import { verifyRefreshToken } from './verify';
export async function rotateRefreshToken(
rawToken: string,
): Promise<{ accessToken: string; newRefreshToken: string }> {
const hash = crypto.createHash('sha256').update(rawToken).digest('hex');
// 1️⃣ Find the record, ensure it isn’t revoked or expired
const rows = await query<{
id: string;
user_id: string;
expires_at: string;
revoked_at: string | null;
}>(`
SELECT *
FROM refresh_tokens
WHERE token_hash = $1
AND revoked_at IS NULL
AND expires_at > now()
FOR UPDATE
`, [hash]);
if (!rows.length) throw new Error('Invalid or expired refresh token');
const token = rows[0];
// 2️⃣ Revoke the old token
await query(`
UPDATE refresh_tokens
SET revoked_at = now()
WHERE id = $1
`, [token.id]);
// 3️⃣ Issue a fresh one
const newRaw = await issueRefreshToken(token.user_id);
// 4️⃣ Return a fresh access JWT
const accessToken = signJwt({ sub: token.user_id }, '5m');
return { accessToken, newRefreshToken: newRaw };
}
Because the SELECT uses FOR UPDATE, concurrent attempts to reuse the same refresh token will block each other, and only the first will succeed. The others will hit the “revoked” check and fail fast.
4. Cookie handling (Render + Cloudflare)
// src/routes/auth.ts
router.post('/refresh', async (req, res) => {
const raw = req.cookies['rt']; // HttpOnly, Secure
try {
const { accessToken, newRefreshToken } = await rotateRefreshToken(raw);
res
.cookie('rt', newRefreshToken, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 30 * 24 * 60 * 60 * 1000,
})
.json({ accessToken });
} catch (e) {
res.clearCookie('rt').status(401).json({ error: 'Refresh failed' });
}
});
Failure modes to watch
| Symptom | Cause | Fix |
|---|---|---|
| “Refresh failed” after a page reload | Client sent an old cookie after a network retry | Set SameSite=Lax and make the front‑end retry only once |
| DB deadlock on high refresh traffic | Multiple workers hit the same token row simultaneously | Keep the rotation endpoint rate‑limited (express-rate-limit) and monitor pg_locks |
Tokens never expire because revoked_at is null |
A bug skipped the UPDATE step | Add a unit test that forces a second rotation and asserts the first row is revoked |
Batching LLM Calls to Stay Within Free‑Tier Quotas

My CI pipeline uses Groq for draft generation and OpenRouter :free as a fallback. Both have per‑minute request caps. The trick is to batch prompts and reuse the same model call for multiple files.
1. Collect pending changes in a single payload
// src/ci/llmBatch.ts
import { execSync } from 'child_process';
import fetch from 'node-fetch';
interface FileChange {
path: string;
diff: string;
}
function getStagedDiffs(): FileChange[] {
const raw = execSync('git diff --cached', { encoding: 'utf8' });
// Very naive split – good enough for a small repo.
return raw
.split('\ndiff --git')
.filter(Boolean)
.map(chunk => {
const [header, ...body] = chunk.split('\n');
const path = header.match(/b\/(.+)$/)?.[1] ?? 'unknown';
return { path, diff: body.join('\n') };
});
}
2. Build a single prompt
function buildPrompt(changes: FileChange[]): string {
const intro = `You are a senior TypeScript engineer. Review the following diffs and suggest improvements. Return a JSON array with objects {file, suggestion}.`;
const bodies = changes.map(c => `--- ${c.path} ---\n${c.diff}`).join('\n');
return `${intro}\n${bodies}`;
}
3. Call the LLM once
async function callGroq(prompt: string) {
const resp = await fetch('https://api.groq.com/openai/v1/chat/completions', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.GROQ_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'llama3-70b-8192',
messages: [{ role: 'user', content: prompt }],
temperature: 0,
}),
});
if (!resp.ok) {
// Fallback to OpenRouter free tier
return callOpenRouter(prompt);
}
const data = await resp.json();
return JSON.parse(data.choices[0].message.content);
}
4. Apply suggestions automatically (or comment them)
async function runBatchReview() {
const changes = getStagedDiffs();
if (!changes.length) return;
const prompt = buildPrompt(changes);
const suggestions = await callGroq(prompt);
for (const { file, suggestion } of suggestions) {
console.log(`🛠️ ${file}: ${suggestion}`);
// Optionally, write a comment file for the PR.
}
}
Running node src/ci/llmBatch.ts in the GitHub Action costs a single request regardless of how many files changed, keeping me well under the free‑tier limit (≈ 30 req/min on Groq, 100 req/min on OpenRouter free).
Failure modes to watch
| Symptom | Cause | Remedy |
|---|---|---|
429 Too Many Requests from Groq |
Burst of CI runs on multiple PRs | Add a GitHub Action concurrency group to serialize runs |
| JSON parse error from LLM output | Model returned prose instead of JSON | Enforce a strict schema in the prompt and add a validation step (ajv) |
| Prompt exceeds model token limit | Too many diffs in one batch | Split into two batches when prompt.length > 12_000 characters |
Observability with Minimal Overhead on Free Tiers
When you’re running on Render’s free tier, you can’t afford a full‑blown APM. A few well‑placed logs and a lightweight metrics endpoint are enough to spot the 429s, cold‑starts, and DB connection leaks described earlier.
1. Structured request logging
// src/middleware/logger.ts
import morgan from 'morgan';
import { v4 as uuidv4 } from 'uuid';
export const requestId = (req, res, next) => {
req.id = uuidv4();
res.setHeader('X-Request-Id', req.id);
next();
};
export const jsonLogger = morgan((tokens, req, res) => {
return JSON.stringify({
ts: new Date().toISOString(),
rid: req.id,
method: tokens.method(req, res),
url: tokens.url(req, res),
status: Number(tokens.status(req, res)),
time: Number(tokens['response-time'](req, res)),
user: req.user?.id ?? null,
});
});
Add app.use(requestId, jsonLogger); early. Render captures stdout and makes it searchable, so I can query for status:503 or time>2000 without extra cost.
2. Tiny health‑metrics endpoint
// src/routes/metrics.ts
import { Router } from 'express';
import { pool } from '../db';
const router = Router();
router.get('/metrics', async (_req, res) => {
const client = await pool.connect();
try {
const { rows } = await client.query(`
SELECT
count(*) FILTER (WHERE state = 'idle') AS idle,
count(*) FILTER (WHERE state = 'active') AS active,
count(*) FILTER (WHERE state = 'idle in transaction') AS idle_tx
FROM pg_stat_activity
WHERE datname = current_database()
`);
res.json({
db: rows[0],
uptime: process.uptime(),
});
} finally {
client.release();
}
});
export default router;
I scrape this endpoint from a cheap cron job (GitHub Action scheduled every 5 min) and push the JSON to a free Grafana Cloud instance. The data volume is < 1 KB per run, well within free limits.
3. Alert on cold‑starts
Render emits a STARTUP log line when a container boots. I set up a GitHub Action that runs:
name: Cold‑Start Alert
on:
schedule:
- cron: '*/10 * * * *' # every 10 minutes
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Pull logs
run: |
curl -s "https://api.render.com/v1/services/${{ secrets.RENDER_SERVICE_ID }}/logs?since=10m" \
-H "Authorization: Bearer ${{ secrets.RENDER_API_KEY }}" \
| grep STARTUP && exit 1 || exit 0
- name: Notify Slack
if: failure()
uses: slackapi/slack-github-action@v1.23.0
with:
payload: '{"text":"⚡️ Render container started (cold‑start)"}'
channel-id: ${{ secrets.SLACK_CHANNEL }}
If a cold‑start occurs, the job fails and posts to Slack, reminding me to check if the health check is too aggressive or if the free tier is throttling the container.
Failure modes to watch
| Symptom | Cause | Fix | |---------|-------|
Reducing Bundle Size with Conditional Imports and Edge‑Ready Code
When I first pushed a full‑stack Astro + Node API to Cloudflare Pages, the JavaScript bundle hit 1.8 MB and the cold‑start time jumped from 300 ms to 1.2 s. The culprit was a monolithic src/lib/index.ts that eagerly imported everything, including a heavy LLM client that never runs in the browser.
1. Split the entry points
I keep two entry points:
src/client.ts– only the code that runs in the browser.src/server.ts– the API layer that runs on Render or Cloudflare Workers.
// src/client.ts
export { fetchPosts } from './api/posts';
export { renderMarkdown } from './ui/markdown';
// src/server.ts
export { createPost } from './api/posts';
export { generateDraft } from './llm/draft';
The tsconfig.json then points the outDir to separate folders:
{
"compilerOptions": {
"module": "ESNext",
"target": "ES2022",
"outDir": "./dist",
"rootDir": "./src",
"paths": {
"@client/*": ["client.ts"],
"@server/*": ["server.ts"]
}
},
"include": ["src/**/*.ts"]
}
2. Use dynamic import() for the LLM client
The LLM client pulls in node-fetch, zod, and a 500 KB protobuf bundle. I only need it on the /draft endpoint, so I lazy‑load it:
// src/api/draft.ts
import type { Request, Response } from 'express';
export async function generateDraft(req: Request, res: Response) {
// Guard against accidental browser execution
if (process.env.NODE_ENV !== 'production' && typeof window !== 'undefined') {
return res.status(400).json({ error: 'LLM endpoint not available in the browser' });
}
const { generate } = await import('../llm/client'); // <-- dynamic import
const { prompt } = req.body;
try {
const draft = await generate(prompt);
res.json({ draft });
} catch (err) {
console.error('LLM error', err);
res.status(502).json({ error: 'LLM service unavailable' });
}
}
Because the import is inside the handler, the server bundle stays under 600 KB, and the client bundle stays under 300 KB. The cold‑start on Cloudflare Workers drops back to ≈ 350 ms.
3. Verify the size locally
I run npm run build && npx esbuild ./dist/client.js --bundle --minify --metafile=meta.json and then inspect meta.json:
{
"outputs": {
"dist/client.js": {
"bytes": 287432,
"imports": [
{ "path": "src/ui/markdown.js", "bytes": 12458 },
{ "path": "src/api/posts.js", "bytes": 56231 }
]
}
}
}
If any import pushes the bundle over 350 KB, I refactor it into a separate module and load it dynamically.
4. Failure mode to watch
| Symptom | Cause | Fix |
|---|---|---|
ReferenceError: fetch is not defined in the browser |
Accidentally imported a Node‑only module (e.g., node-fetch) in client.ts |
Move the import to a server‑only file or guard with if (typeof window === 'undefined') |
| Cold‑start > 1 s after a deploy | Bundle still contains the LLM client | Run the size audit above, ensure the dynamic import is inside the handler |
What I’d do on a Monday: Open the CI artifact, check the bundle size diff, and if it grew, roll back the last commit that added a new heavy dependency. Then push a hot‑fix that moves the import behind a dynamic import().
Storing Large Text Blobs Efficiently with Postgres TOAST and External Object Storage
In the early days of this site I stored every uploaded image as a bytea column. On Neon the free tier caps storage at 10 GB, and a single 5 MB PNG would instantly eat 0.05 % of the quota. The better pattern is: metadata in Postgres, binary payload in object storage. I keep the two in sync with a tiny TypeScript helper.
1. Table schema
-- posts table stores markdown and optional cover image reference
CREATE TABLE posts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
body_md TEXT NOT NULL,
cover_key TEXT, -- key in object storage, nullable
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
Postgres automatically compresses body_md with TOAST, so even a 2 MB article stays under the free‑tier row size limit.
2. Upload helper (TypeScript)
I use Cloudflare R2 because it has a generous free tier and a simple S3‑compatible API.
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { Pool } from 'pg';
import { randomUUID } from 'crypto';
import { Readable } from 'stream';
const s3 = new S3Client({
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
region: 'auto',
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
});
const pg = new Pool({ connectionString: process.env.DATABASE_URL });
export async function storeCoverImage(
postId: string,
mime: string,
data: Buffer
): Promise<void> {
const key = `covers/${postId}/${randomUUID()}`;
// 1️⃣ Upload to R2
await s3.send(
new PutObjectCommand({
Bucket: process.env.R2_BUCKET!,
Key: key,
Body: data,
ContentType: mime,
})
);
// 2️⃣ Update the row
const client = await pg.connect();
try {
await client.query(
`UPDATE posts SET cover_key = $1, updated_at = now() WHERE id = $2`,
[key, postId]
);
} finally {
client.release();
}
}
If the upload fails, the DB transaction never runs, so we never end up with a dangling reference.
3. Serving the image
A cheap Cloudflare Worker proxies the image, adding a short‑lived signed URL to avoid open bucket exposure.
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { GetObjectCommand } from '@aws-sdk/client-s3';
export async function handleRequest(request: Request) {
const url = new URL(request.url);
const postId = url.pathname.split('/')[2]; // /cover/:postId
const client = await pg.connect();
try {
const { rows } = await client.query(
`SELECT cover_key FROM posts WHERE id = $1`,
[postId]
);
if (!rows[0]?.cover_key) return new Response('No cover', { status: 404 });
const command = new GetObjectCommand({
Bucket: process.env.R2_BUCKET!,
Key: rows[0].cover_key,
});
const signed = await getSignedUrl(s3, command, { expiresIn: 300 });
return Response.redirect(signed, 302);
} finally {
client.release();
}
}
The worker runs in a few milliseconds, and the signed URL expires after five minutes, keeping the bucket private.
4. Failure mode checklist
| Symptom | Likely cause | Remedy |
|---|---|---|
404 from /cover/:id |
cover_key is NULL because the upload crashed after DB commit |
Wrap upload and DB update in a single try/catch; on failure delete the partial object with DeleteObjectCommand. |
| R2 bucket hits “exceeded quota” | Storing raw uploads without size limits | Enforce a max size (e.g., 2 MB) in the API layer and reject larger files early. |
| Cold‑start latency spikes when many images are requested | Worker does a DB lookup on every request | Add a Cloudflare KV cache keyed by postId → cover_key with a TTL of 10 minutes. |
What I’d do on a Monday: Run a quick script that scans SELECT id FROM posts WHERE cover_key IS NULL; and alerts if any rows appear, indicating a broken upload pipeline. Then I’d purge the KV cache for any keys that were recently updated.
Leveraging Server‑Side Rendering (SSR) Caching with stale‑while‑revalidate on Render
Static HTML is great for SEO, but my blog needs fresh comment counts and LLM‑generated drafts. I solve this with a tiny SSR cache that respects the free tier’s 20‑connection limit and avoids hammering Neon.
1. Cache middleware (TypeScript)
import type { Request, Response, NextFunction } from 'express';
import LRU from 'lru-cache';
const ssrCache = new LRU<string, { html: string; expires: number }>({
max: 500, // store up to 500 pages
ttl: 1000 * 60 * 5, // 5 minutes default TTL
allowStale: true, // serve stale while revalidating
});
export function ssrCacheMiddleware(req: Request, res: Response, next: NextFunction) {
if (req.method !== 'GET' || req.headers['x-no-cache']) return next();
const key = req.originalUrl;
const cached = ssrCache.get(key);
if (cached) {
// Serve stale copy immediately
res.setHeader('x-cache', 'HIT');
res.send(cached.html);
// If stale, re‑render in the background
if (Date.now() > cached.expires) {
renderAndStore(key, req).catch(err => console.error('SSR revalidate failed', err));
}
return;
}
// No cache – render and store
renderAndStore(key, req)
.then(html => {
res.setHeader('x-cache', 'MISS');
res.send(html);
})
.catch(next);
}
// Helper that runs the actual rendering
async function renderAndStore(key: string, req: Request): Promise<string> {
const html = await renderPage(req); // your Astro/Next render function
ssrCache.set(key, { html, expires: Date.now() + 1000 * 60 * 5 });
return html;
}
The allowStale flag guarantees that a user never waits for the DB query; the background revalidation keeps the data fresh.
2. Integration with Express
import express from 'express';
import { ssrCacheMiddleware } from './middleware/ssrCache';
const app = express();
app.use(ssrCacheMiddleware);
app.use('/api', apiRouter); // JWT‑protected API routes
app.use(express.static('public'));
app.listen(process.env.PORT ?? 3000, () => {
console.log('🚀 Server ready');
});
Because the cache lives in memory, it does not consume any Neon connections. The only DB hit occurs when renderPage runs, and that happens at most once per 5 minutes per URL.
3. Observing cache health
I expose a tiny endpoint that Render’s health check scrapes:
router.get('/cache-stats', (_req, res) => {
res.json({
size: ssrCache.size,
hits: ssrCache.stats?.hits ?? 0,
misses: ssrCache.stats?.misses ?? 0,
});
});
If the hit ratio drops below 70 % over a 30‑minute window, I know the TTL is too short for the traffic pattern and I bump it.
4. Failure modes
| Symptom | Why it happens | Fix |
|---|---|---|
Error: pool is exhausted |
SSR rendering spawns many parallel DB queries during a traffic spike | Reduce max in the LRU cache, increase TTL, or add a semaphore (p-limit) around the DB call inside renderPage. |
| Stale comment count for > 15 min | Background revalidation failed silently | Add a .catch that logs to a free Grafana alert; optionally retry with exponential back‑off. |
| Memory bloat on Render | Cache grows without eviction because max is too high |
Lower max or enable maxSize with a per‑item size estimator. |
What I’d do on a Monday: Pull the /cache-stats endpoint, compare the hit ratio to the threshold, and if it’s low, push a config change that doubles the TTL. Then I’d run a quick load test locally (ab -n 200 -c 20 /) to verify the connection pool stays under the 20‑connection ceiling.
Image credits
- Cover: AI-generated illustration
- inline: rawpixel / Pixabay
- inline: rawpixel / Pixabay
- inline: Tumisu / Pixabay
- inline: cookieone / Pixabay
- inline: Godfrey_atima / Pixabay
- inline: the_iop / Pixabay
- inline: Techaltruistic / Pixabay