# SDE Journey — full content > A Technical blog on my experiences in the tech industry — by Murugappan M. --- # Why SiteGPT's chat runs on PartyKit, not socket.io + Redis URL: https://murugappan.dev/blog/sitegpt-partykit-durable-objects/ Date: 2026-08-16 Description: How one-process-per-room replaces socket.io + Redis for realtime chat — production code, cost math and actor-model tradeoffs from the chatbot on this site. [SiteGPT](https://sitegpt.ai)'s founder [Bhanu Teja](https://x.com/pbteja1998) spent months trying to solve a realtime sync problem. His product — a chatbot trained on your website — needed something deceptively hard: when the bot gets stuck, a **human agent should be able to join the same conversation, live**. Visitor, bot, and agent, all seeing the same messages at the same time. Classic multiplayer. His testimonial on [partykit.io](https://www.partykit.io/) tells the ending: he'd "tried everything and nothing seemed to work properly," until [Sunil Pai](https://x.com/threepointone) (PartyKit's creator, ex-React core) solved the entire problem **in around ten lines of code**. Ten lines. After months. That gap is not a talent gap — it's an architecture gap. The ten lines knew something the months of work didn't: **a chat room isn't a routing problem, it's a place**. Give the room its own process, its own memory, and its own address, and most of the "hard realtime problems" stop existing. I read that story, went down the rabbit hole, and ended up shipping the same architecture for the chatbot on this site. This post is what I learned: how the default socket.io + Redis stack actually works, what the room-as-a-process model replaces it with, real production code, real costs, and — because no architecture post should be a sales pitch — exactly where the old way is still the right way. ## The default stack, drawn honestly If you ask for "scalable websocket chat" in a system design interview, you'll get some version of this: ```mermaid flowchart LR C1[client] & C2[client] & C3[client] --> LB[load balancer
sticky sessions] LB --> S1[socket server 1] & S2[socket server 2] S1 <--> R[(Redis
pub/sub + presence)] S2 <--> R S1 --> DB[(Postgres
message history)] S2 --> DB ``` And the canonical implementation: ```js import { Server } from "socket.io" import { createAdapter } from "@socket.io/redis-adapter" import { createClient } from "redis" const pub = createClient({ url: REDIS_URL }) const sub = pub.duplicate() await Promise.all([pub.connect(), sub.connect()]) const io = new Server(httpServer, { adapter: createAdapter(pub, sub) }) io.on("connection", async socket => { const { roomId } = socket.handshake.query socket.join(roomId) // History lives in Postgres, presence in Redis, the socket on this box socket.emit("history", await db.messages.findMany({ where: { roomId } })) await pub.hSet(`presence:${roomId}`, socket.id, Date.now()) socket.on("chat", async text => { await db.messages.create({ data: { roomId, text } }) // history → Postgres io.to(roomId).emit("chat", text) // fanout → Redis // presence, typing, receipts: same split, every feature }) }) ``` Nothing here is wrong. But look at what you've actually built: **the state of one room is smeared across three systems**. The sockets live on whichever servers the load balancer picked. Presence lives in Redis. History lives in Postgres. Every feature — typing indicators, read receipts, rate limits, "agent joined the chat" — now spans at least two of them, and keeping them coordinated is your job: sticky sessions so reconnects land somewhere sane, pub/sub so server 1 can reach a socket on server 2, cleanup jobs for the presence hashes that leak when a server dies mid-connection. You are, in effect, building a distributed system whose entire purpose is to _simulate_ what a single machine per room would give you for free. So... why not have a single machine per room? ## The inversion: the room is the server That's the whole idea behind Cloudflare's Durable Objects, and behind PartyKit, which made the model ergonomic enough to go mainstream (PartyKit has since joined Cloudflare; its open-source successor library [partyserver](https://github.com/cloudflare/partykit/tree/main/packages/partyserver), maintained inside the PartyKit monorepo, is what this site uses). A Durable Object is three guarantees stapled together: 1. **One instance, globally.** Ask for the object named `room-abc` from anywhere on Earth and you get _the same instance_. The room ID isn't a lookup key — it's the address. No sticky sessions, no session registry: routing is the platform's problem now. 2. **Single-threaded execution.** One room processes one message at a time. Redis has strong atomic primitives — `INCR` always was, and Redis 8.4 added compare-and-set variants of `SET` — but each is a specific primitive you design your logic _around_. Inside a room, arbitrary multi-step code — read, branch, write across SQL tables — is race-free exactly as written. 3. **Storage in the same process.** Each object gets its own private SQLite database, co-located with the compute. Reading history is a synchronous local query, not a network hop to a database that might disagree with your cache. ```mermaid flowchart LR C1[client] & C2[client] --> E[Cloudflare edge] C3[client] --> E E --> DO1["room: abc
sockets + state + SQLite
(one process)"] E --> DO2["room: xyz
sockets + state + SQLite
(one process)"] ``` Compare the two diagrams. The second one didn't get simpler because I hid boxes — it got simpler because the boxes stopped needing to agree with each other. This is the actor model: state and behavior sealed inside a process that owns them, addressable by name. It's a forty-year-old idea. Erlang built a telecom empire on it; we'll come back to that. ## The production code Everything below is trimmed from the actual worker running the "Chat with Jarvis" widget on this site ([full source on GitHub](https://github.com/murugu-21/murugu-21.github.io) — the whole backend, including the LLM plumbing, is about a thousand lines). The room, using partyserver: ```ts import { Server, type Connection } from "partyserver" export class ChatRoom extends Server { static options = { hibernate: true } onStart() { // This room's private database. Not a schema shared with every room — // a whole SQLite file that belongs to this one conversation. this.ctx.storage.sql.exec( `CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, role TEXT NOT NULL, content TEXT NOT NULL, created_at INTEGER NOT NULL )`, ) } onConnect(connection: Connection) { // Reconnect, new tab, returning visitor — history is a local read. this.send(connection, { type: "history", messages: this.history() }) } async onMessage(connection: Connection, raw: unknown) { const msg = parseClientMessage(raw) this.persist("user", msg.text) // Stream an LLM reply token-by-token down the same websocket const reply = await runModelExchange(this.env.AI, this.messages(), delta => this.send(connection, { type: "delta", text: delta }), ) this.persist("assistant", reply.content) this.send(connection, { type: "done" }) } } ``` That's not pseudocode-shaped-like-the-real-thing; that _is_ the real thing minus error handling and a few one-line helpers (`persist`, `history`, and `send` wrap SQL statements and `connection.send`). `onConnect` replays history with a synchronous local query. `onMessage` persists, streams, persists — and because the room is single-threaded, no interleaving between those steps is possible. And the entire client-side session layer: ```ts import { PartySocket } from "partysocket" const socket = new PartySocket({ host: window.location.host, // same Worker serves the static site party: "chat-room", room: roomId(), // a nanoid in localStorage. That's it. That's the session. }) socket.send(JSON.stringify({ type: "chat", text })) // PartySocket buffers sends while (re)connecting — no dropped messages // during the connect window, no readyState bookkeeping in app code. ``` One message, end to end: ```mermaid sequenceDiagram participant V as Visitor (PartySocket) participant DO as ChatRoom (Durable Object) participant AI as DeepSeek V->>DO: {type:"chat", text} DO->>DO: rate check DO->>DO: INSERT message (local SQLite) DO->>AI: chat completion (stream) AI-->>DO: tokens DO-->>V: {type:"delta"} per token DO->>DO: INSERT reply DO-->>V: {type:"done"} ``` Notice what's _absent_: no session store, no pub/sub hop, no presence hashes leaking when a server dies holding open sockets, no "which server owns this socket" logic. The features that took a section each in the old architecture are single lines here, because the room is a place and everything the room needs is in the room. ## What "scalable" actually means here The scaling story has a shape worth being precise about: **this model scales out by room count, not by room size.** A million concurrent conversations means a million small, independent processes, spread across Cloudflare's fleet with no coordination between them — nothing shared, nothing to rebalance, no hot Redis channel. Scale-out is free in the dimension chat actually grows: more conversations. Within one room, the ceiling is real: single-threaded execution means one very hot room is bounded by what one process can do, and the practical answer for enormous rooms is sharding them across several objects. For conversations, support chats, docs, lobbies — rooms measured in ones to thousands of participants — you will never feel it. For a 500k-viewer broadcast, this is the wrong primitive (more on that below). The economics deserve honesty in both directions, because "serverless is cheap" is only half a sentence — it's cheap _at low and spiky utilization_, and you pay a premium per unit of compute for that elasticity. The estimates below use Cloudflare's published rates and ballpark VM-stack list prices, assuming an AI chat like this site's (the room stays awake ~2 seconds per message while the LLM streams). But read the bottom rows as carefully as the dollar rows — for a small team they are the decision: | | socket.io + Redis | Workers + Durable Objects | | ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | | Cost, small scale (up to ~100k msgs/day) | ~$60/mo floor — VMs, Redis, LB, Postgres, running even at zero traffic | ~$0–10/mo; this site's chatbot fits in the free tier | | Cost, ~1M msgs/day | ~$150–200/mo | ~$105/mo — duration is most of it | | Cost, sustained 10M+ msgs/day | ~$300/mo — **dedicated hardware wins clearly here** | ~$1,500/mo — the elasticity premium compounds | | Idle nights and spiky days | full price, 24/7 | ~zero — `hibernate: true` parks idle rooms while the platform holds their sockets open | | **Ops burden** | **the real bill**: patching, scaling, failover, monitoring and on-call across four systems — a part-time job that lands on an engineer | `wrangler deploy`; the platform is the on-call | | Time to first production deploy | days to weeks of plumbing before the first feature | the room class above _is_ the backend | The dollar crossover sits somewhere past a million messages a day (and much further out for plain human-relay chat, where rooms are awake milliseconds, not seconds — the LLM assumption is doing heavy lifting in that $1,500). But the honest summary is that below serious sustained scale, the money difference is noise compared to the ops row: one stack needs an operator, the other doesn't. Past that scale, dedicated hardware wins on unit price and you can afford the operator — which is one reason WhatsApp runs its own Erlang fleet instead of renting actors by the GB-second. ## When socket.io + Redis is still the right answer Here's the test I'd give a design interview candidate: **is your realtime problem a noun or a feed?** Rooms, documents, auctions, game lobbies, device twins — _nouns_. A bounded set of clients interacting with a thing that has state. Nouns want actors. Tickers, scoreboards, notification firehoses — _feeds_. Arbitrary subscription topology, or one identical stream to a huge audience. Feeds want brokers. Concretely, prefer the traditional stack when: - **Subscription topology is a matrix, not a room.** A dashboard client subscribing to 50 instrument feeds at once is what pub/sub was born for; actor-per-ticker forces awkward fan-in. - **One stream, hundreds of thousands of watchers.** A broadcast is N sends on the room's one thread — at some tens of thousands of sockets you'd be hand-building fanout trees. Stateless socket servers draining one Redis channel is the honest architecture there. (The hybrid is legitimate too: an actor as the source of truth _publishing into_ a broadcast layer for spectators.) - **You already run the infra.** A team fluent in Redis with a k8s estate and on-prem requirements should not adopt a new execution model to ship a chat widget. - **Polyglot backends.** socket.io speaks every language; Durable Objects speak JavaScript on workerd. - **Heavy CPU per message.** Workers have tight CPU budgets. A transcoding pipeline doesn't belong in a room actor. And two Durable-Object-specific caveats that vendor posts skip: an object lives **where it was first created** — a room born in Chennai answers from (roughly) Chennai forever, which is perfect when participants are co-located and suboptimal when they're not; and the debugging/observability ecosystem is years younger than what a decade of socket.io + Redis operators built up. ## The vendor question — you're adopting a model, not a vendor The uncomfortable question: isn't this just trading Redis for a deeper kind of Cloudflare lock-in? Here's the reframe that settled it for me. What you're actually adopting is the **actor model** — and it's the single most battle-tested architecture in messaging history. WhatsApp runs on it right now: a cluster of Erlang nodes, every connection its own lightweight process with its own state, messages routed process-to-process with no external broker, roughly a million connections per server, two billion users today — and, famously, a team of about fifty engineers back when it crossed nine hundred million. That's the pedigree. Durable Objects didn't invent the pattern; they made it rentable by the millisecond. One precision worth having before a commenter has it for you: WhatsApp's actor is the _connection_ — a process per user, with group messages fanning out into per-recipient queues — while Durable Objects make the _room_ the actor. Same model, different choice of boundary. Rooms suit the web-chat shape, where the conversation itself has shared state (history, presence, budgets); connection-actors suit per-recipient delivery guarantees at WhatsApp's scale. Knowing which boundary your problem wants _is_ the design skill. Because it's a model and not an API, the portable move is to keep your domain logic behind an interface the size of a postcard: ```ts interface RoomActor { onConnect(conn: Conn): void onMessage(conn: Conn, data: unknown): Promise broadcast(data: unknown, exclude?: string[]): void storage: RoomStorage // co-located, transactional, private to this room } ``` Everything interesting in this site's chat — persistence, rate budgets, LLM streaming, lead capture — codes against those five surface areas. partyserver is today's binding. If tomorrow demanded a move: partyserver and workerd (the Workers runtime itself) are both open source and self-hostable, and the same interface maps almost mechanically onto an Elixir/Phoenix channel backed by a GenServer per room — the boring, decades-proven implementation of the same idea. The transport and the placement are the vendor's. The model is yours. ## The receipt I didn't write this from a benchmark lab. The architecture in this post is the one running the chat widget in the corner of this page — a grounded LLM concierge with streamed replies, persistent per-visitor conversations, rate budgets, and email lead capture, built to production in a weekend, deployed with one command, running on the free tier. Open the widget and say hi to Jarvis. You'll be talking to a Durable Object — one small, single-threaded room that owns everything it needs. Then go read [the source](https://github.com/murugu-21/murugu-21.github.io) and count the pieces of infrastructure you _didn't_ have to deploy. If your realtime problem is room-shaped, give each room its own process: you ship in days instead of months, run almost no infrastructure, and the actor model — not any one vendor — keeps the exit door open. --- # Forms in, webhooks out — what I learned building an event-driven pipeline with Claude URL: https://murugappan.dev/blog/eventform-outbox-pipeline-claude/ Date: 2026-06-12 Description: I built EventForm, a multi-tenant form builder with a transactional outbox, Debezium CDC and idempotent webhook delivery, pair-programming with Claude. I wanted a portfolio project that wasn't another todo app. Something anyone could actually click around in, built on the event-driven patterns I keep getting asked about in system design interviews. So I built [EventForm](https://eventform.murugappan.dev) ([source](https://github.com/murugu-21/eventform)) — a mini-Typeform where every form submission fans out to webhook endpoints through a transactional outbox, Debezium CDC, Kafka, and an idempotent consumer. The whole thing runs as a docker-compose stack on a single small AWS box, with Postgres on managed Neon and Cognito handling auth. I built it with Claude Code doing most of the typing, which was its own learning experience. ## The architecture ```mermaid flowchart LR A[Public form
POST /v1/forms/:slug] --> B[(Postgres
submission + outbox
in ONE transaction)] B -->|WAL| C[Debezium
logical replication] C --> D[Kafka
eventform.events] D --> E[Worker
idempotent consumer] E -->|HMAC-signed POST| F[Tenant's webhook endpoint] E -->|retry scheduler
FOR UPDATE SKIP LOCKED| B ``` Someone submits a form. The API writes the submission, a `deliveries` row for each active endpoint, and an `outbox` row for each delivery, all in **one Postgres transaction**. Debezium tails the write-ahead log and pushes outbox inserts into a Kafka topic. A NestJS worker consumes them and delivers HMAC-signed webhooks. Failed deliveries retry with backoff and eventually show up in a UI with a manual retry button. The "Kafka" here is actually Redpanda — a single-process, Kafka-API-compatible broker with no JVM. On a 2 GB box that footprint matters, and kafkajs and Debezium can't tell it apart from Apache Kafka. Everywhere I say Kafka below, I mean the wire protocol, not the JVM. ## Avoiding the dual write My first instinct years ago would have been to write the submission to Postgres and then publish an event to Kafka. The problem is what happens when the process dies between those two writes. You either lose the event or you publish an event for data that never committed. You can reorder the writes, wrap things in try/catch, add a reconciliation cron — none of it actually closes the window, it just moves it around. Both orderings fail, just in opposite directions: ```mermaid sequenceDiagram participant API participant PG as Postgres participant K as Kafka Note over API,K: ordering 1 — write, then publish API->>PG: COMMIT submission ✓ API--xK: publish event (process dies) Note right of K: committed data,
event lost forever Note over API,K: ordering 2 — publish, then write API->>K: publish event ✓ API--xPG: COMMIT (crash → rollback) Note right of K: ghost event for data
that never existed ``` The transactional outbox pattern gets rid of the second write entirely. The event **is** a database row, committed in the same transaction as the data it describes: ```text BEGIN; INSERT INTO submissions (...); -- the data INSERT INTO deliveries (...); -- one per active endpoint INSERT INTO outbox (id, payload); -- the event, id = event id COMMIT; -- all or nothing ``` There is no publish step to forget and no second system to fail. During review we actually tested this by revoking INSERT on the outbox table mid-request — the whole submission rolled back, no partial state anywhere. ## CDC with Debezium Something still has to move outbox rows into Kafka. The tempting answer is a poller, but polling adds latency and has its own missed-row edge cases. Change Data Capture reads the write-ahead log instead. Debezium connects to Postgres as a logical replication client, sees every committed outbox insert in commit order, and its outbox event router strips the envelope and produces just the payload to `eventform.events`. Messages are keyed by delivery ID, so all attempts for one delivery land in the same partition, in order. ```mermaid flowchart LR subgraph pg [Postgres] OB[(outbox insert
COMMIT)] --> WAL[write-ahead log] WAL --> SLOT[logical replication slot
pgoutput] end SLOT -->|commit order| DBZ[Debezium
outbox EventRouter SMT] DBZ -->|"key = aggregate_id
(delivery id)"| T[(Kafka
eventform.events)] T --> W[worker
consumer group] ``` A few config choices do quiet work here. `pgoutput` is Postgres's built-in logical decoding plugin, so nothing gets installed into the database. `snapshot.mode=no_data` starts the connector streaming from the current WAL position instead of dumping the table first — old outbox rows are history, not events to re-deliver. And the slot's confirmed position only advances after Kafka acknowledges the write, so a connector crash replays from the last confirmed LSN: at-least-once into Kafka, the same contract the rest of the pipeline already lives with. The thing that surprised me when I traced the failure scenarios: the replication slot makes Postgres itself the durability buffer. Postgres will not recycle WAL segments that the slot hasn't confirmed. So if Kafka goes down for an hour, nothing is lost — submissions keep succeeding, WAL accumulates, and Debezium replays everything once Kafka is back. Kafka is just transport here; the database stays the source of truth. The flip side is that a multi-day outage will pin WAL until your disk fills, so in real production you monitor slot lag. ## Exactly once\* (at least once) The worker commits Kafka offsets only after it finishes processing, so the same message can be redelivered after a crash, a rebalance, or a deploy. To make redelivery safe, the worker claims each event in a ledger table, and the placement of that claim is the part worth remembering — it's the **first statement of the same transaction that does the work**: ```sql INSERT INTO processed_events (event_id) VALUES ($1) ON CONFLICT DO NOTHING RETURNING event_id; -- zero rows back = duplicate = skip without sending ``` Because the claim and the work commit or roll back together, they can never disagree. When two workers race on the same event, the second one blocks on the first's uncommitted insert and then sees the conflict, so exactly one webhook goes out. We tested that with two concurrent processors against a slow endpoint. The race, drawn out: ```mermaid sequenceDiagram participant A as worker A participant B as worker B participant PG as Postgres participant EP as endpoint A->>PG: BEGIN · INSERT claim(evt-42) B->>PG: BEGIN · INSERT claim(evt-42) Note over B,PG: blocks on A's
uncommitted claim row A->>EP: HMAC-signed POST A->>PG: record attempt · update status · COMMIT PG-->>B: ON CONFLICT DO NOTHING → 0 rows B->>PG: duplicate — commit the skip, no send ``` This is the textbook reason the claim must be an `INSERT` and not a `SELECT`-then-`INSERT`: the row-level lock on the uncommitted insert is what serialises the two workers. A read-check would let both proceed. There is one boundary I can't engineer away. The HTTP send happens inside the transaction, so if the process dies between the send and the commit, the claim rolls back and the redelivery sends the webhook again. You can't atomically commit a database transaction and an external HTTP call. So the honest description is at-least-once delivery with idempotent processing, and every webhook carries an `X-Eventform-Event-Id` header so receivers can dedupe. Stripe and GitHub offer the same contract. The landing page says "Exactly once\* (at least once)" and I mean both halves of it. Retries follow the same philosophy. The auto-retry (5s, 30s, then dead) and the manual retry button don't get their own delivery mechanism — they insert a new outbox row and go through the same pipeline again. One delivery path, one set of invariants. The retry scheduler claims due rows with `FOR UPDATE SKIP LOCKED`, which means I could run multiple workers without any distributed lock machinery — two workers polling the same table simply grab disjoint rows. The poll itself is cheap because of a partial index, `ON deliveries (next_retry_at) WHERE status = 'retrying'`, so it scans an index that only ever contains the in-flight retry set, not the whole table. The full lifecycle of a delivery: ```mermaid stateDiagram-v2 [*] --> pending: outbox event consumed pending --> delivered: 2xx pending --> retrying: non-2xx / timeout retrying --> delivered: 2xx retrying --> retrying: fail, attempt < 3 (5s, 30s backoff) retrying --> failed: 3rd attempt fails → UI + manual retry failed --> pending: manual retry (new outbox event) delivered --> [*] ``` Every transition is written by the same transaction that does the work, and every attempt — success or failure — leaves a `delivery_attempts` row with the response code, error and latency, which is what the deliveries UI renders. ## Bigger than a PoC: events as a platform concern EventForm is a proof of concept, but the shape of it is a drop-in answer for any business that wants to emit events to its customers — the Stripe/GitHub/Shopify webhook model. The part I'd sell to a platform team is the separation of concerns it buys you. Backend developers integrating with this pipeline have exactly one job: keep writing your application state, and add an outbox insert to the same transaction. That's it. One extra INSERT. They never touch Kafka, never think about retries or backoff, never learn what HMAC is, never get paged because a customer's endpoint has been returning 503 for an hour. Their failure domain ends at COMMIT. Everything downstream — CDC, the topic, the idempotent consumer, signing, retry scheduling, the failed-deliveries UI — is platform machinery built once and shared by every event type. Adding a new event to the catalogue is a payload schema and an outbox insert, not a new delivery system. And because the database is the buffer, producers don't slow down or fail when consumers are down: the slowest customer endpoint in the world can't back-pressure a form submission. Here is the delivery half of the schema on its own — the part a platform team would lift. There is no foreign key back into the domain at all. The producer hands the machinery a payload at creation time, durable on the delivery row, and from that point on the pipeline owns only the envelope: ```mermaid erDiagram ENDPOINT ||--o{ DELIVERY : "fan-out target" DELIVERY ||--o{ DELIVERY_ATTEMPT : "audit trail" DELIVERY ||--o{ OUTBOX_EVENT : "one per (re)send" OUTBOX_EVENT ||--o| PROCESSED_EVENT : "claimed exactly once" OUTBOX_EVENT { uuid id PK "the event id, end to end" text aggregate_type "routes to the topic" uuid aggregate_id "Kafka key = ordering scope" text event_type jsonb payload "opaque to the machinery" } DELIVERY { uuid id PK uuid endpoint_id FK jsonb payload "the event body, producer-supplied" uuid event_id "current outbox event" text status "pending / retrying / delivered / failed" int attempt_count timestamptz next_retry_at "partial index WHERE retrying" } DELIVERY_ATTEMPT { int attempt_no int response_code text error int duration_ms } ENDPOINT { uuid id PK text url text secret_ciphertext "AES-256-GCM, tenant id as AAD" bool active } PROCESSED_EVENT { uuid event_id PK "idempotency ledger" timestamptz processed_at } ``` The payload is opaque to everything downstream: the machinery reads and rewrites exactly two envelope fields it owns — `eventId` and `attempt` — when it re-emits, and never interprets the rest. Retries, manual or scheduled, spread the stored payload into a fresh outbox row with a new envelope; no joins back into producer tables, ever. That's what makes it liftable: a payments team and a forms team could share this delivery system without it knowing either domain exists. It didn't start out this clean. The first version kept a `submission_id` foreign key on deliveries and rebuilt the payload by joining `submissions` and `forms` on every retry — which worked, but meant the "generic" machinery secretly understood forms. Writing this section is what made me notice, and the fix (store the payload, drop the FK) deleted more code than it added: the retry scheduler lost its joins, and its tests no longer need to seed a single domain table. ## Handing off auth instead of owning it This thing lives on the open internet. The moment a domain resolves, scanners and credential-stuffing bots show up — that's not paranoia, it's the baseline weather. So authentication on every tenant-facing route isn't a feature, it's table stakes, and I didn't want to own the risky parts of it: passwords, sessions, token lifecycles. Auth is delegated to Cognito federating Google, over plain OAuth 2.0: ```mermaid sequenceDiagram participant SPA as SPA (public client) participant IdP as Cognito hosted UI
auth.murugappan.dev participant G as Google participant API as API SPA->>SPA: code_verifier → S256 code_challenge SPA->>IdP: /authorize + code_challenge IdP->>G: federated sign-in G-->>IdP: identity IdP-->>SPA: redirect with ?code=… SPA->>IdP: /token (code + code_verifier, no client secret) IdP-->>SPA: access token + ID token SPA->>API: Authorization: Bearer access_token API->>API: JWKS signature · iss · token_use · client_id API->>API: sub → tenant (provision on first login) ``` The SPA runs the authorization-code + PKCE flow against Cognito's hosted UI (on a branded `auth.` subdomain). PKCE means the SPA is a public client with no embedded secret. The API never sees a password and keeps no session state — it verifies the JWT's signature against Cognito's JWKS using `jose`, checks the issuer, `token_use` and client ID, and that's the entire trust decision. A tenant gets provisioned on first login from the token's `sub` claim, and the display name comes from the ID token after the code exchange, since the access token deliberately carries no profile data. I put the verification behind a small `TokenVerifier` interface so local development uses a dev-token implementation and production uses the Cognito one. Everything downstream — the guard, tenant resolution, RLS — is identical in both modes, which made the production cutover a config change rather than a code change. If I had to summarise it for an interview: authentication is the IdP's job, and my application's job reduces to verifying signatures and mapping `sub` to a tenant. The one surface that can't be authenticated is the public form itself — anyone with the link should be able to submit, that's the product. The same open-internet reasoning applies there, just with a different tool: per-IP rate limiting on the submission endpoint, so a bot hammering a form link exhausts its own budget instead of flooding the pipeline with junk submissions and webhook fan-out. ## Letting Postgres enforce tenant isolation Every tenant-scoped query runs inside a transaction that starts with `SET LOCAL app.tenant_id = $1`, and Postgres row-level security policies do the filtering. The API connects as a non-superuser role, so even if application code forgets a WHERE clause, the database refuses to return another tenant's rows. Two RLS gotchas cost me real debugging time, and both pass every happy-path test: 1. After a transaction-local `set_config` commits, the session value on a pooled connection becomes an **empty string**, not NULL. So `current_setting(...)::uuid` throws on the next anonymous query that reuses the connection. Every policy needs `NULLIF(current_setting('app.tenant_id', true), '')::uuid`. 2. Permissive policies OR together. My "anonymous users can read published forms" policy quietly leaked other tenants' published forms into logged-in sessions, because RLS takes the union of matching policies. It needed an explicit "only when no tenant is set" condition. ## Webhook integrity Every delivery is signed with `HMAC-SHA256(secret, timestamp + "." + body)`, sent as an `X-Eventform-Signature` header. The timestamp is bound into the MAC so receivers can reject replays outside a tolerance window, and comparison is constant-time. Review caught a real bug here: `Number(timestamp)` accepts decimals, which let a signature minted for one timestamp/body split verify against a shifted one. The fix was a strict digits-only parse — a one-line change I would never have caught myself. The signing secrets are never stored in plaintext. Each one is encrypted with AES-256-GCM, the tenant ID bound in as additional authenticated data, so a ciphertext copied onto another tenant's row simply fails to decrypt. That gives me cryptographic tenant isolation on top of RLS. The cipher sits behind a small `SecretCipher` seam: today it's an in-process AES-256-GCM implementation keyed from a 32-byte secret in the environment, and swapping in a managed, HSM-backed KMS (AWS KMS, Vault) for audited, rotatable keys is a one-class change with nothing above the seam touched. ## Scaling to zero between visitors A portfolio demo that nobody is looking at most of the time shouldn't cost what a server running around the clock costs. So the box powers itself off when idle and wakes on the next visit. An on-box timer watches for real traffic — every non-health request stamps a timestamp on disk — and after 30 minutes of silence the instance sets its own Auto Scaling group to zero and terminates itself. Nothing is lost when it does: Postgres lives on Neon, and the only on-box state is the Redpanda log, which is disposable. That's the durability property from the CDC section paying off again — because the database is the source of truth and the replication slot replays anything in flight on the next boot, the compute is genuinely throwaway. Waking back up is the half with a visible cost. The SPA is static on Cloudflare Pages, so the site itself never goes down; it just notices the API is unreachable and POSTs to a small authenticated endpoint (API Gateway → Lambda → desired capacity 1) that launches a fresh box. The honest price is a cold start — the first visitor after an idle stretch waits two to three minutes while the instance boots, pulls ~3 GB of images, and starts the pipeline. So instead of a spinner that looks broken, I show them what's actually happening and why, with a live progress estimate. It runs for a few dollars a month this way, which is the difference between leaving a demo up indefinitely and taking it down to save money. ## What working with Claude actually looked like The workflow that worked for me: I described what I wanted, Claude interviewed me about the design — tenancy model, retry semantics, where row locks actually applied — wrote a spec, and broke the build into five phased plans. Each plan task went to a fresh agent, and every task got reviewed twice: once for "did you build what the plan said", once for quality, with reviewers that poke at the running system instead of just reading the diff. That review loop earned its cost many times over. Besides the HMAC and RLS bugs above, it caught the worker crash-looping on a fresh deployment (the Kafka topic doesn't exist until the first event is ever produced, and the consumer's metadata fetch threw — a bug that only shows up on day one in production), and it caught CI booting the entire stack but never running migrations, so every test was hitting an empty database. ### The slop tax The failures were as instructive as the wins. AI agents generate confident nonsense at a low but very real rate, and in my experience it concentrates in prose rather than code. My landing page claimed "5 retries" when the code does 3. The hero badge said "Phase 4 demo" — internal planning jargon that leaked straight into customer-facing copy. The best one: the webhook-secret modal told users "shown once — store it now" while the Reveal button right next to it would happily decrypt the secret on demand, forever. That's copy written for a hash-only security model, pasted onto a system that was deliberately built reveal-capable. The pattern I took away is that automated review verifies behaviour relentlessly and skips words entirely. The tests proved the retry logic worked; nothing ever fact-checked the marketing claim against `MAX_ATTEMPTS = 3`. Once I noticed, one dedicated audit pass — find every factual claim in the UI and verify it against the code — cleaned everything up in a single sweep. If you ship AI-built products, budget for that pass. The code lies rarely. The copy lies fluently. ### What I'd keep doing Tests against real services made the whole thing trustworthy — every integration test hits live Postgres and Kafka and runs the real AES-256-GCM cipher, no mocks anywhere, so when an agent claimed something worked there was a green suite against real infrastructure behind the claim. Plans written as executable documents (exact file paths, actual code blocks) kept agents on rails, and deviations got reported instead of silently improvised. And I kept the design decisions for myself — outbox over dual-write, Cognito over hand-rolled auth — and spent most of my attention being suspicious of everything else. Final tally: 163 tests, a Playwright run that drives sign-in → build → publish → anonymous submit → delivered webhook end to end, and a pipeline whose delivery guarantees I can defend line by line. The robots type fast; you just have to read what they wrote. --- # Modern distributed rate limiting in the cloud URL: https://murugappan.dev/blog/cloud-agnostic-rate-limiting/ Date: 2026-06-09 Description: Why LLM agents make per-user rate limiting essential, and a two-tier IP and per-user pattern that protects your compute budget across clouds A while back I wrote about getting rate limited _by_ an external API. This post is the other side of that coin: how we, a scaling startup, rate limit the traffic hitting **our own** API — and how we built it so that switching cloud providers later would be a change of _implementation_, not a redesign. When you are small you don't think about rate limiting at all. Then one of three things happens. A misbehaving client gets stuck in a retry loop and hammers an endpoint thousands of times a minute. A scraper discovers your public search endpoint and decides to mirror your catalogue. Or someone points a credential-stuffing script at your login route. The symptom is always the same: a flood your autoscaler dutifully tries to serve, a bill that creeps up, and real users getting a degraded experience. Here's what changed recently, and why I think this moved from a nice-to-have to table stakes: **the traffic isn't human anymore.** For most of the web's history a single user generated sporadic, bursty, fundamentally _slow_ load — someone clicks, reads, thinks, clicks again. A human simply can't issue more than a handful of requests a minute by hand. LLM-based agents broke that assumption overnight. One user now points an agent at your API that calls it in a tight programmatic loop, retries aggressively on every hiccup, fans out into parallel sub-tasks, and runs unattended for hours. Per-user load jumped from a few requests a minute to hundreds, sustained, around the clock — and a single over-eager or buggy agent is indistinguishable from an attack. That matters more than it used to because of where the cost lands. Every request an agent makes can cascade into _metered_ spend downstream: more compute, more database load, and increasingly your own LLM/inference bill if the endpoint itself calls a model. An unbounded agent isn't just a latency problem anymore — it's a **budget** problem that can quietly run up a five-figure cloud bill overnight, on legitimate credentials, with nobody doing anything malicious. In the agent era, per-user rate limiting is the ceiling you put on that blast radius. It's no longer about stopping bad actors; it's about keeping a well-meaning automated client from accidentally bankrupting a feature. The naive fix is to add a middleware in the app: check a counter, return a `429`. It works, but it has two problems I learned the hard way. First, by the time your app counts the request, **you have already paid for it** — the connection was accepted, routed, a container woke up, auth ran. Second, and worse: your app containers are a _fixed, slow-to-scale_ resource. During a real flood, the existing containers get bombarded and fall over their health checks long before new ones finish spinning up. The layer doing the rejecting is the layer that dies. So we don't reject in the app. We reject in two tiers _in front_ of it. ## The principle: fix the architecture, swap the implementation The trick to staying cloud-agnostic isn't finding one magic tool that runs everywhere. It's keeping the **layers and their responsibilities constant**, and letting only the _implementation_ of each layer vary per cloud. Anything that speaks a standard protocol travels with you; anything that's a proprietary cloud API is a chain to that vendor. There are two tiers, and they exist for a non-obvious reason explained below: 1. **Edge tier** — coarse, per-IP, pre-authentication. Sheds floods cheaply before they reach your stack. 2. **Per-user tier** — precise, keyed on a stable user id, post-authentication. Runs in an elastic gate _in front of_ your app so the app fleet never absorbs the surge. ```mermaid flowchart TD C[Clients] -->|JWT from OIDC provider| E[Edge tier: Cloudflare or a cloud WAF] E -->|over per-IP limit| EB[429 blocked at edge] E -->|under limit| G[Gateway: Envoy or Kong] subgraph CLUSTER["Your Kubernetes cluster — portable"] direction TB G -->|validate JWT, key on sub| UCHK{Per-user over limit?} UCHK -->|yes| GB[429 blocked at gateway] UCHK -->|no| APP[App pods] G <--> R[(Redis / Valkey)] end style E fill:#fff3d6,stroke:#d4a017 style EB fill:#ffe0e0,stroke:#c0392b style GB fill:#ffe0e0,stroke:#c0392b style APP fill:#e0f5e0,stroke:#27ae60 ``` ## Why per-user can't live at the edge This is the insight that shaped the whole design. Your first instinct (it was mine) is: authenticate the user, figure out who they are, and rate-limit per user right there at the edge. You can't — and the reason is ordering. **The edge security layer always runs before authentication.** On AWS, "WAF rules are evaluated before other access control features, such as resource policies, IAM policies, Lambda authorizers, and Amazon Cognito authorizers." The same is true of edge WAFs generally and of CloudFront's own functions. So at the moment the edge evaluates a request, **the user's identity does not exist yet** — auth happens later, downstream. The edge can only key on what the _client_ sends unprompted: the source IP, and raw headers/cookies/query values it has no way to validate. ```mermaid flowchart LR REQ[Incoming request] --> EDGE[Edge / WAF] EDGE --> IPCHK{Per-IP limit} IPCHK -->|sees IP + raw headers| OK1[OK, forward] OK1 --> AUTHN[Auth: validate JWT] AUTHN --> IDN[Identity known: sub] IDN --> USRCHK{Per-user limit on sub} EDGE -. cannot see sub yet .- IDN style EDGE fill:#fff3d6,stroke:#d4a017 style AUTHN fill:#dbe9ff,stroke:#2c6fbb style USRCHK fill:#e0f5e0,stroke:#27ae60 ``` So the edge tier does what it _can_ do well — limit by IP — and the per-user tier lives after auth, where the identity is actually known. ## Tier 1: the edge (per-IP, pre-auth) This is the layer that sheds dumb floods. Every cloud has a managed WAF (AWS WAF, GCP Cloud Armor, Azure Front Door) and they're all roughly equivalent in capability — which also makes them the easiest lock-in to fall into. To keep this tier independent of your _compute_ cloud, the cleanest move is an edge provider that sits in front of any origin: **Cloudflare**, Fastly, or Akamai. Your origin can be on AWS today and GCP next year; the edge config doesn't move. A per-IP rate limit on Cloudflare, in Terraform: ```hcl resource "cloudflare_ruleset" "edge_rate_limit" { zone_id = var.zone_id name = "edge-ip-rate-limit" kind = "zone" phase = "http_ratelimit" rules { action = "block" description = "Per-IP limit on the API" expression = "(http.request.uri.path contains \"/api/\")" ratelimit { characteristics = ["ip.src", "cf.colo.id"] period = 60 requests_per_period = 2000 mitigation_timeout = 60 } } } ``` Two things worth knowing regardless of provider: roll new limits out in **count/log mode** first and watch the metrics for a few days — your legitimate power users get closer to the threshold than you'd guess — and **scope the rule down** to the paths that need it instead of one global limit. Volumetric L3/L4 DDoS is the one thing you genuinely can't self-host economically, which is the honest reason this tier stays a vendor: just pick one independent of your compute. ## Tier 2: per-user (keyed on `sub`, post-auth, in front of the app) After authentication you finally have a stable identifier for the user. Use the **`sub` claim** from the JWT your identity provider issues — not the raw token, which rotates on every refresh and would hand each user a fresh bucket. The identity provider itself is portable as long as it speaks OIDC: self-host **Keycloak** or **Zitadel**, or use a managed-but-neutral issuer. Your gateway only ever reads a standard claim. The enforcement point is a gateway that runs as containers in your own cluster — **Envoy** or **Kong** — sitting in front of your app pods. This is what solves the bombardment problem: the gateway scales horizontally as its own deployment (HPA), so _it_ absorbs a surge, not your fixed app fleet. The app pods only ever see traffic that already passed the limit. Kong is the low-ops option — its JWT and rate-limiting plugins do this out of the box, with shared state in Redis/Valkey so the limit is correct across all gateway replicas: ```yaml services: - name: api url: http://app.default.svc:8080 routes: - name: api-route paths: ["/api"] plugins: - name: jwt # validates the JWT, resolves the consumer from it - name: rate-limiting config: minute: 300 # per authenticated user limit_by: consumer # the consumer is the authenticated identity (sub) policy: redis # shared state → correct across all gateway replicas redis: host: redis.default.svc port: 6379 fault_tolerant: true ``` Envoy is the more powerful option: its `jwt_authn` filter validates the token and extracts the `sub` claim, and its rate-limit filter sends a descriptor keyed on that claim to the open-source `ratelimit` service, which holds the token buckets in Redis. More wiring, but it's the gold standard for distributed rate limiting at scale. Either way, **the state lives in Redis/Valkey**, which speaks the same protocol on every cloud — swapping ElastiCache → MemoryStore → Azure Cache is a connection-string change, not an architecture change. > **Landing this on AWS specifically:** the same shape maps to AWS WAF (tier 1) + a Lambda authorizer that validates the JWT and does a token-bucket check in DynamoDB before the request reaches your integration (tier 2). It works and it's fully serverless — just know that API Gateway caches authorizer results, so you must set `authorizerResultTtlInSeconds = 0` or the counter won't increment on cached requests. The catch is it's the _most_ locked-in version: Lambda authorizer + DynamoDB don't travel to another cloud. The Envoy/Kong + Redis version is the same architecture without the chain. ![AWS architecture: per-IP rate limiting at CloudFront with AWS WAF, per-user limiting in a Lambda authorizer backed by a DynamoDB token bucket, all in front of the ECS Fargate app; Cognito issues the JWTs and CloudWatch collects metrics from both tiers](aws-architecture.png) The two tiers map straight onto AWS services: CloudFront + WAF shed per-IP floods at the edge, and the Lambda authorizer — keyed on the Cognito `sub` and backed by a DynamoDB token bucket — does the per-user limiting before a request ever reaches Fargate. Notice the authorizer scales per-request, so it absorbs a surge instead of your app fleet. ## The portability map The whole point is that switching clouds touches the right-hand column, never the architecture: | Layer | Responsibility | Portable choice | The lock-in version | | ---------------- | ------------------------------- | ------------------------- | -------------------- | | Edge | Per-IP, flood/DDoS, pre-auth | Cloudflare / Fastly | AWS WAF, Cloud Armor | | Identity | Issue JWT with stable `sub` | Keycloak / Zitadel (OIDC) | Cognito | | Per-user gate | Limit on `sub`, in front of app | Envoy / Kong (in k8s) | Lambda authorizer | | Rate-limit state | Shared counters | Redis / Valkey | DynamoDB | | Compute | Run the stack | Kubernetes | ECS/Fargate | | Observability | Metrics on both tiers | OpenTelemetry | CloudWatch | | Infra-as-code | Provision it all | Terraform/OpenTofu | CDK | ## The trade-off, stated honestly Portability is not free — you pay for it in operations. Managed WAF, Cognito, Lambda, and DynamoDB are close to zero-ops; Envoy, Redis, and Keycloak are yours to run, patch, and scale. For a three-person team that is a real cost, and "use the managed AWS version and abstract it behind Terraform modules" is a perfectly defensible choice if you don't actually expect to move. What you should _not_ do is bury cloud-specific assumptions in your request-handling logic, because that's the thing that turns a cloud migration from a config change into a rewrite. The way I think about it: the edge is the bouncer that keeps the stampede out, the gateway is the doorman who checks each guest's pass, and the app is the host inside — free to focus on guests who actually made it in. Keep those three roles fixed and well-separated, let each one be played by whatever the current cloud offers, and you get a disproportionate amount of resilience _and_ the freedom to move — for not much more than a couple of config files and the discipline to keep the layers honest. And in a world where your "guests" are increasingly tireless automated agents rather than humans who pause to think, that doorman checking each pass is no longer a luxury. It's the difference between an agent-driven feature that scales and one that wakes you up to a budget alert at 3am. --- # Rate limit api requests in nodejs URL: https://murugappan.dev/blog/429-googleapis/ Date: 2022-10-28 Description: How to query external apis without hitting 429 rate limit in nodejs Recently, I was working on syncing contracts in a user's gmail inbox to our clm tool and when testing on my colleague's account, we hit a 429 status code from Google servers and it was working fine on my own Google account. The corresponding message for 429 was **Too Many Requests** My first instinct was to look at the API quota and, to my surprise, peak usage per **minute** was not even 2%. ![api usage!!!](quota.png) On further digging, we found [this](https://developers.google.com/gmail/api/reference/quota) per user per second limit of 250 units per second with each request given a unit like get - 5, send - 100 and so on. The justification behind this painful limit is that Google does not want the user's servers to get overloaded and crash. This also avoids DoS attacks, I suppose, from malicious third parties. I would have put something similar in place if I had designed the system too. Ok. This is a fairly standard design decision by Google, and the solution must be available across the internet, right? **No**, The solution is fairly simple in a multi-threaded language. 1. create 50 threads (50 \* 5 = 250). 2. make the request 3. put the threads to sleep for 1 second 4. Repeat till all resources are fetched Alas!, Node is single-threaded and relies on asynchronous programming for network requests. Node has no native API to control the number of unresolved promises or pause execution for a given time. First, we looked at some npm packages and [p-limit](https://www.npmjs.com/package/p-limit) was the only one with enough weekly downloads to be worthy of consideration, but it had no support for debouncing in terms of time, only concurrent promises. So, we ended up implementing the ideas in a blog post. I have given my understanding of his implementation and how we wrapped axios.get function in it. If you are interested, you can read more [here](https://blog.thoughtspile.tech/2018/07/07/rate-limit-promises/). Since this is a complex problem with two paradigms (concurrency and time), let's try to implement debounce for a single function first. setTimeout is an old API and relies on callbacks rather than promises. Not ideal!. (you can await or use then with promises only) So, let's wrap it in a promise like below, ```js new Promise(ok => setTimeout(ok, 1000)) ``` We might need to change the time it awaits later or reuse it for another debounce with a different delay. So, let's use a closure to make the delay configurable. ```js const resolveAfter = ms => new Promise(ok => setTimeout(ok, ms)) ``` A new function call should now be made only after at least 1 second has passed since the previous function call. We have to make use of promise chaining to achieve this, as below. ```js function rateLimit1(fn, msPerOp) { let wait = Promise.resolve() return (...a) => { // We use the queue tail in wait to start both the // next operation and the next delay const res = wait.then(() => fn(...a)) wait = wait.then(() => resolveAfter(msPerOp)) return res } } ``` Now, for the first call, the wait is resolved, so it calls fn without delay and has a promise attached that resolves after 1 second. Now, if a second call is made concurrently by ,say, `Promise.all`, the function call will only be made after the last promise in the wait object resolves (setTimeout). This is repeated for each call. Now we can wrap the promise and call with no worries, the operations are magically delayed. ```js const slowFetch = rateLimit1(axios.get, 1000) Promise.all(urls.map(u => slowFetch(u, options))) .then(raw => Promise.all(raw.map(p => p.json()))) .then(pages => console.log(pages)) ``` Now we just need to use this debounce for 50 function calls instead of one. One approach would be to create 50 promise objects in a queue and chain a single timeout to them. The issue is that even if one of them resolves before 1 s, then the 51st request would go through the empty slot before it times out. ```js rateLimit(concurrencyLimit(fetch, N), ms) ``` So, we have to do the reverse and create 50 resolveAfter's and put them in a circular queue so the 51st request waits for at least 1 second from the first request before executing. ```js concurrencyLimit(rateLimit(fetch, ms), N) ``` Below code implements this ```js function rateLimit(fn: Function, delayMs: number, maxConcurrent = 1) { // A battery of 1-rate-limiters const queue = Array.from({ length: maxConcurrent }, () => rateLimit1(fn, delayMs) ) // Circular queue cursor let i = 0 return (...a: any) => { // to enqueue, we move the cursor... i = (i + 1) % maxConcurrent // and return the rate-limited operation. return queue[i](...a) } } ``` Now, we just need to replace `rateLimit1(axios.get, 1000)` with `rateLimit(axios.get, 1000, 49)`. I have left some leeway by using only 49 requests because a user opening Gmail app/website would also count as a request and shouldn't result in 429. I hope you can use this idea to solve your rate limit problems in external services!!!!!! Published this as an npm package to make it easy for others to use, [read more here](https://www.npmjs.com/package/rate-limit-concurrent) --- # The developer toolbox URL: https://murugappan.dev/blog/toolbox/ Date: 2022-02-05 Description: A guide to becoming a developer from my experience becoming one. ## A Language The next tool is learning any mainstream language. This is covered well by most uni's. I beleive learning python/js at first will really hurt the Developer experience(Dx) for most people because eventually you will run into concepts like types, pointers, classes and compilation. Then, you will hate these because you know small projects can be done without them and start hating them when you accidentally chose java or c++ for your project. But ultimately, all real world projects are huge scale and relay on these concepts to be sure their system works and can be compartmentalized and reused. All though there is a lot of debate in the community around dynamic vs static typing, I prefer static for all projects and dynamic for any small(like really small) works. even then, It could become a liability quicker than you think. This ismple graph kind of nails it all. ![static vs dynamic typing productivity vs codebase](graph.png) > I recommend learning C first(only the very basics of it with pointers and structs) . Then learn any OOPS language (I prefer kotlin, but even typescript is great) and data structures in C (not OOPS). then learn python/js as a quick to prototype model. ## ## The world of huge codebases I believe a CS degree really lays a solid foundation to become a developer except in one huge area. All projects/lab software that we write in uni tend to be one file programs to at best a few hundred lines of code for an app and often are written individually or by a few people using one copy of the codebase. In the real world of software development, almost no project is like this. All projects have thousands of lines of code and are simultaneously worked on by teams of people. So, the first tool you need to be very familiar with is git. ![git init I guess things are getting pretty serious - Things are getting pretty serious | Make a Meme](git-init.jpg) The best way to do this is by contributing to open-source projects. find a platform you are interested in (web, mobile, ml etc) and find small scale projects with issues open in github. use "Good First issue" label and find a issue to contribute to. fork the project, clone to your remote machine. Often you will find guidelines on how to fix, if not try to google around for possible approaches. once you verify all tests are running in local environment with your modified code, make a commit and push to your github repo. from there, open a pull request(pr) with a description based on template provided. The maintainers will guide you through and ask for any changes needed, make them commit and repeat till they approve and merge your pr. **Congratulations!!** you have contributed to open source and learned your first lesson on working with highly distributed huge scale development of software. this is how its done in the real world. > I am also a undergrad in CS in my 3rd year and until very recently, I too hadn't worked with pr's. I feel this should be thought in uni alongside git during sophomore before we are asked to do projects (typically 3rd year). ## ## Test Driven Development (TDD) The best quote i heard on TDD was: > "Unit tests are like vegetables. you hate eating(writing) them, but they ensure you(codebase) stay healthy" Unit tests do exactly what the name suggests, they ensure a unit (class, function etc) work as you intended them to. TDD philosophy can be applied for developing any feature in three steps. 1. Write a test for the feature and run to see it fail 2. write enough code to make the test pass 3. refactor for more readability/separation of concerns. ![Red, green, and don't forget refactor | mokacoding](red-green-refactor.jpg) This is great for the psychology of the developer also as seeing a green validation of their work increases morale. ## Database The moment you need to persist something over restarts and time, you need a database. Relational databases are the most popular and prove enough for a wide range of application upto medium scale. However, for large scale and niche scenarios like chat data, you will need nosql. The major nosql paradigms are: 1. key-value pair - great for caching ex: Redis 2. Column-Oriented - great for time series and indexing ex: BigTable 3. Document Based - Best sql replacement ex: MongoDB 4. Graph Database - best for graph (friends relation, roads) ex: neo4j Just know the basics of all and learn mongoDB and sql in depth. ## UI/UX My suggestion here is learn basics of html/css/js and one declarative composable framework like react/vue/angular (I prefer React with functional components(Hooks)). These modern frameworks have moved away from the traditional imperative model and popularised ui as function of state (again for reducing bugs in large scale projects). The mobile world has also warmed up to the idea with jetpack compose(Android) and swift ui(ios). So it would be great if you learned these trends. checkout my guide on [React Hooks](https://murugu-21.github.io/react/). This will also make you learn about rest-api's and a bit of microservices as you write to implement the backend of your app and try to make it communicate with your frontend app. ## Deployment signup for aws, add a credit card and deploy your project in a ec2 instance with s3 for assets like images and a database. This is the best way you can learn how deployment works. Docker is a way to package your application in a provider agnostic way. Kubernetes is used to scale your application up and down based on traffic. These are the new trends of cloud, learn the basics of them and play around with a linux distribution. you must be good to go. ## Machine Learning(ML) Thanks to [Moore's Law](https://en.wikipedia.org/wiki/Moore's_law), Both computing power and memory capacities have grown considerably over the past 5 decades. This has lead to a burst in the amount of data collected and analyzed by people over the years. The culmination of all this is neural networks, they are mathematical models which roughly simulate our brain's neurons to form connection among datasets and expected outputs. Python is the defacto language of ML. Tensorflow(Google) and pytorch(Facebook) are the two most popular neural networks libraries. Try to understand the basics of these even if you don't intend to work in this space because ML has become a key part of all organizations operations and chances are you are going to be asked to integrate a model into your product one day or another. The idea that your app can understand the world around it is pretty exciting to think about. This is going to result in a lot of innovative app ideas. Also, If you are interested, this is the most paid field in the industry because its so new and there are very few people with PHD's who can perform competently. Laws of demand and supply apply everywhere. However, The work is very much different from software development and requires multi domain knowledge of statistics, sql, specific domain knowledge of problem etc. All ML projects are also essentially R&D projects but most managements don't understand this and pressure for output especially considering the pay of people involved and hardware costs(Oh yes!, all AI/ML research are super compute/memory intensive and require specialized hardware like NVIDIA GPUs and TPU/NPUs). So consider these factors before jumping all in or starting a startup around ML. ![google ML meme](ml.png) ## Conclusion Overall, its a great time to become a developer and I beleive this guide helped you understand the path to become one. Its a wild world out there, with patience and years of hard work and a bit of money, you will be able to put together any task assigned to you/any tech startup you want to make. Remember, non-technical people thinks its all possible in a day and will ask you do magic with your app. stay away!!! ![you want the app to do what!!!](non-tech.png) --- # React Hooks URL: https://murugappan.dev/blog/react/ Date: 2021-09-26 Description: My mental models about Different React Hooks and Redux pattern. ## History of the web The web was initially all about static documents meant to act as a sort of indexed library of the world. It still is, but the introduction of javascript by netscape and subsequent performance improvements by firefox(gecko) and chrome(V8) made it possible to build super interactive dynamic websites that for most people replaced the need to develop and support desktop apps for different OSes. This also meant that a lot of R&D money from internet companies like Facebook, Google went into making the web a better place. Today, even desktop apps are built using electron, which is basically a browser and server patched into a app. The development of nodejs (again thanks to V8) has resulted in js being used even in the backend/server part of applications. ## JavaScript Back in 1995, Brendan Elich, a Netscape engineer, put together the initial version of JavaScript in 10 days as a additional nice to have feature for their flagship browser. But it became so popular that all browser vendors accepted js as a standard. This hacky initial version meant that js was inherently bad in design, but nobody wanted to break existing websites in their browser. Since a new language was ruled out, people started adding new syntax and nice paradigms to existing js and a lot of transpilers were created to compile better designed languages to js. Since adding new features to js is a painful process as it has to be accepted as a standard by all vendors, companies came out with different frameworks/libraries as a better approach to tranpsilers. In 2013, facebook came out with a js library called React which changed the framework landscape with a new and innovative paradigm(more below). Frameworks like Angular, Vue changed their approach to web development after React became so popular. ## React ![React Home Page](react.jpg "React Home Page") Javascript is imperative in changing state of components. Below is a simple example of incrementing counter value on button click. ```html
``` React essentially does the last part for you. Whenever you change state variables related to UI, you call setState instead of direct assignment and React renders The UI again to match current state. ```jsx import React, { Component } from 'react'; class App extends Component { state = { count: 0 }; render() { return (
{this.state.count}
); }}export default App; ``` This syntax of combining js and html is called JSX, Babel compiles this template to pure js and react ships a runtime to handle setState during runtime. JSX and React allows us to render our UI as a set of reusable components each with their own conatined state and events. This makes our code declarative - this means much more maintainable and readable code as far as UI is concerned. ## Hooks In 2019, React library was updated with hooks to make code more readable(less verbose) and minimise components size. Here’s the same example using hooks. ```jsx import { useState } from "react" export default function App() { const [value, setValue] = useState(0) return (
{value}
) } ``` useState is a hook which takes in initial value as parameter and returns state variable and setState. This results in clean setState functions where you don’t have to copy the whole state to change some of it and rewrite the whole state again in setState(essentially reducing code size). ### useContext This is all fine for one component. But in the real world apps are going to have hundreds of components and when you need a state change in one component to trigger UI changes in another, It get incredibly messy. Before context, you would need to hold all state in parent component and pass down state as props to all dependent children. This can make your parent component huge(1000s of lines huge) and involve a lot of boiler plate code. We can use createContext and useContext hooks as syntactic sugar to make this a lot easier. App.js ```jsx import { createContext, useState } from "react" import CustomButton from "./customButton" export const stateContext = createContext() export default function App() { const [value, setValue] = useState(0) return ( {value} ) } ``` customButton.jsx ```jsx import { useContext } from "react" import { stateContext } from "./App" export default function CustomButton() { const { setValue } = useContext(stateContext) return } ``` ### useEffect The next problem comes when you have to fetch data from a database and load to UI. fetch is asynchoronous, which basically means that it might take a long time to execute and your UI will be frozen if done synchronously. Hence, you use useEffect with empty array as dependency (since fetch only occurs once after loading) to update UI. What fetch does is that it makes a request to a external api and lets the event loop run. When response is received, code inside then() is added and executed as a microtask (when event loop reaches end). ```jsx import { useState } from "react" export default function App() { const [value, setValue] = useState(null) useEffect(() => { fetch("/api/getvalue").then(res => setValue(res)) }, []) //empty dependency array ensures only one execution after initial render return (
{value}
) } ``` useEffect is also very useful when you have state or UI changes or cleanup dependent upon state change of another value(useLayoutEffect is better suited for synchronous code that need to be run before paint). In general, remember this, useEffect runs after state of any dependency array values has changed. ### useReducer ![Redux features](redux.jpg "Redux features") This hook can be used with useContext to follow the redux pattern. ![redux-pattern-image](redux-pattern.png "redux-pattern-image") - **store** - global state variable containing state of whole application. - **action** - state can be changed only by despatching a action. - **reducer** - pure function that takes state, action as input and returns next state. Here pure function means that state is immutable(state object is not directly changed by function, rather a completely new state is returned with corresponding changes.) - **dispatch** - takes in action object and calls the reducer with current state. The useReducer hook takes reducer, initial state as arguments and returns current state, dispatch. This is very similar to useState, but here we extract the logic to change state to a reducer function and only dispatch actions. This makes our code DRY, easier to debug and improves performance by avoiding callbacks. React bails out of rerendering the children or firing effects if same value is returned by reducer. Lets look at the same counter example using redux pattern. App.js ```jsx import { createContext, useReducer } from "react" import CustomButton from "./customButton" export const stateContext = createContext() const initialState = { value: 0 } // store initial value const reducer = (state, action) => { switch (action.type) { case "increment": return { ...state, value: state.value + 1, } default: throw new Error() } } export default function App() { const [state, dispatch] = useReducer(reducer, initialState) return ( {state.value} ) } ``` customButton.js ```jsx import { useContext } from "react" import { stateContext } from "./App" export default function CustomButton() { const dispatch = useContext(stateContext) return } ``` Based on the complexity of your application and features you want to implement. redux pattern can either seem like needless boilerplate (for small apps without a lot of shared state between components) or the best design decision you ever made (for large apps with a lot of shared state between components that needs to be debugged every week or so and has to meet a lot of performance metrics). Basically if you are making a blog with react, you will never need redux. But if you are building the next facebook or twitter with huge teams involved in development, redux will save you a lot of effort and time. check out [this article](https://medium.com/@dan_abramov/you-might-not-need-redux-be46360cf367) by the creator of Redux. If you are just begining to learn react, I suggest you try out your idea using the methods mentioned here [thinking in React](https://reactjs.org/docs/thinking-in-react.html). If you want to delve deeper into the hooks api, checkout [react-hooks](https://reactjs.org/docs/hooks-reference.html). --- # 0.1+0.2 not equal to 0.3???😕 URL: https://murugappan.dev/blog/first-post/ Date: 2021-08-21 Description: The limitations of floating point math and weighing tradeoffs. Hey there, if you are reading this in a desktop, press ctrl + shift + i and open console and paste the below code. ```js 0.1 + 0.2 === 0.3 ``` if you are new to programming, you might be surprised by the output false and is computer arithmetic broke or is this a secret plan by illuminati to control all our computers. Don’t worry, this is by design. we think of numbers as decimal(multiples of 10), similarly all computers process numbers in binary. So, the same way 1/3 can never be represented accurately in decimal. No fraction with divisors other than 2 can be represented exactly in binary. In this case, binary64 0.1 is a little greater than 1/10 and 0.2 is a little greater than 1/5, so the difference add up is significant enough for the result to become 0.30000000000000004. --- ## The solution In financial computing, fixed point is used to overcome this problem. But, this is only because of rounding to given precision(generally 2 points after decimal), Binary can never represent 0.1 or 0.2 or any fraction with denominator other than 2 accurately, the same way decimal cannot represent any fraction with denominator other than 2 and 5 accurately. processor designers preffered to implement FPUs(Floating Point Units) because this format offers more range for the same amount of space (16, 32, 64 bits) and figured most(99.9%) of the computations will not be affected by these subtle idiosyncrasies. But most computing will be affected by a lack of range and quick arithmetic operations. Play around by changing pricision numbers using below snippet😁✌️. ```js Number.parseFloat(0.1).toFixed(20) ``` ```js "0.10000000000000000555" ``` --- # why i chose gatsby for my blog? URL: https://murugappan.dev/blog/gastby/ Date: 2021-08-15 Description: TL;DR Static Site Generator using graphQl React stack. If you are learning web development, you might have come across libraries/frameworks like React, Angular and Vue. The reason these web technologies are so popular is because they allow you to write declarative code and not worry about the implementation details. At its core, if you want a button, you only describe what happens when the button is clicked and not how it happens. This paradigm was made famous by the React library from facebook in the past decade and competing technologies have adopted a similar strategy. This way of writing ui makes the code much more maintainable and easier to read. however, the tradeoff here is that the entire ui is rendered on the client-side, this can be incredibly slow and hurt battery use on mobile devices, and a lot of js code has to be transferred to the client(bundle sizes can reach megabites). The web community has realised this and come out with two main solutions 1. Static Site Generation (SSG) 2. Server Side Rendering (SSR) Out of these, if your content doesn’t change for every user and different timings, then SSG is the way to go. something like newsfeed has to use SSR. I preferred gatsby because it uses graphQl with React, one of my favourite stacks to work on for front-end projects. Also, gatsby has a lot of plugins which make it easy to add third party features and leverage out of the box solutions for common components like dark mode, google analytics, image processing etc. gatsby also had a wonderful blog template that i noticied in a lot of blogs across the internet. https://www.gatsbyjs.com/starters/gatsbyjs/gatsby-starter-blog The blog consistently achieves 90+ performance in lighthouse for mobile devices. --- # Coin Change Problem URL: https://murugappan.dev/blog/coin-change-problem/ Date: 2021-08-09 Description: Find minimum number of coins that make a given value. When I was a child, I used to run to grocery store nearby wondering how much change I should carry in order to pay the bill exactly as many shopkeepers use no change as classic excuse to push chocolates on you (effectively, increasing their sales numbers). I figured that if I carried one ₹5 coin, two ₹2 coins and one ₹1 coin I could pay any change from 1 to 10. Coin change problem is much simpler, given a value n and a list of coin values, we have to figure out the minimum number of coins required to reach the value n. If no solution exist we can output -1. My initial approach is that if the value is equal to one of the coins value I could return 1. It is easy to scale for larger values of n as we can subtract each coin value from n and repeat till n becomes one of the coin values. We can compare the solution generated and choose one with minimum value for (n - coin) value for each and use a global variable to select the sequence with minimum number of coins. ```js function minCoins(coins, n) { if (n === 0) return 0 let noOfCoins = -1 for (let i = 0; i < coins.length; i++) { if (coins[i] <= n) { let noOfCoinsSub = minCoins(coins, n - coins[i]) if (noOfCoinsSub !== -1 && (noOfCoinsSub + 1 < noOfCoins || noOfCoins === -1)) noOfCoins = noOfCoinsSub + 1 } } return noOfCoins } //example console.log(minCoins([1, 2, 5], 12)) ``` ```text 3 ``` The above solution uses recursion. In general, It is a good practice to avoid/optimize recursion as much as possible in our application, while it makes reading code a breeze, the call stack can become huge and result in stack overflows. We can see that minCoins([1, 2, 5], 10) is called twice once after subtracting two 1’s and again for coin 2. Like this, The same results are computed many times and this really hurts the performance of our function as n becomes large. We can optimize our function by memoizing the answer for minCoins([1, 2, 5], 10). In fact this is a very common technique in DSA known as Dynamic Programming (DP). Basically, wherever we use recursion and a lot of the subproblems overlap, memoization can improve run time by orders of magnitude. There are two techniques tabulation and memoization within DP. We will use memoization since this will keep our function resembling the original solution with just another if condition rather than resorting to loops as in the case of tabulation. ```js function minCoinsMemo(coins, n, dp) { if (n === 0) return 0 let noOfCoins = -1 if (dp[n]) return dp[n] for (let i = 0; i < coins.length; i++) { if (coins[i] <= n) { dp[n - coins[i]] = minCoinsMemo(coins, n - coins[i], dp) if (dp[n - coins[i]] !== -1 && (dp[n - coins[i]] + 1 < noOfCoins || noOfCoins === -1)) { noOfCoins = dp[n - coins[i]] + 1 } } } return noOfCoins } //example console.log(minCoinsMemo([1, 2, 5], 12, new Array(12))) ``` ```text 3 ``` We can measure the improvement using performance.measure(). I have given my results below. ```js const { performance } = require("perf_hooks") let coins = [1, 2, 5], n = 30, dp = new Array(n) let t0 = performance.now() minCoins(coins, n) let t1 = performance.now() console.log(t1 - t0, "ms") let tm0 = performance.now() minCoinsMemo(coins, n, dp) let tm1 = performance.now() console.log(tm1 - tm0, "ms") ``` ```text 75.27449998259544 ms 0.07790002226829529 ms ``` **Geez** Magnitudes of improvement!!! **Note:** I was asked a variant of this problem where the sequence rather than just the count of coins was needed in a recent interview. I have linked the stackoverflow question and my answer [here](https://stackoverflow.com/questions/67793004/there-are-x-participants-the-participants-are-to-be-divided-into-groups-each/68948687#68948687).