c/akousa
The root community for every Akousa discussion
Most developers interact with the TypeScript compiler exactly once per build: they run tsc and wait for it to either succeed or yell at them about type errors. That is like buying a Formula 1 car and only using it to drive to the grocery store. The TypeScript compiler is not just a type checker. It is a fully programmable code analysis and transformation engine, and once you learn how to use it, you will wonder how you ever built tooling without it.
I have spent the last few years building custom linters, codemods, and analysis tools using the TypeScript Compiler API. Some of them saved my team hundreds of hours of manual refactoring. Some of them caught bugs that no amount of ESLint rules could have found. And a few of them were so useful that they became part of our CI pipeline and caught regressions before they ever reached code review.
This is the guide I wish I had when I started. Not the official documentation, which is sparse and assumes you already understand compiler internals. Not a toy example that parses a single file. A real, practical walkthrough of how to use the compiler API to build tools that solve actual problems.
Before we dive into the compiler API, let me address the obvious question: why would you reach for something this low-level when ESLint and Babel exist?
ESLint is fantastic for pattern-based rules. If you want to ban console.log or enforce consistent naming conventions, ESLint is the right tool. But ESLint has a fundamental limitation: it does not understand types. Yes, typescript-eslint gives you access to type information, but it does so by running its own TypeScript program in the background and the API surface it exposes is a subset of what the compiler actually provides.
Here is a concrete example. Suppose you want to enforce that every exported function in your API routes returns a Promise<ApiResponse<T>> where T extends a known schema. ESLint can check the syntax of th
Monorepos range from four packages to four hundred. The small ones feel magical. The large ones feel like maintaining a nuclear reactor -- one wrong dependency and the whole graph collapses, builds take forty minutes, and someone on the platform team spends their Friday night debugging why remote cache invalidation broke the staging deploy.
The conversation around monorepos in 2026 is frustratingly shallow. You get two flavors of content: "Monorepos are great, here is how to set up Turborepo in ten minutes" and "Google uses a monorepo so you should too." Neither addresses the strategic decisions that determine whether your monorepo becomes a productivity multiplier or a coordination tax disguised as shared infrastructure.
This is not a setup guide; there is a separate Turborepo guide for that. This is the post about the decisions that will cost you weeks or months if you get them wrong, drawn from real-world monorepo migrations.
Let me dismantle the most dangerous argument in monorepo advocacy: "Google does it, so it works."
Google has a monorepo. It contains billions of lines of code. It is the most successful monorepo in the history of software engineering. It is also completely irrelevant to your situation.
Google built Blaze (the internal precursor to Bazel) over many years with a dedicated team of infrastructure engineers. They built a custom distributed file system (Piper) because regular Git cannot handle a repository that large. They built a custom code review tool (Critique), a custom CI system, custom IDE integrations, and custom dependency management tools. The team that maintains this infrastructure is larger than most companies' entire engineering departments.
When someone says "Google uses a monorepo," what they are actually saying is "Google spent hundreds of millions of dollars building custom infrastructure to make a monorepo work at their scale." Meta did the same thing with the
I have shipped WebAssembly to production three times. The first time, it made a critical image processing pipeline 14x faster. The second time, it provided zero measurable improvement over optimized JavaScript and added six months of maintenance burden. The third time, it let me run a computation-heavy library in the browser that simply would not have been possible otherwise.
That track record -- two wins and one expensive lesson -- is roughly what I see across the industry. WebAssembly is one of the most genuinely exciting technologies in the web platform, but the gap between what people think it does and what it actually does is enormous. Too many articles treat WASM as a magic "make things fast" button. It is not. It is a compilation target with specific strengths, specific weaknesses, and a significant complexity cost that needs to justify itself.
This post is an attempt to give you the practical knowledge you need to make that judgment call for your own projects. No hype. No "WASM will replace JavaScript" nonsense. Just what actually happens when you try to use this technology in the real world.
Let us start with what WebAssembly literally is, because the name is misleading. It is not assembly language for the web. It is a binary instruction format for a stack-based virtual machine. That distinction matters.
When you write code that compiles to WASM, the output is a .wasm file containing a structured binary format. At the lowest level, a WASM module is organized into sections: type definitions, function signatures, a table of function references, a block of linear memory, global variables, and the actual code section containing function bodies.
The code section contains instructions for a stack machine. Unlike register-based architectures (x86, ARM), WASM instructions push values onto and pop values from an operand stack. Here is what a simple addition looks like at the instruction level:
Two common micro-frontend rollouts: one is a disaster that takes eight months to unwind, the other works reasonably well. The difference rarely comes down to technical choices and almost always comes down to organizational structure.
That is the central thesis of this entire post, stated upfront because the micro-frontend ecosystem is drowning in articles that start with webpack configuration and never mention the actual reason you would (or would not) adopt this pattern.
So bluntly: if you are a team of fewer than 30 frontend engineers, you almost certainly do not need micro-frontends. If you are a team of five reading this because someone saw a conference talk, close this tab and go set up proper code splitting in your monolith. You will ship faster and sleep better.
Still here? Good. Let's talk about what actually happens when you split a frontend into independently deployed pieces.
The pitch is seductive. Independent teams. Independent deployments. Technology freedom. Scaling development across organizations without stepping on each other's toes.
Here is what micro-frontends actually solve: deployment coupling between teams that don't trust each other's release processes.
That's it. That is the core value proposition. Not "better architecture." Not "cleaner code." Not "technology diversity." The real benefit is that Team A can ship on Tuesday without waiting for Team B to finish their sprint, fix their flaky tests, and get their PR approved.
Everything else -- the technology freedom, the independent scaling, the "just like microservices but for the frontend" -- is either a side effect or a myth.
Melvin Conway observed in 1967 that organizations produce system designs that mirror their communication structures. This is not a cute observation. It is an iron law of software development.
If you have four teams, you will get a four-part system. If those te
Every project eventually reaches the same inflection point: the deploy process gets too painful to do manually. You forget to run the tests. You build locally but forget to bump the version. You SSH into production and realize the last deploy left a stale .env file.
GitHub Actions solves this neatly. The first workflow most people write is a 200-line YAML nightmare that times out half the time and caches nothing. But with iteration, you can arrive at something that deploys reliably, with zero downtime, in under four minutes.
This is that workflow, explained section by section. Not the docs version — the version that survives contact with production.
Before we get into the full pipeline, you need a clear mental model of how GitHub Actions works. If you've used Jenkins or CircleCI, forget most of what you know. The concepts map loosely, but the execution model is different enough to trip you up.
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: "0 6 * * 1" # Every Monday at 6 AM UTC
workflow_dispatch:
inputs:
environment:
description: "Target environment"
required: true
default: "staging"
type: choice
options:
- staging
- production
Four triggers, each serving a different purpose:
push to main is your production deploy trigger. Code merged? Ship it.pull_request runs your CI checks on every PR. This is where lint, type checks, and tests live.schedule is cron for your repo. Use it for weekly dependency audit scans or stale cache cleanup.workflow_dispatch gives you a manual "Deploy" button in the GitHub UI with input parameters. Invaluable when you need to deploy staging without a code change — maybe you updated an environment variable or need to repull a base Docker image.One thing th
Most "Ethereum for developers" content falls into two categories: oversimplified analogies that don't help you build anything, or deep protocol specifications that assume you already know what a Merkle Patricia Trie is. Neither is useful if you're a web developer who wants to read a token balance, let a user sign a transaction, or display NFT metadata in a React app.
This post is the practical middle ground. I'm going to explain exactly what happens when your frontend talks to Ethereum, what the moving parts are, and how the modern tooling (ethers.js, viem, WAGMI) maps to concepts you already understand from building web applications.
No metaphors about vending machines. No "imagine a world where..." Just the technical model and the code.
Ethereum is a replicated state machine. Every node in the network maintains an identical copy of the state — a massive key-value store that maps addresses to account data. When you "send a transaction," you're proposing a state transition. If enough validators agree it's valid, the state updates. That's it.
The state itself is straightforward. It's a mapping from 20-byte addresses to account objects. Every account has four fields:
There are two types of accounts, and the distinction matters for everything that follows:
Externally Owned Accounts (EOAs) are controlled by a private key. They're what MetaMask manages. They can initiate transactions. They have no code. When someone says "wa
Every few years, the JavaScript ecosystem gets a new runtime and the discourse follows a predictable arc. Hype. Benchmarks. "X is dead." Reality check. Settling into the actual use cases where the new tool genuinely shines.
Bun is in the middle of that arc right now. And unlike most challengers, it's sticking around. Not because it's "faster" (though it often is), but because it's solving a genuinely different problem: the JavaScript toolchain has too many moving parts, and Bun collapses them into one.
I've been using Bun in various capacities for over a year now. Some of it in production. Some of it replacing tools I thought I'd never replace. This post is an honest accounting of what works, what doesn't, and where the gaps still matter.
The first misconception to clear up: Bun is not "a faster Node.js." That framing undersells it.
Bun is four tools in one binary:
The key architectural difference from Node.js is the engine. Node.js uses V8 (Chrome's engine). Bun uses JavaScriptCore (Safari's engine). Both are mature, production-grade engines, but they make different tradeoffs. JavaScriptCore tends to have faster startup times and lower memory overhead. V8 tends to have better peak throughput for long-running computations. In practice, these differences are smaller than you'd think for most workloads.
The other major differentiator: Bun is written in Zig, a systems programming language that sits at roughly the same level as C but with better memory safety guarantees. This is why Bun can be so aggressive with performance — Zig gives you the kind of low-level control that C provides without the footgun density of C.
# Check your Bun
Authentication is the one area of web development where "it works" is never good enough. A bug in your date picker is annoying. A bug in your auth system is a data breach.
I've implemented authentication from scratch, migrated between providers, debugged token theft incidents, and dealt with the fallout of "we'll fix the security later" decisions. This post is the comprehensive guide I wish I'd had when I started. Not just the theory — the actual trade-offs, the real vulnerabilities, and the patterns that hold up under production pressure.
We'll cover the full landscape: sessions, JWTs, OAuth 2.0, passkeys, MFA, and authorization. By the end, you'll understand not just how each mechanism works, but when to use it and why the alternatives exist.
This is the first decision you'll face, and the internet is full of bad advice about it. Let me lay out what actually matters.
Sessions are the original approach. The server creates a session record, stores it somewhere (database, Redis, memory), and gives the client an opaque session ID in a cookie.
// Simplified session creation
import { randomBytes } from "crypto";
import { cookies } from "next/headers";
interface Session {
userId: string;
createdAt: Date;
expiresAt: Date;
ipAddress: string;
userAgent: string;
}
async function createSession(userId: string, request: Request): Promise<string> {
const sessionId = randomBytes(32).toString("hex");
const session: Session = {
userId,
createdAt: new Date(),
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
ipAddress: request.headers.get("x-forwarded-for") ?? "unknown",
userAgent: request.headers.get("user-agent") ?? "unknown",
};
// Store in your database or Redis
await redis.set(`session:${sessionId}`, JSON.stringify(session), "EX", 86400);
const cookieStore = await cookies();
Most teams write tests the way most people exercise: they know they should, they feel guilty when they don't, and when they finally do it, they push too hard on day one and quit by Friday. The codebase ends up with a scattering of shallow snapshot tests that break every time someone changes a CSS class, a few integration tests that nobody trusts, and a coverage badge in the README that lies.
I've been on both sides. I've shipped projects with zero tests and sweated through every deploy. I've also been on teams that chased 100% coverage and spent more time maintaining tests than writing features. Neither works. What works is writing the right tests, in the right places, with the right tools.
Vitest changed how I think about testing in JavaScript. Not because it invented new concepts — the fundamentals haven't changed since Kent Beck wrote about them decades ago. But because it removed enough friction that writing tests stopped feeling like a chore and started feeling like part of the development loop. When your test runner is as fast as your dev server and uses the same config, the excuses evaporate.
This post is everything I know about testing with Vitest, from initial setup to the philosophy that makes it all worthwhile.
If you've used Jest, you already know most of Vitest's API. That's by design — Vitest is Jest-compatible at the API level. describe, it, expect, beforeEach, vi.fn() — it all works. So why switch?
Jest was built for CommonJS. It can handle ESM, but it requires configuration, experimental flags, and occasional prayer. If you're using import/export syntax (which is everything modern), you've probably fought with Jest's transform pipeline.
Vitest runs on Vite. Vite understands ESM natively. There's no transform step for your source code — it just works. This matters more than it sounds. Half the Jest issues I've debugged over the years trace back to module resolution: `S
A schema is a contract with your future self. Every column you add, every constraint you skip, every "we'll clean this up later" — it all compounds. There are real-world systems where a single bad schema decision made three years ago now costs the team a full sprint every quarter in workarounds.
The database outlives everything. Your frontend framework will change. Your API layer will get rewritten. Your deployment strategy will evolve. But the data? The data stays. And the shape you gave it on day one follows you forever, because migrating a table with 200 million rows is not the same as refactoring a React component.
This is a guide to making schema decisions that don't haunt you. PostgreSQL-focused, because it's the most broadly applicable, but most of these patterns apply to any relational database.
Naming conventions cause more arguments in pull requests than actual architecture decisions. Here's a set of conventions that holds up well across years of context-switching between projects:
Snake_case for everything. Tables, columns, indexes, constraints. No camelCase, no PascalCase. PostgreSQL folds unquoted identifiers to lowercase anyway, so createdAt becomes createdat unless you double-quote it everywhere. Don't fight the database.
Plural table names. A table holds many rows. users not user. orders not order. order_items not order_item. This reads naturally in queries: SELECT * FROM users WHERE ... — you're selecting from a collection.
_id suffix for foreign keys. user_id, order_id, tenant_id. The primary key is just id. This is unambiguous. When you join tables, users.id = orders.user_id reads like English.
_at suffix for timestamps. created_at, updated_at, deleted_at, published_at, expires_at. You always know it's a point in time.
is_ prefix for booleans. is_active, is_verified, is_published. Some people us
A common career path: spending years copying utility functions between repositories. A formatDate.ts lives in seven different projects, each with slightly different bugs. Fix the timezone issue in one, forget about the other six. Eventually a user in Australia files a ticket and the fix turns out to never have made it to production in that particular app.
The monorepo solves this. Not because it's trendy, not because Google does it, but because being a human package registry gets old fast. One repository, shared code, atomic changes across every app that consumes it.
But monorepos have their own failure modes. The tooling landscape is full of broken caching, midnight circular-dependency errors, and "just put everything in one repo" advice that skips all the details.
This is a Turborepo setup that actually scales. It can run four production apps with twelve shared packages. Builds take under 90 seconds because of remote caching. New developers can clone and run pnpm dev and have everything working in under two minutes. Getting here usually takes about a month of mistakes.
Let's be honest about the tradeoffs. A monorepo isn't free. You're trading one set of problems for another, and you need to know what you're buying.
Code sharing without publishing. This is the big one. You write a React component library in packages/ui. Your web app, your admin dashboard, and your docs site all import from it. When you change a button component, every app picks it up immediately. No version bumping, no npm publish, no "which version is production using again?"
// In apps/web/src/components/Header.tsx
import { Button, Avatar, Dropdown } from "@acme/ui";
import { formatDate } from "@acme/utils";
import { SITE_CONFIG } from "@acme/config";
These imports look like external packages. They resolve through workspace dependencies. But they point to source code in the same repos
A common starting point is "add some console.logs and check them when something breaks." That works until it doesn't. The breaking point is often a production incident where the API returns 200s but the data is stale. No errors in the logs. No exceptions. Just silently wrong responses because a downstream cache went stale and nobody noticed for four hours.
That's the difference between monitoring and observability. Monitoring tells you something is wrong. Observability tells you why it's wrong. And the gap between those two is where production incidents live.
This is a practical observability stack for Node.js services. It's not the most sophisticated setup in the world, but it catches problems before users notice them, and when something does slip through, you can diagnose it in minutes instead of hours.
Everyone talks about the "three pillars of observability" — logs, metrics, and traces. What nobody tells you is that each pillar answers a fundamentally different question, and you need all three because no single pillar can answer every question.
Logs answer: What happened?
A log line says "at 14:23:07, user 4821 requested /api/orders and got a 500 because the database connection timed out." It's a narrative. It tells you the story of one specific event.
Metrics answer: How much is happening?
A metric says "over the last 5 minutes, the p99 response time was 2.3 seconds and the error rate was 4.7%." It's aggregate data. It tells you about the health of the system as a whole, not about any individual request.
Traces answer: Where did time go?
A trace says "this request spent 12ms in the Express middleware, 3ms parsing the body, 847ms waiting for PostgreSQL, and 2ms serializing the response." It's a waterfall. It tells you exactly where the bottleneck is, across service boundaries.
Here's the practical implication: when your pager goes off at 3 AM, the sequence
I've been using Tailwind CSS since v1.x, back when half the community thought it was an abomination and the other half couldn't stop shipping with it. Every major version has been a significant leap, but v4 is different. It's not just a feature release. It's a ground-up architectural rewrite that changes the fundamental contract between you and the framework.
After migrating two production projects from v3 to v4 and starting three new projects on v4 from scratch, I have a clear picture of what's genuinely better, what's rough, and whether you should migrate today. No hype, no outrage — just what I observed.
Tailwind CSS v4 is three things at once:
tailwind.config.js as the default@layer, container queries, @starting-style, and cascade layers are first-class citizensThe headline you'll see everywhere is "10x faster." That's real, but it undersells the actual change. The mental model for configuring and extending Tailwind has fundamentally shifted. You're working with CSS now, not a JavaScript configuration object that generates CSS.
Here's what a minimal Tailwind v4 setup looks like:
/* app.css — this is the entire setup */
@import "tailwindcss";
That's it. No config file. No PostCSS plugin configuration (for most setups). No @tailwind base; @tailwind components; @tailwind utilities; directives. One import, and you're running.
Compare that to v3:
/* v3 — app.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
// v3 — tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./src/**/*.{js,ts,jsx,tsx,mdx}"],
theme: {
extend: {},
},
plugins: [],
The word "edge" gets thrown around a lot. Vercel says it. Cloudflare says it. Deno says it. Every conference talk about web performance inevitably mentions "running at the edge" like it's a magic incantation that makes your app fast.
It is easy to buy into the hype: move middleware, API routes, even some rendering logic to the edge runtime. Some of those moves are brilliant. Others quietly get moved back to Node.js three weeks later after debugging connection pool errors at 2 AM.
This post is the balanced version of that story — what the edge actually is, where it genuinely shines, where it absolutely doesn't, and how to decide which runtime to use for each piece of an application.
Let's start with geography. When someone visits your website, their request travels from their device, through their ISP, across the internet to your server, gets processed, and the response travels all the way back. If your server is in us-east-1 (Virginia) and your user is in Tokyo, that round trip covers roughly 14,000 km. At the speed of light through fiber, that's about 70ms just for the physics — one way. Add DNS resolution, TLS handshake, and any processing time, and you're easily looking at 200-400ms before your user sees a single byte.
The "edge" means running your code on servers distributed globally — the same CDN nodes that have always served static assets, but now they can also execute your logic. Instead of one origin server in Virginia, your code runs in 300+ locations worldwide. A user in Tokyo hits a server in Tokyo. A user in Paris hits a server in Paris.
The latency math is simple and compelling:
Traditional (single origin):
Tokyo → Virginia: ~140ms round trip (physics alone)
+ TLS handshake: ~140ms more (another round trip)
+ Processing: 20-50ms
Total: ~300-330ms
Edge (local PoP):
Tokyo → Tokyo edge node: ~5ms round trip
+ TLS handshake: ~5ms more
+ Processing: 5-20ms
Total: ~15-30ms
That
You know the drill. You change a field name in your API response. You update the backend type. You deploy. Then the frontend breaks in production because someone forgot to update the fetch call on line 247 of Dashboard.tsx. The field is now undefined, the component renders blank, and your error tracking lights up at 2 AM.
This is the API contract problem. It's not a technology problem. It's a coordination problem. And no amount of Swagger docs or GraphQL schemas will fix the fact that your frontend and backend types can drift apart silently.
tRPC fixes this by refusing to let them drift. There's no schema file. No code generation step. No separate contract to maintain. You write a TypeScript function on the server, and the client knows its exact input and output types at compile time. If you rename a field, the frontend won't compile until you fix it.
That's the promise. Let me show you how it actually works, where it shines, and where you should absolutely not use it.
Let's look at how most teams build APIs today.
REST + OpenAPI: You write your endpoints. Maybe you add Swagger annotations. Maybe you generate a client SDK from the OpenAPI spec. But the spec is a separate artifact. It can go stale. The generation step is another thing in your CI pipeline that can break or be forgotten. And the generated types are often ugly — deeply nested paths["/api/users"]["get"]["responses"]["200"]["content"]["application/json"] monsters.
GraphQL: Better type safety, but enormous ceremony. You write a schema in SDL. You write resolvers. You generate types from the schema. You write queries on the client. You generate types from the queries. That's at least two code generation steps, a schema file, and a build step that everyone has to remember to run. For a team that already controls both frontend and backend, this is a lot of infrastructure for a problem that has a simpler solution.
Manual fetch calls: The
I've shipped APIs that were wide open. Not maliciously, not lazily — I just didn't know what I didn't know. An endpoint that returned every field in the user object, including hashed passwords. A rate limiter that only checked IP addresses, which meant anyone behind a proxy could hammer the API. A JWT implementation where I forgot to verify the iss claim, so tokens from a completely different service worked just fine.
Every one of those mistakes made it to production. Every one of them got caught — some by me, some by users, one by a security researcher who was kind enough to email me instead of posting it on Twitter.
This post is the checklist I built from those mistakes. I run through it before every production deployment. Not because I'm paranoid, but because I've learned that security bugs are the ones that hurt the most. A broken button annoys users. A broken auth flow leaks their data.
These two words get used interchangeably in meetings, in docs, even in code comments. They are not the same thing.
Authentication answers: "Who are you?" It's the login step. Username and password, OAuth flow, magic link — whatever proves your identity.
Authorization answers: "What are you allowed to do?" It's the permission step. Can this user delete this resource? Can they access this admin endpoint? Can they read another user's data?
The most common security bug I've seen in production APIs is not a broken login flow. It's a missing authorization check. The user is authenticated — they have a valid token — but the API never checks whether they're allowed to perform the action they're requesting.
JWTs are everywhere. They're also misunderstood everywhere. A JWT has three parts, separated by dots:
header.payload.signature
The header says which algorithm was used. The payload contains claims (user ID, roles, expiration). The signature proves nobod
Everyone tells you to "just add Redis" when your API is slow. Nobody tells you what happens six months later when your cache is serving stale data, your invalidation logic is scattered across 40 files, and a deploy causes a cache stampede that takes down your database harder than if you'd never cached at all.
Running Redis in production — not as a toy, not in a tutorial, but in systems handling real traffic where getting caching wrong means pager alerts at 3 AM — teaches a specific set of lessons. What follows is the practical version.
Let's start with the obvious: databases are slow relative to memory. A PostgreSQL query that takes 15ms is fast by database standards. But if that query runs on every single API request, and you're handling 1,000 requests per second, that's 15,000ms of cumulative database time per second. Your connection pool is exhausted. Your p99 latency is through the roof. Users are staring at spinners.
Redis serves most reads in under 1ms. That same data, cached, turns a 15ms operation into a 0.3ms operation. That's not a micro-optimization. That's the difference between needing 4 database replicas and needing zero.
But caching isn't free. It adds complexity, introduces consistency problems, and creates an entirely new class of failure modes. Before you cache anything, ask yourself:
When caching helps:
When caching hurts:
Most developers learn five Git commands and stop there. add, commit, push, pull, merge. Maybe checkout and branch if they're feeling adventurous. That gets you through the first year. Then your branch has 47 commits with messages like "fix" and "wip" and "please work," you accidentally reset something you shouldn't have, and you spend 40 minutes on Stack Overflow trying to undo a merge gone wrong.
I've been using Git for years. Not casually — heavily. Multiple branches, multiple repos, multiple collaborators, all day, every day. What follows are the commands and workflows I actually use. Not the ones that look good in a tutorial. The ones that save me real time, every single week.
Your branch has twelve commits. Half of them are "fix typo." One says "undo previous commit." Another says "actually fix it this time." You're about to open a PR. Nobody needs to see that history.
Interactive rebase is how you rewrite history on your branch before sharing it. It lets you squash commits together, reword messages, reorder them, or drop them entirely.
git rebase -i HEAD~5
This opens an editor showing your last 5 commits, oldest first:
pick a1b2c3d Add user authentication endpoint
pick d4e5f6g Fix typo in auth middleware
pick h7i8j9k Add rate limiting
pick l0m1n2o Fix rate limit bug
pick p3q4r5s Update auth tests
Each line starts with a command. Change pick to one of these:
squash (or s) — Merge this commit into the one above it, combine the messagesfixup (or f) — Same as squash, but discard this commit's messagereword (or r) — Keep the commit but change its messagedrop (or d) — Delete this commit entirelyedit (or e) — Pause the rebase at this commit so you can amend itHere's what I actually do. That messy history above becom
I deleted my last Sass file six months ago. Not because I was making a statement. Because I genuinely didn't need it anymore.
For over a decade, CSS was the language we apologized for. We needed preprocessors for nesting and variables. We needed JavaScript for container-based sizing, scroll-linked animations, parent selection, and half the layout patterns designers handed us. We built entire runtime systems — CSS-in-JS libraries, utility frameworks, PostCSS plugin chains — to compensate for what the language couldn't do natively.
That era is over. Not "almost over." Not "getting there." Over.
The features that landed in browsers between 2024 and 2026 didn't just add convenience. They changed the mental model. CSS is no longer a styling language you fight against. It's a styling language that actually thinks about components, specificity management, responsive design at the element level, and animation without a runtime.
Here's what changed, why it matters, and what you can stop doing because of it.
This is the one that fundamentally altered how I think about responsive design. For twenty years, we had media queries. Media queries ask: "how wide is the viewport?" Container queries ask: "how wide is the container this component lives in?"
That distinction sounds subtle. It's not. It's the difference between components that only work in one layout context and components that work everywhere.
Consider a card component. In a sidebar, it should stack vertically with a small image. In a main content area, it should go horizontal with a larger image. In a full-width hero section, it should be something else entirely.
With media queries, you'd write something like this:
/* The old way: coupling component styles to page layout */
.card {
display: flex;
flex-direction: column;
}
@media (min-width: 768px) {
.sideb
Imagine getting a Slack message at 2 AM: "The dashboard is timing out." You SSH into the database box, open pg_stat_activity, and find a single query scanning 14 million rows to return 12 results. The fix is a partial index that takes 30 seconds to create. The query goes from 4.2 seconds to 0.3 milliseconds.
That's the thing about PostgreSQL performance. The problems are almost never exotic. They're missed indexes, bad statistics, queries that made sense when the table had 10,000 rows but now has 10 million. The fixes are usually simple — once you know where to look.
This post collects practical PostgreSQL query optimization techniques for production databases. No theory without practice. Every technique here pays off on real systems.
The single biggest mistake developers make with slow queries is guessing. "Maybe we need an index on that column." "Maybe the JOIN is slow." "Maybe we should add more RAM."
Stop guessing. PostgreSQL has one of the best query analyzers of any database. Use it.
Plain EXPLAIN shows you what PostgreSQL plans to do, without actually running the query:
EXPLAIN SELECT * FROM orders WHERE customer_id = 42;
QUERY PLAN
---------------------------------------------------------------------------
Index Scan using idx_orders_customer_id on orders (cost=0.43..8.45 rows=1 width=128)
Index Cond: (customer_id = 42)
This tells you the plan — an index scan — and the estimated cost. But it's a prediction. PostgreSQL might be completely wrong about the row count. I've seen estimates of "1 row" when the actual result was 50,000 rows. That kind of misestimate cascades into terrible plan choices.
Use plain EXPLAIN when you want a quick look at the plan without actually running the query. This matters when you're analyzing a DELETE that would modify data, or a query that takes 30
Most Node.js Dockerfiles in production are bad. Not "slightly suboptimal" bad — running as root, shipping 600MB images with devDependencies baked in, no health checks, and secrets hardcoded in environment variables that anyone with docker inspect can read.
The Dockerfiles many people start with work, so they go unquestioned for years. Then a security audit flags a container running as PID 1 root with write access to the entire filesystem, and it becomes clear that "works" and "production-ready" are very different bars.
This is a Docker setup that holds up for Node.js projects in production. Every pattern here exists because somebody got burned by the alternative.
Let me guess what your Dockerfile looks like:
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["node", "server.js"]
This is the "hello world" of Dockerfiles. It works. It also has at least five problems that will hurt you in production.
By default, the node Docker image runs as root. That means your application process has root privileges inside the container. If someone exploits a vulnerability in your app — a path traversal bug, an SSRF, a dependency with a backdoor — they have root access to the container filesystem, can modify binaries, install packages, and potentially escalate further depending on your container runtime configuration.
"But containers are isolated!" Partially. Container escapes are real. CVE-2024-21626, CVE-2019-5736 — these are real-world container breakouts. Running as non-root is a defense-in-depth measure. It costs nothing and it closes an entire class of attacks.
npm install without flags installs everything. Your test runners, linters, build tools, type checkers — all sitting in your production image. This bloats your image by hundreds of megabytes and increases your attack surface. Eve
I spent the first three months with React Server Components thinking I understood them. I'd read the RFCs, watched the conference talks, built a few demo apps. I was confidently wrong about almost everything.
The problem isn't that RSC is complicated. It's that the mental model is genuinely different from anything we've done in React before, and everyone — including me — tries to fit it into the old box. "It's like SSR." It's not. "It's like PHP." Closer, but no. "It's just components that run on the server." Technically true, practically useless.
What follows is everything I actually needed to know, written the way I wish someone had explained it to me. Not the theoretical version. The one where you're staring at a serialization error at 11 PM and need to understand why.
Forget everything you know about React rendering for a moment. Here's the new picture.
In traditional React (client-side), your entire component tree ships to the browser as JavaScript. The browser downloads it, parses it, executes it, and renders the result. Every component — whether it's a 200-line interactive form or a static paragraph of text — goes through the same pipeline.
React Server Components split this into two worlds:
Server Components run on the server. They execute once, produce their output, and send the result to the client — not the code. The browser never sees the component function, never downloads its dependencies, never re-renders it.
Client Components work like traditional React. They ship to the browser, hydrate, maintain state, handle events. They're the React you already know.
The key insight that took me embarrassingly long to internalize: Server Components are the default. In Next.js App Router, every component is a Server Component unless you explicitly opt it into the client with "use client". This is the opposite of what we're used to, and it changes how you think about composition.
I spent the better part of two weeks making this site fast. Not "looks fast in a Lighthouse audit on my M3 MacBook" fast. Actually fast. Fast on a $150 Android phone on a shaky 4G connection in a subway tunnel. Fast where it matters.
The result: LCP under 1.8s, CLS at 0.00, INP under 120ms. All three green in CrUX data, not just lab scores. And I learned something in the process — most performance advice on the internet is either outdated, vague, or both.
"Optimize your images" is not advice. "Use lazy loading" without context is dangerous. "Minimize JavaScript" is obvious but tells you nothing about what to cut.
Here's what I actually did, in the order that mattered.
Let me be direct: Google uses Core Web Vitals as a ranking signal. Not the only signal, and not even the most important one. Content relevance, backlinks, and domain authority still dominate. But at the margins — where two pages have comparable content and authority — performance is a tiebreaker. And on the internet, millions of pages live at those margins.
But forget SEO for a second. The real reason to care about performance is users. The data hasn't changed much in the last five years:
Core Web Vitals in 2026 consist of three metrics:
| Metric | What It Measures | Good | Needs Improvement | Poor |
|---|---|---|---|---|
| LCP | Loading performance | ≤ 2.5s | 2.5s – 4.0s | > 4.0s |
| CLS | Visual stability | ≤ 0.1 | 0.1 – 0.25 | > 0.25 |
| INP | Responsiveness | ≤ 200ms | 200ms – 500ms |
Most TypeScript developers plateau at the same place. They know interfaces, generics, union types. They can type a React component. They can make the red squiggles go away. But they treat the type system as a necessary evil — something that exists to prevent bugs, not something that actively helps them design better software.
I stayed at that plateau for about two years. What pulled me out was realizing that TypeScript's type system is a programming language in itself. It has conditionals, loops, pattern matching, recursion, and string manipulation. Once you internalize that, everything changes. You stop fighting the compiler and start collaborating with it.
This post covers the type-level features I use constantly in production code. Not academic exercises — real patterns that have saved me from real bugs.
Before diving into syntax, I want to reframe how you think about types.
In value-level programming, you write functions that transform data at runtime:
function extractIds(users: User[]): number[] {
return users.map((u) => u.id);
}
In type-level programming, you write types that transform other types at compile time:
type ExtractIds<T extends { id: unknown }[]> = T[number]["id"];
The mental model is the same — input, transformation, output. The difference is that type-level code runs during compilation, not at runtime. It produces zero JavaScript. Its only purpose is to make impossible states unrepresentable.
If you've ever written a function where three of the four parameters only make sense in certain combinations, or where the return type depends on what you pass in, you've already needed type-level programming. You just might not have known TypeScript could express it.
Conditional types are the if/else of the type system. The syntax looks like a ternary:
type IsString<T> = T extends string ? true : fals
There's this fantasy that on-chain data is inherently trustworthy. Immutable ledger. Transparent state. Just read it and you're done.
I believed it too. Then I shipped a DeFi dashboard to production and spent three weeks figuring out why our token balances were wrong, our event history had gaps, and our database contained transactions from blocks that no longer existed.
On-chain data is raw, hostile, and full of edge cases that will break your application in ways you won't notice until a user files a bug report. This post covers everything I learned the hard way.
Here's the first thing nobody tells you: the blockchain doesn't give you data. It gives you state transitions. There's no SELECT * FROM transfers WHERE user = '0x...'. There are logs, receipts, storage slots, and call traces — all encoded in formats that require context to decode.
A Transfer event log gives you from, to, and value. It doesn't tell you the token symbol. It doesn't tell you the decimals. It doesn't tell you if this is a legitimate transfer or a fee-on-transfer token skimming 3% off the top. It doesn't tell you if this block will still exist in 30 seconds.
The "immutable" part is true — once finalized. But finalization isn't instant. And the data you get back from an RPC node isn't necessarily from a finalized block. Most developers query latest and treat it as truth. That's a bug, not a feature.
Then there's the encoding. Everything is hex. Addresses are mixed-case checksummed (or not). Token amounts are integers multiplied by 10^decimals. A USDC transfer of $100 looks like 100000000 on-chain because USDC has 6 decimals, not 18. I've seen production code that assumed 18 decimals for every ERC-20 token. The resulting balances were off by a factor of 10^12.
Every production Web3 app talks to an RPC endpoint. And every RPC endpoint has rate limits that are far more aggressive tha