What this post covers
Problems I hit over several days all converged on one question: what does the layer in the middle pass through untouched, and what does it fill in by guessing? Product names and numbers are left out; only the generic concepts that hold anywhere.
What I worked on
| Task | What I wanted | What I did | Outcome |
|---|---|---|---|
| Infinite redirect | Revive a path that bounced back to itself | Confirmed the cause by inspecting the upstream response with curl | Confirmed the rewrite passes 3xx through; fixed by changing the destination |
| Premature Korean commit | Stop values being saved mid-composition | Tracked composition state and deferred the commit past composition end | Works regardless of per-browser event order |
| Bundle reduction | Cut JS shipped per route | Moved the client boundary of the provider and header auth UI inward | Reduced without deleting code — only by moving the boundary |
| Image ratios | Fix screenshots that looked cropped | Injected intrinsic dimensions at build time, kept the fallback | Layout shift and cropping resolved together |
1. A rewrite is a proxy, not a redirect
The situation. Opening a certain path never rendered; the browser gave up with "too many redirects." Yet the application code contained not one line redirecting that path anywhere.
The core idea. A redirect hands the browser a 3xx saying "go over there," and the address bar changes. A rewrite leaves the address bar alone and has the server fetch the content on your behalf — a proxy. The trap is that a rewrite does not transform the upstream response: if the upstream returns a 3xx, that 3xx goes straight to the browser. Many SaaS products 308-redirect a tenant address to the customer's canonical domain to avoid duplicate URLs — and if that canonical domain rewrites back to the SaaS, the round trip never ends.
What I did. Instead of guessing, I printed the upstream status code and location directly with curl -I --max-redirs 0 <destination>. The tenant address 308'd to my own domain; the vendor's proxy-only host for custom domains returned 200. Changing the destination fixed it. The decisive clue: a sibling rule in the same config file that had been working all along was already using that host. Of the config snippet I was handed, I adopted only the destination line — pasting it wholesale would have wiped out the existing catch-all rewrites and the image and bundler settings, taking the whole site down.
Lesson. Before putting a proxy in front of something, verify the destination returns 200. And read the healthy sibling rule next to the broken one — the answer is often already in the same file.
→ Rewrite proxies and upstream redirect loops
2. A Korean input bug is about commit timing, not the value
The situation. In a hand-editable input field, typing Korean and then clicking elsewhere or pressing Enter saved the value before the last character finished forming.
The core idea. Korean, Japanese, and Chinese have a composition phase in which several keystrokes become one character. If blur or Enter runs the commit (the confirming save) before that phase ends, the value on screen and the value saved diverge. More annoying still, the arrival order of compositionend, keydown, and blur differs across browsers. Fix it by assuming the order in one environment and it recurs verbatim in another.
What I did. I dropped the option of adopting a rich-text editor and kept the native textarea — the value is a plain string, and layering an editor on top leaves the IME problem while adding a tier. I held "am I composing right now?" as state driven by the composition start/end signals, so a blur during composition does not save immediately but is queued and saved once composition ends. Enter during composition is ignored too. I added no string-level tidying (de-duplicating characters and so on) — that would also destroy input where the user genuinely typed the same character twice.
Lesson. Do not assume event order; decide from state. And do not misdiagnose a boundary problem as a value problem.
→ IME composition and the commit boundary
3. Shrinking a bundle is moving a boundary, not deleting code
The situation. Even low-interaction routes like lists and detail pages were downloading the same amount of JS because of a shared provider and header.
The core idea. "use client" is not a marker that makes one file client-side; it is a boundary that ships the entire import graph rooted at that file to the browser. Casually pulling in one heavy library makes every page under that boundary share the cost. Putting the provider at the top level is the same thing — convenience, not a default.
What I did. I pushed the data-cache provider down into the infinite-list feature that actually uses it, and split out only the login-state-dependent part of the header as a separate client fragment. The heavy relationship graph now goes through a loader that fetches it on demand. Verification came from unit tests plus the existing E2E suite.
Lesson. Draw the boundary at the innermost point where interaction is genuinely needed. Push it down into the route that uses it and every other route gets lighter for free.
4. Don't guess the ratio — read it from the source
The situation. A tall screenshot in a post appeared cropped. The fixed-ratio fallback I had added to prevent layout jitter was damaging content this time.
The core idea. An image without dimensions has zero height before it loads, so arriving late it pushes the content below it down (cumulative layout shift). The common response, aspect-ratio: 16/9, stops the jitter but guesses the ratio, so it eats portrait images. If the source file is local there is no reason to guess — opening the file at build time and reading the real dimensions costs nothing at runtime and requires no network request.
What I did. I hooked into the markdown-to-HTML step with a plugin that injects local images' real dimensions as width/height. External URLs are left alone. The styles give height: auto only to dimensioned images so they draw at their true ratio, and images whose dimensions could not be obtained keep the existing crop fallback. A unit test pins down "attached for local, untouched for external."
Lesson. Do not delete the existing fallback when adding a new path. And do not defer to the user's device a computation you can do at build time.
5. Aside — a CLI that exits 0 and does nothing
A globally installed CLI finished with exit code 0, no output, no artifact. The cause: the npm global bin is a symbolic link, so the path-comparison guard that decides "was I run directly?" silently became false. Running the real path directly worked fine. This failure mode, with not a single failure signal, is the most dangerous kind.
→ Symbolic links and the direct-run guard