<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:fh="http://purl.org/syndication/history/1.0"><channel><title>Dulak | Notes</title><description>Deliberately boring full-stack boilerplate: Hono + bun:sqlite + Inertia v3 on Bun, with React/Svelte/Vue templates (vanilla CSS or Tailwind).</description><link>https://dulak.pages.dev/</link><language>en</language><fh:complete/><atom:link rel="self" href="https://dulak.pages.dev/blog/rss.xml"/><item><title>Nginx is dead. Cloudflare killed it</title><link>https://dulak.pages.dev/blog/why-no-nginx/</link><guid isPermaLink="true">https://dulak.pages.dev/blog/why-no-nginx/</guid><description>Nginx terminates TLS, proxies, compresses, rate-limits, and serves static files. Cloudflare does all five at the edge — and Dulak handles the rest in-process. You don&apos;t need Nginx anymore.</description><pubDate>Sun, 09 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Every deployment guide for a web app has the same section. “Now install Nginx.” Then a config file. Then Certbot. Then a cron job to renew the cert. Then &lt;code dir=&quot;auto&quot;&gt;proxy_set_header&lt;/code&gt; lines you copy-paste without reading. Then you debug why &lt;code dir=&quot;auto&quot;&gt;X-Forwarded-For&lt;/code&gt; is wrong and your rate limiter thinks every user is the same IP.&lt;/p&gt;
&lt;p&gt;Nginx has been the default reverse proxy for 25 years. It’s good software. But if your domain is on Cloudflare — and in 2026, why wouldn’t it be — you’re installing Nginx to do five jobs that Cloudflare already does, for free, at 300+ edge locations worldwide.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-five-jobs-nginx-does&quot;&gt;The five jobs Nginx does&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;When you put Nginx in front of a Node/Bun app, it does these things:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;TLS termination&lt;/strong&gt; — HTTPS cert, renewal, cipher negotiation&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reverse proxy&lt;/strong&gt; — forward requests to the app port&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Gzip compression&lt;/strong&gt; — shrink responses before they hit the wire&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rate limiting&lt;/strong&gt; — &lt;code dir=&quot;auto&quot;&gt;limit_req_zone&lt;/code&gt;, burst queues, 429s&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Static file serving&lt;/strong&gt; — serve assets without hitting the app&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That’s the Nginx config you maintain. Let’s go through each one.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;job-1-tls-termination&quot;&gt;Job 1: TLS termination&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Nginx + Certbot:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;server&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;   &lt;span&gt; &lt;/span&gt;&lt;/span&gt;&lt;span&gt;listen &lt;/span&gt;&lt;span&gt;80&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;   &lt;span&gt; &lt;/span&gt;&lt;/span&gt;&lt;span&gt;server_name &lt;/span&gt;&lt;span&gt;your-domain.com;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;location&lt;/span&gt;&lt;span&gt; / {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;       &lt;span&gt; &lt;/span&gt;&lt;/span&gt;&lt;span&gt;proxy_pass &lt;/span&gt;&lt;span&gt;http://127.0.0.1:4000;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;sudo&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;certbot&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;--nginx&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;-d&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;your-domain.com&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;# plus a cron job to renew&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;# plus cipher suite configuration&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;# plus HSTS headers&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Cloudflare: you click “Proxied” on your DNS record. TLS is terminated at the edge. The cert is issued, renewed, and rotated automatically. You pick “Flexible” or “Full” mode in the dashboard. That’s it. No Certbot, no renewal cron, no cipher negotiation.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;job-2-reverse-proxy&quot;&gt;Job 2: Reverse proxy&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Nginx:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;location&lt;/span&gt;&lt;span&gt; / {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;   &lt;span&gt; &lt;/span&gt;&lt;/span&gt;&lt;span&gt;proxy_pass &lt;/span&gt;&lt;span&gt;http://127.0.0.1:4000;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;   &lt;span&gt; &lt;/span&gt;&lt;/span&gt;&lt;span&gt;proxy_set_header &lt;/span&gt;&lt;span&gt;Host $&lt;/span&gt;&lt;span&gt;host&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;   &lt;span&gt; &lt;/span&gt;&lt;/span&gt;&lt;span&gt;proxy_set_header &lt;/span&gt;&lt;span&gt;X-Forwarded-For $&lt;/span&gt;&lt;span&gt;proxy_add_x_forwarded_for&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;   &lt;span&gt; &lt;/span&gt;&lt;/span&gt;&lt;span&gt;proxy_http_version &lt;/span&gt;&lt;span&gt;1.1&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Cloudflare: the edge proxies to your origin. Two options, neither requires a config file:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Proxied DNS&lt;/strong&gt; — an A record with the orange cloud. Traffic flows through Cloudflare to your server on port 4000. One firewall rule restricts port 4000 to Cloudflare IPs only.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cloudflare Tunnel&lt;/strong&gt; — &lt;code dir=&quot;auto&quot;&gt;cloudflared&lt;/code&gt; connects outbound. No open ports, no firewall rules, no public IP needed. Your server is invisible to the internet.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The &lt;code dir=&quot;auto&quot;&gt;proxy_set_header&lt;/code&gt; dance? Cloudflare passes &lt;code dir=&quot;auto&quot;&gt;Host&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;X-Forwarded-For&lt;/code&gt; through by default. If the Host header gets rewritten, one Origin Rule in the dashboard fixes it — no config file syntax to learn.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;job-3-gzip-compression&quot;&gt;Job 3: Gzip compression&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Nginx:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;gzip &lt;/span&gt;&lt;span&gt;on&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;gzip_types &lt;/span&gt;&lt;span&gt;text/plain text/css application/javascript application/json;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;gzip_min_length &lt;/span&gt;&lt;span&gt;1024&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Cloudflare: compresses responses at the edge with &lt;strong&gt;brotli or gzip&lt;/strong&gt;, automatically, based on the client’s &lt;code dir=&quot;auto&quot;&gt;Accept-Encoding&lt;/code&gt;. You don’t configure anything. It’s on by default.&lt;/p&gt;
&lt;p&gt;But here’s the thing — Dulak also compresses in-process. The &lt;code dir=&quot;auto&quot;&gt;compress.ts&lt;/code&gt; middleware gzips SSR HTML and asset bundles using &lt;code dir=&quot;auto&quot;&gt;node:zlib&lt;/code&gt;:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// Only /assets/* (js/css bundles) and SSR HTML are compressed —&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// API/JSON responses pass through untouched.&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;!&lt;/span&gt;&lt;span&gt;c&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;req&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;path&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;startsWith&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;/assets/&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;&amp;#x26;&amp;#x26;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;!&lt;/span&gt;&lt;span&gt;IS_HTML&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;test&lt;/span&gt;&lt;span&gt;(type)) &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;So even without Cloudflare, Dulak handles compression. With Cloudflare, responses are double-optimized: the origin gzips, the edge can re-compress or serve as-is. No Nginx needed for either layer.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;job-4-rate-limiting&quot;&gt;Job 4: Rate limiting&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Nginx:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;limit_req_zone &lt;/span&gt;&lt;span&gt;$&lt;/span&gt;&lt;span&gt;binary_remote_addr&lt;/span&gt;&lt;span&gt; zone=api:10m rate=10r/s;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;location&lt;/span&gt;&lt;span&gt; / {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;   &lt;span&gt; &lt;/span&gt;&lt;/span&gt;&lt;span&gt;limit_req &lt;/span&gt;&lt;span&gt;zone=api burst=20;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;   &lt;span&gt; &lt;/span&gt;&lt;/span&gt;&lt;span&gt;proxy_pass &lt;/span&gt;&lt;span&gt;http://127.0.0.1:4000;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Dulak has its own rate limiter — in-memory, zero dependencies, in &lt;code dir=&quot;auto&quot;&gt;rate-limit.ts&lt;/code&gt;:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;function&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;clientKey&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;request&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Request&lt;/span&gt;&lt;span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;server&lt;/span&gt;&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;BunServer&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;|&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;null&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;forwarded&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;request&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;headers&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;get&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;x-forwarded-for&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (forwarded) &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; forwarded&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;split&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)[&lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span&gt;!.&lt;/span&gt;&lt;span&gt;trim&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;ip&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;server&lt;/span&gt;&lt;span&gt;?.&lt;/span&gt;&lt;span&gt;requestIP&lt;/span&gt;&lt;span&gt;?.&lt;/span&gt;&lt;span&gt;(request)&lt;/span&gt;&lt;span&gt;?.&lt;/span&gt;&lt;span&gt;address&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; ip &lt;/span&gt;&lt;span&gt;??&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;local&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Two layers: a global limiter (DDoS baseline, 200 req/min per IP) and a stricter one on auth endpoints (30 req/min, brute-force protection). Cloudflare sits in front with its own DDoS protection and rate limiting rules — so by the time traffic reaches Dulak, the volumetric attacks are already filtered. The in-app limiter handles the application-layer abuse that Cloudflare can’t see (brute-force login attempts, etc).&lt;/p&gt;
&lt;p&gt;Nginx’s &lt;code dir=&quot;auto&quot;&gt;limit_req&lt;/code&gt; sits awkwardly in the middle — it can’t see application semantics, and it’s redundant with Cloudflare for volumetric protection. Dulak’s limiter sees the actual routes and can differentiate &lt;code dir=&quot;auto&quot;&gt;/login&lt;/code&gt; from &lt;code dir=&quot;auto&quot;&gt;/dashboard&lt;/code&gt;. Nginx can’t.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;job-5-static-file-serving&quot;&gt;Job 5: Static file serving&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Nginx:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;location&lt;/span&gt;&lt;span&gt; /assets/ {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;   &lt;span&gt; &lt;/span&gt;&lt;/span&gt;&lt;span&gt;alias &lt;/span&gt;&lt;span&gt;/var/www/dulak/dist/;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;   &lt;span&gt; &lt;/span&gt;&lt;/span&gt;&lt;span&gt;expires &lt;/span&gt;&lt;span&gt;1y;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;   &lt;span&gt; &lt;/span&gt;&lt;/span&gt;&lt;span&gt;add_header &lt;/span&gt;&lt;span&gt;Cache-Control &lt;/span&gt;&lt;span&gt;&quot;public, immutable&quot;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Dulak serves its own assets. &lt;code dir=&quot;auto&quot;&gt;Bun.serve&lt;/code&gt; handles static files directly — the asset pipeline in &lt;code dir=&quot;auto&quot;&gt;assets.ts&lt;/code&gt; builds, fingerprints, and serves bundles with correct cache headers. No &lt;code dir=&quot;auto&quot;&gt;alias&lt;/code&gt; directive, no &lt;code dir=&quot;auto&quot;&gt;root&lt;/code&gt; path to keep in sync with your build output.&lt;/p&gt;
&lt;p&gt;And Cloudflare caches static assets at the edge automatically. The first request hits your origin; subsequent requests are served from the nearest Cloudflare location. You don’t configure cache rules for fingerprinted assets — the &lt;code dir=&quot;auto&quot;&gt;immutable&lt;/code&gt; cache header does it for you.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-you-actually-deploy&quot;&gt;What you actually deploy&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Without Nginx, the deployment is:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;Internet → Cloudflare edge (TLS, DDoS, cache, brotli) → your-server:4000&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;                                                            &lt;/span&gt;&lt;/span&gt;&lt;span&gt;└→ Bun.serve (app + assets + gzip + rate limit)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;                                                                 &lt;/span&gt;&lt;/span&gt;&lt;span&gt;└→ SQLite + uploads&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;One process. One port. No proxy config file. No cert renewal. No &lt;code dir=&quot;auto&quot;&gt;nginx -t &amp;#x26;&amp;#x26; systemctl reload nginx&lt;/code&gt; after every config change.&lt;/p&gt;
&lt;p&gt;The VPS deployment is a systemd unit:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;[Service]&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;ExecStart&lt;/span&gt;&lt;span&gt;=/home/dulak/.bun/bin/bun run src/index.ts&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;Restart&lt;/span&gt;&lt;span&gt;=always&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Plus a DNS record with the orange cloud. That’s the entire production setup. No Nginx package, no Nginx user, no Nginx logs to rotate, no Nginx security updates to track.&lt;/p&gt;
&lt;p&gt;With Cloudflare Tunnel, it’s even simpler — no open ports, no firewall rules, no public IP. The tunnel connects outbound and Cloudflare routes traffic to it. Your server is a dark node that only talks to Cloudflare.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;fewer-layers-fewer-milliseconds&quot;&gt;Fewer layers, fewer milliseconds&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Every hop in your stack adds latency. With Nginx in the middle, a request travels:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;User → Cloudflare edge → Nginx (on your server) → Bun&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;That Nginx hop is a &lt;code dir=&quot;auto&quot;&gt;proxy_pass&lt;/code&gt; to &lt;code dir=&quot;auto&quot;&gt;127.0.0.1:4000&lt;/code&gt; — a TCP connection, header parsing, and a second process wake-up, all on the same machine. It’s not a network round-trip across the country, but it’s not free either: Nginx adds ~0.5-1ms per request on a typical VPS, plus memory for worker processes, plus context switches between Nginx and Bun.&lt;/p&gt;
&lt;p&gt;Remove Nginx and the path becomes:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;User → Cloudflare edge → Bun&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;One fewer process. One fewer hop. One fewer allocation path. The request lands in Bun directly, and Bun handles TLS (if Full mode), routing, compression, and the app — all in one process. For a page that makes 5 API calls, that’s 5× the per-request overhead removed. On a budget VPS where CPU is scarce, that’s real.&lt;/p&gt;
&lt;p&gt;The deeper point: every layer you add is a layer that can break, misconfigure, or silently rewrite headers. Nginx rewriting &lt;code dir=&quot;auto&quot;&gt;Host&lt;/code&gt; or mangling &lt;code dir=&quot;auto&quot;&gt;X-Forwarded-For&lt;/code&gt; is a classic deployment bug. Cloudflare + Bun is two layers that talk directly. Fewer moving parts, fewer midnight debugging sessions.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-cert-renewal-cron-you-deleted&quot;&gt;The cert renewal cron you deleted&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Certbot installs a systemd timer that renews certs every 12 hours and reloads Nginx. If renewal fails — Let’s Encrypt rate limit, expired account, DNS change — your site goes dark in 90 days. You won’t notice until it does.&lt;/p&gt;
&lt;p&gt;Cloudflare’s edge certs are managed by Cloudflare. You don’t see them, you don’t renew them, you don’t reload anything. If you want an origin cert for Full mode, Cloudflare issues it from their CA for free, with a 15-year expiry. No cron, no timer, no 3am pager alert because Let’s Encrypt had an outage.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;when-you-still-need-nginx&quot;&gt;When you still need Nginx&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Nginx isn’t dead for everyone. It has legitimate use cases:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;You’re not on Cloudflare&lt;/strong&gt; — some teams can’t or won’t use a third-party proxy. Nginx + Certbot is the self-hosted path. Dulak’s &lt;a href=&quot;https://dulak.pages.dev/deployment/reverse-proxy/&quot;&gt;reverse proxy guide&lt;/a&gt; covers it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;You need complex routing&lt;/strong&gt; — multiple upstreams, weighted load balancing, path-based routing to different apps. Nginx is a real load balancer; Cloudflare’s free tier isn’t.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;You’re serving huge static files directly&lt;/strong&gt; — if you’re a CDN backend serving terabytes of video, Nginx’s &lt;code dir=&quot;auto&quot;&gt;sendfile&lt;/code&gt; and &lt;code dir=&quot;auto&quot;&gt;aio&lt;/code&gt; are tuned for that. Dulak serves fingerprinted JS/CSS bundles, not media.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Regulatory constraints&lt;/strong&gt; — some jurisdictions require all infrastructure to be self-hosted. Cloudflare’s edge is outside your control.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of these apply to a typical Dulak deployment: a single-process app on a VPS, domain on Cloudflare, SQLite on disk. For that setup, Nginx is a dependency that duplicates work Cloudflare and Bun already do.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-short-version&quot;&gt;The short version&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Nginx terminates TLS, proxies, compresses, rate-limits, and serves static files. Cloudflare does the first four at the edge. Dulak does the last three in-process. There’s nothing left for Nginx to do.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;You’re maintaining a config file, a cert renewal cron, and a package to track — for a proxy that proxies to a proxy. Delete the middleman. Point Cloudflare at Bun and go home.&lt;/p&gt;
&lt;p&gt;Read the full Cloudflare setup in the &lt;a href=&quot;https://dulak.pages.dev/deployment/reverse-proxy/&quot;&gt;reverse proxy guide&lt;/a&gt; — Option A1 (Proxied DNS + Origin Rule) is the recommended path.&lt;/p&gt;</content:encoded><category>deployment</category><category>cloudflare</category><category>nginx</category><category>philosophy</category></item><item><title>bcrypt is from 1999. Your passwords deserve better</title><link>https://dulak.pages.dev/blog/why-argon2id-not-bcrypt/</link><guid isPermaLink="true">https://dulak.pages.dev/blog/why-argon2id-not-bcrypt/</guid><description>bcrypt was designed in 1999 for a world without GPU mining. argon2id won the 2015 Password Hashing Competition by making GPU attacks economically impractical through memory-hardness. Dulak uses it.</description><pubDate>Sat, 08 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Every password hashing comparison reaches the same question: “bcrypt works fine, why change?”&lt;/p&gt;
&lt;p&gt;It does work fine. Nobody is saying bcrypt is broken. But “not broken” and “the right choice for a new project in 2026” are different things. argon2id is the latter.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;bcrypt-was-designed-for-1999&quot;&gt;bcrypt was designed for 1999&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;bcrypt was published in 1999 by Niels Provos and David Mazières. It was a significant improvement over MD5 and DES-based crypt — the Blowfish-based key schedule made it expensive to brute-force, and the adaptive cost factor meant you could increase iterations as hardware got faster.&lt;/p&gt;
&lt;p&gt;Here’s the thing: in 1999, a “fast computer” was a 500 MHz Pentium III. GPUs didn’t exist as general-purpose compute devices. ASICs were a concept from the semiconductor industry, not a consumer product. bcrypt’s threat model was “an attacker with a lot of CPUs.” It solved that problem well.&lt;/p&gt;
&lt;p&gt;Then in 2010, Nvidia launched CUDA, and everything changed. A modern GPU can perform billions of SHA-256 hashes per second. Bitcoin mining built an entire industry around ASICs that hash at hundreds of terahashes per second. The threat landscape shifted from “many CPUs” to “specialized hardware that’s orders of magnitude faster and cheaper per hash.”&lt;/p&gt;
&lt;p&gt;bcrypt is CPU-hard but not memory-hard. It needs a few kilobytes of memory per hash. A GPU with 24 GB of VRAM can run thousands of bcrypt hashes in parallel without breaking a sweat. The cost factor slows it down, but you’re fighting a hardware arms race by turning a dial — and the dial has a ceiling, because every login also pays that cost.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;argon2id-was-designed-for-2015&quot;&gt;argon2id was designed for 2015&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;argon2id is the winner of the &lt;a href=&quot;https://www.password-hashing.net/&quot;&gt;Password Hashing Competition (PHC)&lt;/a&gt; — a 2013–2015 academic competition that attracted 24 submissions from cryptographers worldwide, evaluated by a panel including Bruce Schneier and Colin Percival. It was designed from the ground up to resist the threat landscape that bcrypt couldn’t anticipate: GPU and ASIC attacks.&lt;/p&gt;
&lt;p&gt;The key innovation is &lt;strong&gt;memory-hardness&lt;/strong&gt;. argon2id requires a configurable amount of RAM to compute a single hash. Not a few kilobytes — tens of megabytes. This doesn’t matter on a server doing one login at a time. It matters enormously on a GPU.&lt;/p&gt;
&lt;p&gt;A GPU is fast because it has thousands of parallel cores. But it has limited memory bandwidth. If each hash requires 19 MiB of memory, a GPU with 24 GB of VRAM can only run ~1,200 hashes in parallel — and the memory bandwidth becomes the bottleneck, not the compute cores. The GPU’s entire advantage (massive parallelism) is neutralized.&lt;/p&gt;
&lt;p&gt;ASICs are worse off. You can build a chip that computes bcrypt hashes at ludicrous speed because bcrypt needs almost no memory. Building an ASIC with 19 MiB of fast SRAM per hash core is economically impractical — the silicon cost per hash skyrockets. Memory-hardness turns a compute problem into a memory problem, and memory is expensive in silicon.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;three-knobs-not-one&quot;&gt;Three knobs, not one&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;bcrypt has one tunable parameter: the cost factor (iterations). You turn it up as hardware gets faster. That’s it.&lt;/p&gt;
&lt;p&gt;argon2id has three:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Memory cost&lt;/strong&gt; — how much RAM each hash requires. Measured in KiB. This is the primary defense against GPUs and ASICs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Time cost&lt;/strong&gt; — how many iterations the memory-hard function runs. More iterations = more passes over the memory buffer.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Parallelism&lt;/strong&gt; — how many parallel lanes the computation uses. This lets you tune for the number of cores on your server.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This means you can tune argon2id for the hardware you actually run on. If your server has 4 cores and 4 GB of RAM, you pick parameters that make login take ~100ms without starving other processes. If you’re on a 32-core machine with 64 GB, you can afford higher memory cost. bcrypt’s single dial can’t express “use more memory” at all — it doesn’t have that concept.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-dulak-actually-uses&quot;&gt;What Dulak actually uses&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Here’s the implementation in &lt;code dir=&quot;auto&quot;&gt;src/server/auth.ts&lt;/code&gt;:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;export const &lt;/span&gt;&lt;span&gt;hashPassword&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;password&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; =&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;Bun&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;password&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;hash&lt;/span&gt;&lt;span&gt;(password&lt;/span&gt;&lt;span&gt;, {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;algorithm: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;argon2id&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;memoryCost: &lt;/span&gt;&lt;span&gt;19456&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;timeCost: &lt;/span&gt;&lt;span&gt;2&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;That’s it. No abstraction, no wrapper class, no config file. &lt;code dir=&quot;auto&quot;&gt;Bun.password.hash&lt;/code&gt; is built into Bun — zero dependencies.&lt;/p&gt;
&lt;p&gt;The parameters:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;memoryCost: 19456&lt;/strong&gt; — 19 MiB per hash. This matches the &lt;a href=&quot;https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html&quot;&gt;OWASP-recommended&lt;/a&gt; argon2id configuration (m=19456, t=2, p=1 as one of two accepted configurations).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;timeCost: 2&lt;/strong&gt; — two passes over the memory buffer. OWASP recommends 1–2; Dulak uses 2 for a small additional safety margin.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;parallelism: 1&lt;/strong&gt; (Bun’s default when not specified) — single-lane computation, appropriate for a web server where logins are sequential per request.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Verification is equally simple:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;export const &lt;/span&gt;&lt;span&gt;verifyPassword&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;password&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;hash&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; =&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;Bun&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;password&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;verify&lt;/span&gt;&lt;span&gt;(password&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;hash);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The algorithm and parameters are encoded in the hash string itself (&lt;code dir=&quot;auto&quot;&gt;$argon2id$v=19$m=19456,t=2,p=1$...&lt;/code&gt;), so verification doesn’t need to be told which parameters were used — it reads them from the hash. This means you can change parameters later and old hashes still verify correctly. You re-hash on next login.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-cost-in-practice&quot;&gt;The cost in practice&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;19 MiB of memory per hash means: if 10 users log in simultaneously, that’s 190 MiB of RAM. On a server with even 1 GB of memory, this is a non-issue. On a $5 VPS with 512 MB, it’s still fine — logins are rare events, not per-request operations.&lt;/p&gt;
&lt;p&gt;The time cost: with these parameters, a single argon2id hash takes roughly 50–100ms on modern hardware. That’s the &lt;em&gt;point&lt;/em&gt; — password hashing should be slow. A 100ms login delay is invisible to a user and devastating to an attacker trying billions of combinations. bcrypt at cost factor 12 takes a similar amount of time. The difference is that argon2id’s 100ms also consumed 19 MiB of memory, while bcrypt’s 100ms consumed ~4 KB.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;when-bcrypt-is-fine&quot;&gt;When bcrypt is fine&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Let’s be clear: &lt;strong&gt;if you have an existing system with bcrypt, do not panic-migrate.&lt;/strong&gt; bcrypt at cost factor 12+ is still resistant to offline attacks with reasonable password entropy. The sky is not falling. Your bcrypt hashes are not “insecure” — they’re just not optimal.&lt;/p&gt;
&lt;p&gt;The pragmatic approach for existing systems:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Keep bcrypt for existing hashes.&lt;/li&gt;
&lt;li&gt;Use argon2id for new passwords.&lt;/li&gt;
&lt;li&gt;When a user with a bcrypt hash logs in, verify with bcrypt, then re-hash with argon2id and update the stored hash.&lt;/li&gt;
&lt;li&gt;Over time, your user base migrates naturally — no forced password resets, no downtime.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This is called &lt;strong&gt;password hash migration on login&lt;/strong&gt;, and it’s the standard recommendation. You don’t need a “migration project.” You need one &lt;code dir=&quot;auto&quot;&gt;if&lt;/code&gt; statement in your verify path.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;why-not-just-use-bcrypt-with-a-higher-cost-factor&quot;&gt;Why not just use bcrypt with a higher cost factor?&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Because turning up bcrypt’s cost factor is a losing game. Every doubling of the cost factor doubles login time for your users &lt;em&gt;and&lt;/em&gt; doubles the attacker’s cost per hash. But the attacker has a GPU with thousands of parallel cores — doubling their cost per hash barely matters when they’re running 10,000 in parallel. Meanwhile, your user is now waiting 400ms instead of 200ms for a login.&lt;/p&gt;
&lt;p&gt;argon2id’s memory cost scales differently. Increasing memory cost from 19 MiB to 64 MiB doesn’t just make each hash slower — it reduces the number of hashes a GPU can run in parallel by 3.4×, because VRAM is the constraint. You’re not just increasing the cost per hash; you’re reducing the attacker’s throughput. That’s a fundamentally different defense.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-short-version&quot;&gt;The short version&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;bcrypt for legacy. argon2id for new code.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;bcrypt was the right answer in 1999. It’s still &lt;em&gt;an&lt;/em&gt; answer in 2026. But if you’re starting a new project, there is no reason to pick the 1999 answer when the 2015 answer exists, is OWASP-recommended, is built into your runtime, and costs you one line of configuration.&lt;/p&gt;
&lt;p&gt;Dulak uses argon2id with OWASP-recommended parameters because the alternative is using a hashing algorithm designed before GPUs existed and pretending the last 15 years of hardware evolution didn’t happen.&lt;/p&gt;
&lt;p&gt;See the &lt;a href=&quot;https://dulak.pages.dev/auth/sessions-guards/&quot;&gt;auth and sessions guide&lt;/a&gt; for how password hashing fits into the full authentication flow — DB-backed sessions, CSRF protection, and route guards.&lt;/p&gt;</content:encoded><category>auth</category><category>security</category></item><item><title>JWT is the wrong default. Sessions are the right one</title><link>https://dulak.pages.dev/blog/why-sessions-not-jwt/</link><guid isPermaLink="true">https://dulak.pages.dev/blog/why-sessions-not-jwt/</guid><description>JWT is sold as stateless auth. In practice you need a revocation list — which is server state. Dulak uses DB-backed sessions: one row in SQLite, delete to revoke.</description><pubDate>Sat, 08 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Every auth discussion on the internet goes the same way. Someone says “just use JWT.” Someone else says “no, sessions.” Both sides write 3,000-word blog posts. Nobody ships.&lt;/p&gt;
&lt;p&gt;Dulak uses server-side sessions. Not because it’s trendy — because JWT doesn’t solve a problem Dulak has, and it creates several that sessions don’t have.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;jwt-cannot-be-revoked&quot;&gt;JWT cannot be revoked&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;This is the fundamental problem. A JWT is a self-contained token: the server signs it, hands it to the client, and the client sends it back on every request. The server verifies the signature and trusts the claims. There is no server-side record of the token.&lt;/p&gt;
&lt;p&gt;So what happens when a user clicks “log out”? The server… can’t do anything. The token is still valid until it expires. You can delete it from the client’s localStorage, but if someone copied it — and tokens are famously easy to steal from localStorage — they still have a valid session.&lt;/p&gt;
&lt;p&gt;The standard JWT answer to this is a &lt;strong&gt;revocation list&lt;/strong&gt;: a server-side set of revoked token IDs that you check on every request. Congratulations, you just invented sessions — except worse, because now you have JWT &lt;em&gt;and&lt;/em&gt; a database lookup &lt;em&gt;and&lt;/em&gt; a revocation list to manage.&lt;/p&gt;
&lt;p&gt;Dulak’s session revocation is one line of SQL:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;export&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;function&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;deleteSessionByToken&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;token&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;void&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;deleteSession&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;run&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;hashToken&lt;/span&gt;&lt;span&gt;(token));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;That’s a &lt;code dir=&quot;auto&quot;&gt;DELETE FROM sessions WHERE token_hash = ?&lt;/code&gt;. The row is gone. The next request with that cookie finds nothing and gets redirected to &lt;code dir=&quot;auto&quot;&gt;/login&lt;/code&gt;. Instant, unconditional, no allowlist or blocklist to maintain.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;refresh-token-rotation-is-a-rube-goldberg-machine&quot;&gt;Refresh token rotation is a Rube Goldberg machine&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;To work around the revocation problem, JWT-based systems invented refresh tokens. The access token is short-lived (15 minutes), and a separate refresh token is used to get new access tokens. This way, a stolen access token dies quickly.&lt;/p&gt;
&lt;p&gt;But now you need:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Token rotation&lt;/strong&gt;: every refresh produces a new refresh token, invalidating the old one.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reuse detection&lt;/strong&gt;: if the old refresh token is used again, it means someone stole it — so you revoke the entire token family.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Token families&lt;/strong&gt;: a chain of refresh tokens linked by a shared ID, so you can nuke them all on reuse detection.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A server-side store&lt;/strong&gt; to track which refresh tokens are valid.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You read that last bullet correctly. The “stateless” solution requires server-side state to work securely. The very thing JWT was supposed to eliminate.&lt;/p&gt;
&lt;p&gt;Dulak’s session “rotation” is: delete the old session row, insert a new one. This happens on login and registration as a session-fixation defense. Password changes go further — &lt;code dir=&quot;auto&quot;&gt;deleteOtherSessionsByToken&lt;/code&gt; wipes every session for the user except the current one:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;export&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;function&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;deleteOtherSessionsByToken&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;token&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;userId&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;number&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;void&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;deleteOtherSessions&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;run&lt;/span&gt;&lt;span&gt;(userId&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;hashToken&lt;/span&gt;&lt;span&gt;(token));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;One SQL statement. Every other device is signed out. No token families, no reuse detection, no refresh token flow to debug at 2am.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;jwt-payload-bloat&quot;&gt;JWT payload bloat&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A JWT has three parts: header, payload, signature — all base64-encoded and sent on every request. The payload contains whatever claims you put in it: user ID, roles, email, maybe permissions.&lt;/p&gt;
&lt;p&gt;A minimal JWT payload might be 200 bytes. Add roles, permissions, and metadata and you’re at 500+ bytes. Every. Single. Request. For a page that makes 5 API calls, that’s 2.5KB of token data flying across the wire — parsed, base64-decoded, and signature-verified on every call.&lt;/p&gt;
&lt;p&gt;Dulak’s session cookie carries a 64-character hex string: a 256-bit random token. That’s 64 bytes. The server looks up the session row, gets the &lt;code dir=&quot;auto&quot;&gt;user_id&lt;/code&gt;, and fetches the user. The user data lives server-side — it doesn’t travel with the request.&lt;/p&gt;
&lt;p&gt;And since Dulak uses SQLite with WAL mode, that lookup is a primary-key scan on an indexed table. The benchmark: &lt;strong&gt;52,000 reads per second&lt;/strong&gt; on a single Bun process. The session lookup is not your bottleneck.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-secret-rotation-headache&quot;&gt;The secret rotation headache&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;JWT requires a signing secret. If that secret leaks, every token ever issued with it is forgeable. So you need to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Store the secret securely (environment variable, secrets manager).&lt;/li&gt;
&lt;li&gt;Rotate it periodically (best practice, they say).&lt;/li&gt;
&lt;li&gt;Support multiple secrets during rotation (old tokens verified with old key, new tokens signed with new key).&lt;/li&gt;
&lt;li&gt;Decide what algorithm to use (HS256? RS256? what if someone downgrades to &lt;code dir=&quot;auto&quot;&gt;none&lt;/code&gt;?).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Session tokens are 256 bits of &lt;code dir=&quot;auto&quot;&gt;crypto.randomBytes&lt;/code&gt;. There is no secret. There is no algorithm. There is no rotation. You generate random bytes, hash them with SHA-256, store the hash. If you want to invalidate all sessions, you truncate the &lt;code dir=&quot;auto&quot;&gt;sessions&lt;/code&gt; table. No key management, no algorithm migration, no “alg: none” attack vector.&lt;/p&gt;
&lt;p&gt;Dulak’s token generation is three lines:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;export&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;function&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;createSession&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;userId&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;number&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;SessionInfo&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;token&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;randomBytes&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;32&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;toString&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;hex&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;expiresAt&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Date&lt;/span&gt;&lt;span&gt;(Date&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;now&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt; + &lt;/span&gt;&lt;span&gt;SESSION_TTL_MS&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;insertSession&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;run&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;hashToken&lt;/span&gt;&lt;span&gt;(token)&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; userId&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; expiresAt&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;toISOString&lt;/span&gt;&lt;span&gt;());&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; { token&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; expiresAt };&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;No signing key. No algorithm. No rotation ceremony. Random bytes in, hash out, row inserted.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-stateless-myth&quot;&gt;The “stateless” myth&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;JWT is marketed as “no server state.” This is the selling point. No session table, no database lookup, no server-side storage. Just sign and verify.&lt;/p&gt;
&lt;p&gt;Here’s what actually happens in production:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;You need revocation (log out, password change, compromised token) → you add a revocation list. &lt;strong&gt;Server state.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;You need to check if a user is banned or their role changed → you can’t trust the JWT payload anymore, so you do a database lookup. &lt;strong&gt;Server state.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;You need refresh token rotation → you track valid refresh tokens server-side. &lt;strong&gt;Server state.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;You end up with JWT &lt;em&gt;plus&lt;/em&gt; a database lookup &lt;em&gt;plus&lt;/em&gt; a revocation list. You have all the complexity of sessions and all the complexity of JWT. The “stateless” promise evaporated the moment you had a real product with real security requirements.&lt;/p&gt;
&lt;p&gt;Dulak skips the JWT layer entirely. Every request does one session lookup and one user lookup — both primary-key queries on SQLite, both sub-millisecond. That’s the same cost as the JWT revocation check, except the architecture is simpler and revocation actually works.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;but-why-not-cache-the-session-lookup&quot;&gt;“But why not cache the session lookup?”&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A reasonable question: if every request does two SQLite PK lookups, wouldn’t an in-memory cache make it faster? Benchmarked on an M4 with 100K users and 100K sessions in SQLite WAL mode:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;th&gt;Cost/req&lt;/th&gt;
&lt;th&gt;Ops/sec&lt;/th&gt;
&lt;th&gt;vs no cache&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No cache (hash + 2 PK scans)&lt;/td&gt;
&lt;td&gt;1.73µs&lt;/td&gt;
&lt;td&gt;578K&lt;/td&gt;
&lt;td&gt;baseline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full cache (token → user in a &lt;code dir=&quot;auto&quot;&gt;Map&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;0.35µs&lt;/td&gt;
&lt;td&gt;2.87M&lt;/td&gt;
&lt;td&gt;5× faster&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Session cache (token → session in a &lt;code dir=&quot;auto&quot;&gt;Map&lt;/code&gt;, still fetch user)&lt;/td&gt;
&lt;td&gt;0.95µs&lt;/td&gt;
&lt;td&gt;1.05M&lt;/td&gt;
&lt;td&gt;1.8× faster&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The full cache is 5× faster. It’s also a trap.&lt;/p&gt;
&lt;p&gt;Remember the stale-payload problem from the last section — “you can’t trust the JWT payload anymore” when a role changes or a user is banned? A &lt;code dir=&quot;auto&quot;&gt;token → user&lt;/code&gt; cache has the &lt;strong&gt;exact same problem&lt;/strong&gt;. The cached user row goes stale the moment an admin bans the account or changes a role. To stay correct you need cache invalidation: evict on logout, evict on role change, evict on ban, evict on password change, add a TTL safety net. That is a revocation list — the very complexity this architecture exists to avoid. You’d be trading 1.38µs per request for the bug surface JWT was rejected for.&lt;/p&gt;
&lt;p&gt;The session-only cache (1.8× faster) is the safe middle ground — user data stays fresh, only the session row is cached. But 0.77µs saved per request doesn’t justify the invalidation logic and memory overhead.&lt;/p&gt;
&lt;p&gt;And there’s a deeper point: SQLite with WAL mode already keeps hot B-tree pages in the OS page cache. The benchmark proves it — scaling from 10K to 100K rows added only 0.09µs (5.5%). If those lookups were disk-bound, 10× the data would be far slower. Near-constant cost means the pages are already in RAM. A JavaScript &lt;code dir=&quot;auto&quot;&gt;Map&lt;/code&gt; on top of the OS page cache is a cache on top of a cache, with invalidation burden and no real I/O to save.&lt;/p&gt;
&lt;p&gt;At 1.73µs per request, auth lookup consumes under 0.3% of a core at 1K req/sec. The bottleneck is network, TLS, SSR rendering, business logic — never the session row. The session lookup is not your bottleneck, and caching it would cost you the one property that makes sessions worth choosing over JWT: revocation that actually works.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;how-dulaks-sessions-actually-work&quot;&gt;How Dulak’s sessions actually work&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The full picture, from &lt;code dir=&quot;auto&quot;&gt;src/server/auth.ts&lt;/code&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Token&lt;/strong&gt;: 256-bit random bytes (&lt;code dir=&quot;auto&quot;&gt;randomBytes(32)&lt;/code&gt;), stored as a 64-char hex string in the cookie.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;At rest&lt;/strong&gt;: only the SHA-256 hash of the token is stored in the &lt;code dir=&quot;auto&quot;&gt;sessions&lt;/code&gt; table. A database leak cannot expose valid tokens — the raw token never touches disk.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cookie&lt;/strong&gt;: &lt;code dir=&quot;auto&quot;&gt;httpOnly&lt;/code&gt; (no JavaScript access), &lt;code dir=&quot;auto&quot;&gt;SameSite=Lax&lt;/code&gt; (CSRF baseline), &lt;code dir=&quot;auto&quot;&gt;Secure&lt;/code&gt; in production, 30-day expiry.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Lookup&lt;/strong&gt;: &lt;code dir=&quot;auto&quot;&gt;resolveUser(token)&lt;/code&gt; hashes the cookie token, finds the session row, lazily deletes expired sessions, and returns the user.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Revocation&lt;/strong&gt;: &lt;code dir=&quot;auto&quot;&gt;DELETE FROM sessions WHERE token_hash = ?&lt;/code&gt;. One row, one statement, done.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rotation&lt;/strong&gt;: new session on login/register (session-fixation defense). Password change wipes other devices.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Flash messages&lt;/strong&gt;: one-shot messages stored on the session row (&lt;code dir=&quot;auto&quot;&gt;sessions.flash&lt;/code&gt;), consumed on render. No separate flash store.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The schema is four columns:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;CREATE&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;TABLE&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;sessions&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;token_hash &lt;/span&gt;&lt;span&gt;TEXT&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;PRIMARY KEY&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;user_id    &lt;/span&gt;&lt;span&gt;INTEGER&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;NOT NULL&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;REFERENCES&lt;/span&gt;&lt;span&gt; users(id) &lt;/span&gt;&lt;span&gt;ON DELETE CASCADE&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;flash      &lt;/span&gt;&lt;span&gt;TEXT&lt;/span&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;NOT NULL&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;DEFAULT&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;{}&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;expires_at &lt;/span&gt;&lt;span&gt;TEXT&lt;/span&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;NOT NULL&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;created_at &lt;/span&gt;&lt;span&gt;TEXT&lt;/span&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;NOT NULL&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;DEFAULT&lt;/span&gt;&lt;span&gt; (strftime(&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;%Y-%m-%dT%H:%M:%fZ&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;now&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;))&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;That’s it. No token families, no refresh token table, no revocation list, no signing key rotation schedule.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;when-jwt-actually-makes-sense&quot;&gt;When JWT actually makes sense&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;JWT isn’t evil. It has legitimate use cases:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Federated identity / SSO&lt;/strong&gt;: when one service issues tokens that another service consumes, and the two services don’t share a database. JWT’s signed claims make sense here.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Short-lived authorization between microservices&lt;/strong&gt;: a service-to-service token that lives for 30 seconds and doesn’t need revocation.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;OAuth/OIDC&lt;/strong&gt;: Dulak uses Google OAuth, and OAuth uses JWT-style tokens internally. That’s fine — it’s a different problem domain.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of these are “user authentication in a single-process app with a database.” That’s Dulak’s problem, and sessions solve it with less code, less complexity, and revocation that actually works.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-short-version&quot;&gt;The short version&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Sessions are one row in SQLite. Delete the row, the session is gone.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;JWT gives you a signed token you can’t revoke, a payload that bloats every request, a secret you have to rotate, and a “stateless” architecture that needs server state the moment you ship a real product. Sessions give you a cookie, a database lookup, and a DELETE statement.&lt;/p&gt;
&lt;p&gt;Read the full session and guard implementation in the &lt;a href=&quot;https://dulak.pages.dev/auth/sessions-guards/&quot;&gt;sessions &amp;#x26; guards docs&lt;/a&gt;.&lt;/p&gt;</content:encoded><category>auth</category><category>security</category><category>philosophy</category></item><item><title>Laravel is good. Your next project should not use it</title><link>https://dulak.pages.dev/blog/why-leave-laravel/</link><guid isPermaLink="true">https://dulak.pages.dev/blog/why-leave-laravel/</guid><description>PHP was great in 2005. In 2026, TypeScript has won, Bun has won, and AI writes code in any language. The language barrier that kept you in PHP is gone — so which ecosystem gives you less infrastructure to manage?</description><pubDate>Sat, 08 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Laravel is a good framework. It’s well-documented, has a thriving ecosystem, and has shipped thousands of production apps. This is not a hit piece. This is a case for why your &lt;em&gt;next&lt;/em&gt; project should not be one of them.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-four-toolchain-tax&quot;&gt;The four-toolchain tax&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Laravel doesn’t run on one thing. It runs on four:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;PHP&lt;/strong&gt; — the language and runtime&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Composer&lt;/strong&gt; — the package manager&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A web server&lt;/strong&gt; — Nginx, Apache, or FrankenPHP (which is itself a Go binary embedding PHP)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A database driver&lt;/strong&gt; — PDO MySQL, PDO PostgreSQL, or the SQLite driver&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Each of these has its own version, its own configuration, its own upgrade cycle, and its own breaking changes. When you deploy a Laravel app, you’re maintaining four toolchains. When one of them releases a breaking change — PHP 8.4 deprecates something, Composer v3 changes resolution behavior, Nginx updates its config syntax — you’re debugging the intersection.&lt;/p&gt;
&lt;p&gt;Dulak runs on &lt;strong&gt;one runtime&lt;/strong&gt;: Bun. Bun is the HTTP server, the database engine (bun:sqlite), the bundler, the test runner, and the package manager. One install, one version, one upgrade. The web server is built in. The database is built in. There is no second toolchain to configure.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-ssr-tax&quot;&gt;The SSR tax&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Laravel’s Inertia SSR — the feature that lets you render React or Vue on the server — requires a &lt;strong&gt;separate Node.js process&lt;/strong&gt;. You’re running PHP &lt;em&gt;and&lt;/em&gt; Node to serve one application. Two runtimes, two process managers, two sets of environment variables, two things that can crash independently.&lt;/p&gt;
&lt;p&gt;Dulak renders SSR in the &lt;strong&gt;same Bun process&lt;/strong&gt; as the HTTP server and the database. &lt;code dir=&quot;auto&quot;&gt;renderToString&lt;/code&gt; runs inside the Hono handler, in the same event loop that serves the request. No second runtime, no IPC, no separate deployment. One process does everything.&lt;/p&gt;
&lt;p&gt;If you want Inertia SSR in Laravel, you need Node installed on your production server alongside PHP. If you want SSR in Dulak, you need nothing — it’s already running.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-orm-tax&quot;&gt;The ORM tax&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Eloquent is a full-featured ORM. It has model hydration, collections, query builders, relationships, scopes, accessors, mutators, and event dispatch. It’s impressive. It’s also 8× slower than raw SQL.&lt;/p&gt;
&lt;p&gt;The numbers, from the &lt;a href=&quot;https://dulak.pages.dev/database/performance/&quot;&gt;benchmark page&lt;/a&gt;:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Endpoint&lt;/th&gt;
&lt;th&gt;Dulak (Hono+Bun)&lt;/th&gt;
&lt;th&gt;Laravel (FrankenPHP, 8 workers)&lt;/th&gt;
&lt;th&gt;Dulak faster&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;GET /health&lt;/code&gt; (no DB)&lt;/td&gt;
&lt;td&gt;65,807 req/s&lt;/td&gt;
&lt;td&gt;6,237 req/s&lt;/td&gt;
&lt;td&gt;10.5×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;GET /users/:id&lt;/code&gt; (PK read)&lt;/td&gt;
&lt;td&gt;51,934 req/s&lt;/td&gt;
&lt;td&gt;6,391 req/s&lt;/td&gt;
&lt;td&gt;8.1×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;GET /users?limit=20&lt;/code&gt; (list)&lt;/td&gt;
&lt;td&gt;32,222 req/s&lt;/td&gt;
&lt;td&gt;4,734 req/s&lt;/td&gt;
&lt;td&gt;6.8×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;DELETE /users/:id&lt;/code&gt; (delete)&lt;/td&gt;
&lt;td&gt;27,744 req/s&lt;/td&gt;
&lt;td&gt;5,655 req/s&lt;/td&gt;
&lt;td&gt;4.9×&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Laravel’s health check (6,237 req/s) and DB point read (6,391 req/s) are nearly identical — the database adds no measurable overhead because ~7ms of framework overhead dwarfs the ~0.1ms SQLite query. The framework is the bottleneck, not the database.&lt;/p&gt;
&lt;p&gt;Dulak uses raw SQL prepared statements. No model hydration, no collection wrapping, no event dispatch. The query plan is compiled once at module load and reused for every request. You see the exact SQL that runs. The AI writing your next feature writes it at peak performance — no abstraction layer to generate through.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;typescript-everywhere&quot;&gt;TypeScript everywhere&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Laravel developers write PHP on the server and JavaScript on the client. Two languages, two type systems, two mental models, two sets of tooling. Every feature requires context-switching between PHP and JS. Every shared type is manually synced — a &lt;code dir=&quot;auto&quot;&gt;User&lt;/code&gt; model in Eloquent and a &lt;code dir=&quot;auto&quot;&gt;User&lt;/code&gt; interface in TypeScript, kept in sync by hope and code review.&lt;/p&gt;
&lt;p&gt;Dulak is TypeScript end to end. Server, client, shared types — all in &lt;code dir=&quot;auto&quot;&gt;src/shared/types.ts&lt;/code&gt;. The &lt;code dir=&quot;auto&quot;&gt;User&lt;/code&gt; type that the database returns is the same &lt;code dir=&quot;auto&quot;&gt;User&lt;/code&gt; type the React component receives. No sync, no drift, no duplicate definitions.&lt;/p&gt;
&lt;p&gt;In 2026 where AI generates code, having one language means the AI is always in context. It doesn’t switch between PHP mode and JavaScript mode. It doesn’t forget the shape of a model because it was defined in a different language. One language, one type system, one context window.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-deployment-complexity&quot;&gt;The deployment complexity&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A production Laravel deployment looks like this:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Install PHP + Composer&lt;/li&gt;
&lt;li&gt;Install and configure a web server (Nginx or FrankenPHP)&lt;/li&gt;
&lt;li&gt;Configure PHP-FPM (or Octane for persistent workers)&lt;/li&gt;
&lt;li&gt;Run &lt;code dir=&quot;auto&quot;&gt;composer install --no-dev --optimize-autoloader&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Set up environment variables (&lt;code dir=&quot;auto&quot;&gt;.env&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Configure a queue worker (Supervisor + &lt;code dir=&quot;auto&quot;&gt;php artisan queue:work&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Configure a scheduler (&lt;code dir=&quot;auto&quot;&gt;cron&lt;/code&gt; + &lt;code dir=&quot;auto&quot;&gt;php artisan schedule:run&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;If you want SSR: install Node, configure the Inertia SSR server, manage it as a separate process&lt;/li&gt;
&lt;li&gt;Configure a reverse proxy to route to PHP-FPM or FrankenPHP&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A production Dulak deployment looks like this:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;bun&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;run&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;start&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Or with Docker:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;docker&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;compose&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;up&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;-d&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;One process. One binary. The queue worker, the scheduler, the SSR server — they don’t exist as separate things to manage. If you need a background job, you write a function and call it. If you need SSR, it’s already running. The entire deployment is one process that does everything.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-2026-angle&quot;&gt;The 2026 angle&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Here’s the argument that matters most: &lt;strong&gt;PHP was a great language for 2005.&lt;/strong&gt; It was easy to learn, easy to deploy (upload files to a server), and had a massive ecosystem. The language barrier — “I already know PHP” — kept developers in the Laravel ecosystem for decades.&lt;/p&gt;
&lt;p&gt;In 2026, that barrier is gone. AI writes code in whatever language you ask. TypeScript is as easy to generate as PHP. The question “but who will maintain the TypeScript?” has the same answer as “but who will maintain the PHP?” — the AI will, and it’s equally fluent in both.&lt;/p&gt;
&lt;p&gt;So the remaining question is: &lt;strong&gt;which ecosystem gives you less infrastructure to manage?&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Laravel: 4 toolchains, 2 languages, 2 runtimes for SSR, a queue worker, a scheduler, a web server, a database driver.&lt;/li&gt;
&lt;li&gt;Dulak: 1 runtime, 1 language, 1 process, 1 binary.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The answer is not subtle.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;when-to-stay-with-laravel&quot;&gt;When to stay with Laravel&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;This is not a “rewrite everything” argument. If you have:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;An existing Laravel app with years of code&lt;/strong&gt; — don’t rewrite. The switching cost is real, and Laravel is a fine framework for maintaining what you have.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A team deep in PHP expertise&lt;/strong&gt; — switching to TypeScript + Bun has a learning curve. If your team is fast and happy in Laravel, the productivity cost of switching may not be worth it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Laravel-specific packages you depend on&lt;/strong&gt; — Nova, Horizon, Telescope, Filament, Livewire. These are genuinely good tools with no Dulak equivalents. If your app is built on them, Dulak is not a drop-in replacement.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is about what to start &lt;em&gt;new&lt;/em&gt; projects in. When you’re choosing a stack for a greenfield app in 2026, the question is not “which framework do I know?” — AI knows all of them. The question is “which stack gives me the least infrastructure to wire, the fewest toolchains to maintain, and the fastest path from zero to business logic?”&lt;/p&gt;
&lt;p&gt;That’s Dulak.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-short-version&quot;&gt;The short version&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Laravel for existing apps. Dulak for new ones.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The numbers: &lt;a href=&quot;https://dulak.pages.dev/database/performance/&quot;&gt;8× slower on reads, 5× slower on writes&lt;/a&gt;. The toolchains: 4 vs 1. The runtimes for SSR: 2 vs 1. The languages: 2 vs 1. The deployment: 8 steps vs 1 command. See the &lt;a href=&quot;https://dulak.pages.dev/comparisons/&quot;&gt;full comparison&lt;/a&gt; for the detailed breakdown.&lt;/p&gt;</content:encoded><category>laravel</category><category>comparisons</category><category>philosophy</category></item><item><title>Next.js is the default. It should not be</title><link>https://dulak.pages.dev/blog/why-leave-nextjs/</link><guid isPermaLink="true">https://dulak.pages.dev/blog/why-leave-nextjs/</guid><description>Next.js is the default choice for React apps — and the most complex one. Node runtime, RSC boundaries, a framework that re-renders on both sides, and a deploy target that nudges you toward a proprietary cloud. In 2026, AI writes the code — so which stack leaves you the least to wire?</description><pubDate>Sat, 08 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Next.js is the default React framework. It has excellent docs, a massive ecosystem, and Vercel’s marketing machine behind it. If you type “React starter” into any search engine, Next.js is the answer. This is not a hit piece — it’s a case for why your &lt;em&gt;next&lt;/em&gt; React app should not be built on it.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-runtime-tax&quot;&gt;The runtime tax&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Next.js doesn’t run on one thing. It runs on several:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Node.js&lt;/strong&gt; — the runtime, version-managed separately from your code&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A package manager&lt;/strong&gt; — npm, yarn, or pnpm, each with its own lockfile format and resolution behavior&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A build pipeline&lt;/strong&gt; — webpack or Turbopack, with config files layered on top (&lt;code dir=&quot;auto&quot;&gt;next.config.js&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;tsconfig.json&lt;/code&gt;, PostCSS config, ESLint config, Tailwind config)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A deployment target&lt;/strong&gt; — Vercel’s serverless platform, or a self-hosted Node server you configure yourself&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Each of these has its own version, its own config, its own upgrade cycle. Next.js itself releases a new version every few months; each one can change the router, the build output, or the config format. When you upgrade, you’re not upgrading one thing — you’re upgrading the intersection of Node, the bundler, and the framework.&lt;/p&gt;
&lt;p&gt;Dulak runs on &lt;strong&gt;one runtime&lt;/strong&gt;: Bun. Bun is the HTTP server, the database engine (bun:sqlite), the bundler, the test runner, and the package manager. One install, one version, one upgrade. There is no second toolchain to configure.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-react-server-components-tax&quot;&gt;The React Server Components tax&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Next.js’s App Router is built on React Server Components. RSC is a genuinely clever idea — render components on the server, stream the result, hydrate on the client. But it comes with a mental-model tax that has no equivalent in a plain React app:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;&apos;use client&apos;&lt;/code&gt; boundaries.&lt;/strong&gt; Every component that touches state, effects, or browser APIs needs the directive. Every library that isn’t RSC-aware needs a wrapper. You spend real time deciding &lt;em&gt;which side&lt;/em&gt; of the boundary each component lives on — and re-deciding when you move a component and the tree breaks.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Two render passes.&lt;/strong&gt; The server renders the component tree, then the client hydrates it. Two executions of the same components, two chances for a mismatch, one invisible bug that only shows up in production.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Serialization limits.&lt;/strong&gt; Props crossing the server/client boundary must be serializable — no functions, no class instances, no &lt;code dir=&quot;auto&quot;&gt;Date&lt;/code&gt; in some cases. Data fetching looks local (&lt;code dir=&quot;auto&quot;&gt;await db.query()&lt;/code&gt; in a component) but isn’t, which confuses both humans and AI.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Dulak’s Inertia rendering has none of this. The server renders HTML with &lt;code dir=&quot;auto&quot;&gt;react-dom/server&lt;/code&gt;; the client calls &lt;code dir=&quot;auto&quot;&gt;hydrateRoot&lt;/code&gt; and attaches event listeners. One render pass, one component tree, no &lt;code dir=&quot;auto&quot;&gt;&apos;use client&apos;&lt;/code&gt;, no boundary to reason about. Props are the page payload — serializable by definition.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-framework-overhead-tax&quot;&gt;The framework overhead tax&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Next.js is a large framework. Before your component renders, the request travels through the Next.js router, the RSC runtime, the data cache, the client reference registry, and the streaming layer. That machinery has a measurable cost per request.&lt;/p&gt;
&lt;p&gt;Dulak’s HTTP layer is Hono — a zero-overhead router on top of Bun’s native server. The &lt;a href=&quot;https://dulak.pages.dev/database/performance/&quot;&gt;benchmark numbers&lt;/a&gt; show what framework overhead looks like when it’s small: the no-DB health check does 66K req/s, and a SQLite point read adds just 0.2ms. The framework is not the bottleneck — because there isn’t much framework.&lt;/p&gt;
&lt;p&gt;The Next.js equivalent: a framework that re-renders your components on two runtimes, with a build pipeline, a cache layer, and a middleware runtime — before your code even runs. Every request pays for all of it.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-deployment-tax&quot;&gt;The deployment tax&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Next.js deployment means choosing between two paths, both expensive:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Vercel (the default).&lt;/strong&gt; The frictionless path is the proprietary one. Your app runs on Vercel’s serverless functions, with Vercel’s caching, Vercel’s edge network, and Vercel’s pricing. Moving off later means rewriting the deployment — the “works on Vercel” configuration doesn’t transfer.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Self-hosted.&lt;/strong&gt; You configure a Node server, run &lt;code dir=&quot;auto&quot;&gt;next start&lt;/code&gt;, set up a process manager, a reverse proxy, environment variables, and the build pipeline. Static pages, ISR, and serverless functions each need their own serving strategy. This is a real project, not a command.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A production Dulak deployment is one command:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;bun&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;run&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;start&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Or with Docker:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;docker&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;compose&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;up&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;-d&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;One process, one binary, no platform to migrate off later. The app runs anywhere Bun runs — a VPS, a Docker host, a Raspberry Pi. There is no “deploy target” to escape.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-2026-angle&quot;&gt;The 2026 angle&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Here’s the argument that matters most: &lt;strong&gt;framework complexity is now pure cost.&lt;/strong&gt; In 2016, a framework like Next.js earned its complexity by making SSR accessible — you didn’t have to wire Webpack, Babel, and a server by hand. The abstraction was paying for itself.&lt;/p&gt;
&lt;p&gt;In 2026, AI writes the code. Wiring the bundler, configuring the router, and translating intent into &lt;code dir=&quot;auto&quot;&gt;&apos;use client&apos;&lt;/code&gt; boundaries are tasks an AI does — but it does them &lt;em&gt;around&lt;/em&gt; the framework, not because of it. The complexity doesn’t disappear; it becomes something the AI has to navigate in every session. Every Next.js-specific concept — RSC boundaries, serialization limits, cache semantics, middleware runtimes — is context the AI must load, reason about, and avoid tripping over.&lt;/p&gt;
&lt;p&gt;The question is no longer “what does this framework do for me?” It’s “how much does this framework make the AI maintain, and is that complexity earning its keep?” For Next.js, the answer is a lot of maintenance and a thin earning.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;when-to-stay-with-nextjs&quot;&gt;When to stay with Next.js&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;This is not a “rewrite everything” argument. If you have:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;An existing Next.js app with years of code&lt;/strong&gt; — don’t rewrite. The switching cost is real, and Next.js is a fine framework for maintaining what you have.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A team deep in the Next.js ecosystem&lt;/strong&gt; — if your team ships fast in the App Router, the productivity cost of switching may not be worth it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A need for the framework’s opinionated conventions&lt;/strong&gt; — file-based routing, &lt;code dir=&quot;auto&quot;&gt;next/image&lt;/code&gt; optimization, built-in fonts. These are genuinely useful. If your app leans on them, Next.js is not a bad choice.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This is about what to start &lt;em&gt;new&lt;/em&gt; projects in. When you’re choosing a stack for a greenfield app in 2026, the question is not “which framework has the most features?” — AI can wire any of them. The question is “which stack leaves me the least to configure, the fewest boundaries to reason about, and the fastest path from zero to business logic?”&lt;/p&gt;
&lt;p&gt;That’s Dulak: Hono + Inertia on Bun. One runtime, one render pass, one process, no &lt;code dir=&quot;auto&quot;&gt;&apos;use client&apos;&lt;/code&gt;.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-short-version&quot;&gt;The short version&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Next.js for existing apps. Dulak for new ones.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The runtimes: 2+ (Node + build pipeline) vs 1. The render passes: 2 (RSC) vs 1. The component boundaries: &lt;code dir=&quot;auto&quot;&gt;&apos;use client&apos;&lt;/code&gt; everywhere vs none. The deploy target: Vercel or self-hosted Node vs one binary. The complexity the AI must navigate: the entire framework vs one event loop.&lt;/p&gt;</content:encoded><category>comparisons</category><category>philosophy</category><category>performance</category></item><item><title>Node needs 5 tools. Bun ships one</title><link>https://dulak.pages.dev/blog/why-bun-not-node/</link><guid isPermaLink="true">https://dulak.pages.dev/blog/why-bun-not-node/</guid><description>Node needs five separate toolchains to ship a web app. Bun is one binary. Dulak picks the one that doesn&apos;t tax you from day one.</description><pubDate>Sat, 08 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Every Node.js project starts the same way: a &lt;code dir=&quot;auto&quot;&gt;package.json&lt;/code&gt;, then a dependency list that grows like a fungus. HTTP server, package manager, test runner, bundler, database driver — five separate tools, five separate config files, five things to keep in sync. By the time your app boots, you’ve spent an afternoon reading setup docs.&lt;/p&gt;
&lt;p&gt;Dulak runs on Bun. One binary. One runtime. Zero config files for the toolchain. Here’s why that matters.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-node-toolchain-tax&quot;&gt;The Node toolchain tax&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A production Node.js web app needs all of these:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concern&lt;/th&gt;
&lt;th&gt;Node tool&lt;/th&gt;
&lt;th&gt;Config file&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Package manager&lt;/td&gt;
&lt;td&gt;npm / pnpm / yarn&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;package.json&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;pnpm-workspace.yaml&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;yarn.lock&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTTP server&lt;/td&gt;
&lt;td&gt;Express / Fastify / native &lt;code dir=&quot;auto&quot;&gt;http&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;tsconfig.json&lt;/code&gt; (if TS), framework config&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test runner&lt;/td&gt;
&lt;td&gt;Jest / Vitest / Mocha&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;jest.config.js&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;vitest.config.ts&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bundler&lt;/td&gt;
&lt;td&gt;Webpack / Vite / esbuild&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;webpack.config.js&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;vite.config.ts&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database driver&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;pg&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;mysql2&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;better-sqlite3&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;connection pool config, env vars&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;That’s five tools. Five things to install. Five things to upgrade. Five things that can break independently. Five release cycles to track. Five opportunities for a breaking change to land on a Tuesday morning and ruin your sprint.&lt;/p&gt;
&lt;p&gt;Each one also brings its own dependency tree. &lt;code dir=&quot;auto&quot;&gt;jest&lt;/code&gt; alone pulls in 40+ packages. &lt;code dir=&quot;auto&quot;&gt;webpack&lt;/code&gt; has 60+ plugins you’ll need to configure. Your &lt;code dir=&quot;auto&quot;&gt;node_modules&lt;/code&gt; directory is a small village of code you didn’t write, can’t audit, and don’t control.&lt;/p&gt;
&lt;p&gt;This isn’t a knock on any individual tool. Express is fine. Jest is fine. Vite is fine. The problem is the &lt;em&gt;aggregation&lt;/em&gt; — five separate ecosystems, each with its own maintainers, release cadence, and opinions about how things should work. The more moving parts, the more ways they disagree.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;bun-is-one-binary&quot;&gt;Bun is one binary&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Bun ships everything in a single executable:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concern&lt;/th&gt;
&lt;th&gt;Bun built-in&lt;/th&gt;
&lt;th&gt;Config file&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Package manager&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;bun install&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;bun.lock&lt;/code&gt; (auto-generated)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTTP server&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Bun.serve()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test runner&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;bun test&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bundler&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;Bun.build()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database&lt;/td&gt;
&lt;td&gt;&lt;code dir=&quot;auto&quot;&gt;bun:sqlite&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;One install. One runtime. Zero config files for the toolchain itself. Dulak’s entire server entry point is this:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;server&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;Bun&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;serve&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;port&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;fetch: &lt;/span&gt;&lt;span&gt;createApp&lt;/span&gt;&lt;span&gt;(assets)&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;fetch&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;That’s it. No Express app factory, no middleware registration ceremony, no &lt;code dir=&quot;auto&quot;&gt;app.listen()&lt;/code&gt;. &lt;code dir=&quot;auto&quot;&gt;Bun.serve&lt;/code&gt; takes a port and a fetch handler. Hono provides the handler. The server is running.&lt;/p&gt;
&lt;p&gt;The database is equally simple:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; { Database } &lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;bun:sqlite&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;db&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Database&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;app.sqlite&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;No &lt;code dir=&quot;auto&quot;&gt;npm install better-sqlite3&lt;/code&gt;. No native compilation step. No &lt;code dir=&quot;auto&quot;&gt;node-gyp&lt;/code&gt;. &lt;code dir=&quot;auto&quot;&gt;bun:sqlite&lt;/code&gt; is compiled into the Bun binary — it’s a native binding to SQLite, not a JavaScript wrapper around a C library you have to build yourself.&lt;/p&gt;
&lt;p&gt;The test runner needs no config either:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; { describe, expect, it } &lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;bun:test&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;bun test&lt;/code&gt; discovers test files, runs them, reports results. No &lt;code dir=&quot;auto&quot;&gt;jest.config.js&lt;/code&gt;. No &lt;code dir=&quot;auto&quot;&gt;vitest.config.ts&lt;/code&gt;. No transform pipeline. Dulak’s 67-test E2E suite runs with &lt;code dir=&quot;auto&quot;&gt;bun test --isolate&lt;/code&gt; — one flag, one command, done.&lt;/p&gt;
&lt;p&gt;The bundler is the same story. Dulak builds client assets with &lt;code dir=&quot;auto&quot;&gt;Bun.build&lt;/code&gt;:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;result&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;Bun&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;build&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;entrypoints:&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;src/client/app.tsx&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;outdir: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;dist/assets&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;target: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;browser&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;No &lt;code dir=&quot;auto&quot;&gt;webpack.config.js&lt;/code&gt;. No &lt;code dir=&quot;auto&quot;&gt;vite.config.ts&lt;/code&gt;. No plugin chain. One function call, content-hashed output, done.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;performance-the-numbers&quot;&gt;Performance: the numbers&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Bun isn’t just simpler — it’s faster. Dulak’s &lt;a href=&quot;https://dulak.pages.dev/database/performance/&quot;&gt;full-stack benchmarks&lt;/a&gt; measure the complete HTTP path: Hono routing, &lt;code dir=&quot;auto&quot;&gt;bun:sqlite&lt;/code&gt; queries, JSON serialization, 50 concurrent workers.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Endpoint&lt;/th&gt;
&lt;th&gt;req/s&lt;/th&gt;
&lt;th&gt;p50 latency&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Health check (no DB)&lt;/td&gt;
&lt;td&gt;65,807&lt;/td&gt;
&lt;td&gt;0.76ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Point read (&lt;code dir=&quot;auto&quot;&gt;GET /users/:id&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;51,934&lt;/td&gt;
&lt;td&gt;0.82ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delete (&lt;code dir=&quot;auto&quot;&gt;DELETE /users/:id&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;27,744&lt;/td&gt;
&lt;td&gt;1.41ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The health check — pure HTTP, no database — hits &lt;strong&gt;66K req/s&lt;/strong&gt;. The database point read hits &lt;strong&gt;52K req/s&lt;/strong&gt;. The database adds only 0.2ms to a 0.76ms HTTP base. The framework is so lean that the database query is a visible fraction of total request time, not buried under framework overhead.&lt;/p&gt;
&lt;p&gt;For comparison, Laravel 13.24 on FrankenPHP (8 workers — the fastest production PHP server for Laravel) hits 6,237 req/s on the same health check. That’s &lt;strong&gt;10.5× slower&lt;/strong&gt; than Bun on the no-database endpoint. On the database point read, Laravel hits 6,391 req/s — &lt;strong&gt;8.1× slower&lt;/strong&gt;. The gap isn’t the database; it’s the runtime and framework overhead.&lt;/p&gt;
&lt;p&gt;Bun’s HTTP server is built on &lt;code dir=&quot;auto&quot;&gt;uSockets&lt;/code&gt; — a C library that handles TCP, TLS, and HTTP parsing at near-kernel speed. Node’s &lt;code dir=&quot;auto&quot;&gt;http&lt;/code&gt; module is JavaScript all the way down. The difference shows up in every request.&lt;/p&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;bun:sqlite&lt;/code&gt; has no driver overhead either. Node’s &lt;code dir=&quot;auto&quot;&gt;better-sqlite3&lt;/code&gt; is a native addon, but it still crosses a V8 ↔ native boundary per call. &lt;code dir=&quot;auto&quot;&gt;bun:sqlite&lt;/code&gt; is compiled into Bun’s binary — the SQLite C library and Bun’s JavaScript engine share the same process, same memory, same event loop. Zero serialization, zero copy, zero IPC.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;typescript-no-compilation-step&quot;&gt;TypeScript: no compilation step&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Node’s relationship with TypeScript in 2026 is… improving. Node 22.6+ ships &lt;code dir=&quot;auto&quot;&gt;--experimental-strip-types&lt;/code&gt;, which strips type annotations at runtime. It works, but it’s experimental, it doesn’t do type checking, and you’re still passing a flag.&lt;/p&gt;
&lt;p&gt;Before that, you needed &lt;code dir=&quot;auto&quot;&gt;ts-node&lt;/code&gt;, or &lt;code dir=&quot;auto&quot;&gt;tsx&lt;/code&gt;, or a build step with &lt;code dir=&quot;auto&quot;&gt;tsc&lt;/code&gt;, or a bundler that transpiles. Each option adds a dependency and a configuration decision. &lt;code dir=&quot;auto&quot;&gt;tsconfig.json&lt;/code&gt; alone is a 40-line file with 30 options, and if you get one wrong, your imports silently break.&lt;/p&gt;
&lt;p&gt;Bun runs TypeScript natively. No flag. No &lt;code dir=&quot;auto&quot;&gt;ts-node&lt;/code&gt;. No compilation step. No &lt;code dir=&quot;auto&quot;&gt;tsx&lt;/code&gt;. You write &lt;code dir=&quot;auto&quot;&gt;.ts&lt;/code&gt; files, you run &lt;code dir=&quot;auto&quot;&gt;bun src/index.ts&lt;/code&gt;, it works. Type checking is a separate concern — &lt;code dir=&quot;auto&quot;&gt;tsc --noEmit&lt;/code&gt; in CI — but the runtime doesn’t care about your types. It just runs the code.&lt;/p&gt;
&lt;p&gt;Dulak’s &lt;code dir=&quot;auto&quot;&gt;package.json&lt;/code&gt; has &lt;code dir=&quot;auto&quot;&gt;&quot;typecheck&quot;: &quot;tsc --noEmit&quot;&lt;/code&gt; as a separate script. Type errors are caught in CI, not at runtime. The runtime executes JavaScript; the types are a development-time contract. This is the correct separation of concerns — and Bun makes it the default, not something you configure.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-toolchain-tax-quantified&quot;&gt;The toolchain tax, quantified&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Let’s count the things you &lt;em&gt;don’t&lt;/em&gt; maintain when you pick Bun over Node for a Dulak-style app:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;No &lt;code dir=&quot;auto&quot;&gt;jest.config.js&lt;/code&gt;&lt;/strong&gt; — &lt;code dir=&quot;auto&quot;&gt;bun test&lt;/code&gt; has sensible defaults&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No &lt;code dir=&quot;auto&quot;&gt;vite.config.ts&lt;/code&gt;&lt;/strong&gt; or &lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;webpack.config.js&lt;/code&gt;&lt;/strong&gt; — &lt;code dir=&quot;auto&quot;&gt;Bun.build&lt;/code&gt; is a function call&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No &lt;code dir=&quot;auto&quot;&gt;better-sqlite3&lt;/code&gt; native build&lt;/strong&gt; — &lt;code dir=&quot;auto&quot;&gt;bun:sqlite&lt;/code&gt; is in the binary&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No &lt;code dir=&quot;auto&quot;&gt;ts-node&lt;/code&gt; / &lt;code dir=&quot;auto&quot;&gt;tsx&lt;/code&gt;&lt;/strong&gt; — Bun runs TypeScript directly&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No &lt;code dir=&quot;auto&quot;&gt;nodemon&lt;/code&gt;&lt;/strong&gt; — &lt;code dir=&quot;auto&quot;&gt;bun --watch&lt;/code&gt; is built in&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No &lt;code dir=&quot;auto&quot;&gt;cross-env&lt;/code&gt;&lt;/strong&gt; — Bun handles env vars consistently across platforms&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That’s six dependencies and six config files you never create. Six things that never break. Six upgrade cycles you never track. Six entries in your &lt;code dir=&quot;auto&quot;&gt;package.json&lt;/code&gt; that don’t exist.&lt;/p&gt;
&lt;p&gt;Dulak’s &lt;code dir=&quot;auto&quot;&gt;package.json&lt;/code&gt; has &lt;strong&gt;four runtime dependencies&lt;/strong&gt;: &lt;code dir=&quot;auto&quot;&gt;hono&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;@inertiajs/react&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;react&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;react-dom&lt;/code&gt;. Plus &lt;code dir=&quot;auto&quot;&gt;@sinclair/typebox&lt;/code&gt; for validation. That’s it. No HTTP framework config, no test runner config, no bundler config, no database driver. The toolchain &lt;em&gt;is&lt;/em&gt; the runtime.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;when-node-still-makes-sense&quot;&gt;When Node still makes sense&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;This isn’t a religious argument. Node has legitimate advantages:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Existing infrastructure&lt;/strong&gt; — if your company has Node deploys, monitoring, and on-call runbooks wired up, switching runtimes has a real cost. Don’t rewrite for the sake of it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Specific npm packages&lt;/strong&gt; — most npm packages work with Bun (Bun implements the Node API), but some don’t. If your app depends on a package that uses Node-specific internals Bun doesn’t support, Node is the safer choice.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Edge runtime requirements&lt;/strong&gt; — Cloudflare Workers, Vercel Edge, and Deno Deploy use V8-based or custom runtimes. If you’re deploying to the edge, Node compatibility matters more than Bun’s toolchain consolidation.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Team expertise&lt;/strong&gt; — if your team knows Node deeply and has no Bun experience, the learning curve is real. It’s small (Bun’s API is simpler), but it’s nonzero.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Dulak is a &lt;em&gt;starting point&lt;/em&gt;. If you’re starting fresh, Bun eliminates a class of problems you’d otherwise spend time solving. If you’re integrating into an existing Node ecosystem, the toolchain tax is already paid — and switching may not be worth it.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-short-version&quot;&gt;The short version&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Node is five toolchains pretending to be one. Bun is one binary that actually is one.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The Node ecosystem’s answer to “why do I need five tools?” is “because each one is best-in-class.” Maybe. But best-in-class tools that don’t know about each other still produce a config file per tool, a dependency tree per tool, and a breaking change per tool per quarter.&lt;/p&gt;
&lt;p&gt;Bun’s answer is: the runtime should be the toolchain. HTTP server, database, test runner, bundler, package manager, TypeScript — all one binary, all one install, all one upgrade. Dulak serves &lt;a href=&quot;https://dulak.pages.dev/database/performance/&quot;&gt;52K reads/s and 95K writes/s&lt;/a&gt; on a single process with four runtime dependencies. That’s what “no abstraction tax” looks like in practice.&lt;/p&gt;
&lt;p&gt;Pick Node if you have to. Pick Bun if you can.&lt;/p&gt;</content:encoded><category>bun</category><category>philosophy</category><category>architecture</category></item><item><title>ORMs are dead in 2026. AI killed them</title><link>https://dulak.pages.dev/blog/why-no-orm/</link><guid isPermaLink="true">https://dulak.pages.dev/blog/why-no-orm/</guid><description>ORMs were built to help humans avoid writing SQL. In 2026, AI generates correct, optimized raw SQL on demand — the problem ORMs solved is gone. What remains is the cost.</description><pubDate>Sat, 08 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Every boilerplate in the TypeScript ecosystem ships an ORM. Prisma, Drizzle, TypeORM, MikroORM — pick your poison. So when people see that Dulak has zero ORM dependencies and every query is raw SQL in &lt;code dir=&quot;auto&quot;&gt;db.ts&lt;/code&gt;, the reaction is predictable: “That doesn’t scale. You’ll regret this.”&lt;/p&gt;
&lt;p&gt;Let’s talk about why that’s wrong, and why the ORM question in 2026 is fundamentally different from the ORM question in 2015.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-problem-orms-solved-is-gone&quot;&gt;The problem ORMs solved is gone&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Here’s the thing nobody says out loud: ORMs were never about performance. They were about &lt;em&gt;human friction&lt;/em&gt;. Writing SQL by hand was painful — you had to know the syntax, remember the join types, get the escaping right, and deal with the fact that your database spoke a different language than your application code. ORMs let you write TypeScript instead of SQL, and that was genuinely valuable when the alternative was string-concatenating queries in a text editor.&lt;/p&gt;
&lt;p&gt;In 2026, that value proposition has collapsed. AI generates correct, optimized raw SQL on demand. You describe what you want — “get the 10 most recent users with their session count” — and you get back a query with the right joins, the right indexes, and the right &lt;code dir=&quot;auto&quot;&gt;LIMIT&lt;/code&gt;. The AI doesn’t need an ORM to translate your intent into SQL. It writes SQL directly, because SQL is the language the database actually speaks.&lt;/p&gt;
&lt;p&gt;And here’s the deeper point: this isn’t just about SQL. In 2026, code is AI-generated. The entire category of “abstractions that make humans more productive” — ORMs, query builders, template engines, utility CSS frameworks — exists to reduce human friction. When code is free to produce, that friction disappears. What’s left is the question you should have been asking all along: &lt;strong&gt;does this abstraction make my code faster, or slower?&lt;/strong&gt; ORMs make it slower. They add a translation layer between your intent and the database. In a world where AI writes raw SQL at peak performance, accepting that translation layer is paying a tax for a service nobody is rendering anymore.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-remains-is-the-cost&quot;&gt;What remains is the cost&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;An ORM is a dependency. Dependencies have a lifecycle, and it’s never free:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;You upgrade it.&lt;/strong&gt; Prisma had four major versions in four years. Each one changed the schema syntax, the query API, or the migration format. Every upgrade is a project.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;You audit it.&lt;/strong&gt; An ORM that generates SQL is a code generator running in your production process. When a query is slow, you have to figure out what SQL the ORM &lt;em&gt;actually generated&lt;/em&gt; — which means enabling query logging, reading the output, and mapping it back to the ORM method chain that produced it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;You debug it.&lt;/strong&gt; When the ORM’s eager-loading strategy produces an N+1 query (more on that below), you’re debugging two layers: your application logic and the ORM’s loading strategy. The ORM is a black box between you and the database.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of this is the ORM’s fault — it’s the inherent cost of an abstraction layer. The question is whether the abstraction earns its cost. In 2026, with AI writing your SQL, it doesn’t.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-n1-problem-is-an-orm-problem&quot;&gt;The N+1 problem is an ORM problem&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Here’s a pattern every ORM user has lived through:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// Looks fine. Runs fine in dev with 5 users.&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;users&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;User&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;findMany&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;for&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;user&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;of&lt;/span&gt;&lt;span&gt; users) {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;sessions&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;user&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;sessions&lt;/span&gt;&lt;span&gt;(); &lt;/span&gt;&lt;span&gt;// ← N+1&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Five users, five extra queries. In production with 10,000 users, that’s 10,001 queries for a single page load. The ORM made the wrong thing &lt;em&gt;easy&lt;/em&gt; — iterating over a relationship looks natural, so you do it without thinking about the query count.&lt;/p&gt;
&lt;p&gt;Every ORM has a fix for this — &lt;code dir=&quot;auto&quot;&gt;include&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;with&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;eager&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;populate&lt;/code&gt; — but the fix is ORM-specific syntax you have to learn, and the default behavior is always the dangerous one. The ORM optimizes for developer convenience at the call site, not for query efficiency at the database.&lt;/p&gt;
&lt;p&gt;Raw SQL makes joins explicit:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;SELECT&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;u&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;id&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;u&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;name&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;u&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;email&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;COUNT&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;s&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;token_hash&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;AS&lt;/span&gt;&lt;span&gt; session_count&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;FROM&lt;/span&gt;&lt;span&gt; users u&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;LEFT JOIN&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;sessions&lt;/span&gt;&lt;span&gt; s &lt;/span&gt;&lt;span&gt;ON&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;s&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;user_id&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;u&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;id&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;GROUP BY&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;u&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;id&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;ORDER BY&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;u&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;id&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;DESC&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;LIMIT&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;10&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;One query. You can see it’s one query because it &lt;em&gt;is&lt;/em&gt; one query. There’s no hidden loading strategy, no lazy evaluation, no “did I remember to eager-load this relationship?” The SQL is the query plan.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;migrations-explicit-vs-generated&quot;&gt;Migrations: explicit vs. generated&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;ORMs ship migration generators. You change your model file, run a command, and the ORM diffs the old schema against the new one and produces a migration. It sounds great until you see what it generates.&lt;/p&gt;
&lt;p&gt;ORM-generated migrations are conservative by design — they don’t know your intent, so they do safe things. Want to rename a column? The ORM sees “column dropped, column added” and generates a &lt;code dir=&quot;auto&quot;&gt;DROP COLUMN&lt;/code&gt; + &lt;code dir=&quot;auto&quot;&gt;ADD COLUMN&lt;/code&gt;. Your data is gone. You have to manually rewrite the migration to do &lt;code dir=&quot;auto&quot;&gt;ALTER TABLE ... RENAME COLUMN&lt;/code&gt;, assuming your database even supports it.&lt;/p&gt;
&lt;p&gt;Dulak’s migrations are hand-written SQL, versioned, and applied in a transaction. You can see the &lt;a href=&quot;https://dulak.pages.dev/database/schema-migrations/&quot;&gt;schema migration guide&lt;/a&gt; for the full pattern, but the shape is simple:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;-- migrations/0003_add_user_avatar.sql&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;ALTER&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;TABLE&lt;/span&gt;&lt;span&gt; users &lt;/span&gt;&lt;span&gt;ADD&lt;/span&gt;&lt;span&gt; COLUMN avatar_url &lt;/span&gt;&lt;span&gt;TEXT&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;That’s it. You wrote the SQL. You know what it does. The migration runner applies it in a transaction — if it fails, the database rolls back and the app doesn’t start. No generator to second-guess your intent, no auto-generated migration that drops a column you wanted to rename.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-dulak-actually-does&quot;&gt;What Dulak actually does&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Open &lt;code dir=&quot;auto&quot;&gt;src/server/db.ts&lt;/code&gt; and you’ll see the entire database layer. It’s prepared statements, prepared once at module load, typed with TypeScript generics:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;export const &lt;/span&gt;&lt;span&gt;findUserByEmail&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;db&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;query&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;UserRow&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt;SELECT id, name, email, password_hash AS passwordHash, role,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;          &lt;/span&gt;&lt;/span&gt;&lt;span&gt;google_id AS googleId, avatar_url AS avatarUrl,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;          &lt;/span&gt;&lt;/span&gt;&lt;span&gt;created_at AS createdAt&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;   &lt;/span&gt;&lt;/span&gt;&lt;span&gt;FROM users WHERE email = ?&lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;export const &lt;/span&gt;&lt;span&gt;createUser&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;db&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;query&lt;/span&gt;&lt;span&gt;&amp;#x3C;{ &lt;/span&gt;&lt;span&gt;id&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;number&lt;/span&gt;&lt;span&gt; }, &lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt;INSERT INTO users (name, email, password_hash) VALUES (?, ?, ?) RETURNING id&lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Every query in the application is defined here. The SQL is right there in the source — you can read it, copy it into a SQLite shell, run &lt;code dir=&quot;auto&quot;&gt;EXPLAIN QUERY PLAN&lt;/code&gt; on it, and see exactly what the database will do. No logging required, no query inspector, no “what did the ORM generate?” debugging session.&lt;/p&gt;
&lt;p&gt;The TypeScript generics give you type safety on the result shape and the parameter tuple. &lt;code dir=&quot;auto&quot;&gt;findUserByEmail&lt;/code&gt; takes a &lt;code dir=&quot;auto&quot;&gt;string&lt;/code&gt; and returns a &lt;code dir=&quot;auto&quot;&gt;UserRow&lt;/code&gt;. The compiler checks both. This is the same type safety an ORM provides — except it’s zero-dependency and the types describe the actual SQL, not an ORM model that &lt;em&gt;generates&lt;/em&gt; SQL.&lt;/p&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;bun:sqlite&lt;/code&gt; is synchronous, so there’s no &lt;code dir=&quot;auto&quot;&gt;await&lt;/code&gt; on every query, no promise chain, no event loop scheduling overhead. The query runs, returns, you move on. SQLite is in-process — the query is already done by the time you’d schedule a microtask. Async database drivers exist for network-attached databases where the query might take 50ms over the wire. That’s not this.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;when-an-orm-is-the-right-call&quot;&gt;When an ORM is the right call&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The old answers don’t survive contact with 2026. Here are the reasons ORMs were recommended, checked against the world AI created:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;“Large teams with varying SQL literacy.”&lt;/strong&gt; The ORM was a standardization layer for humans who didn’t know SQL. In 2026, humans aren’t writing the SQL — the AI is, and it writes SQL correctly in every dialect. A consistent query interface is now a code-review convention or a lint rule, not a runtime dependency. This argument collapses.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;“Switching databases mid-project.”&lt;/strong&gt; This was always weaker than it sounded: ORMs abstract &lt;em&gt;some&lt;/em&gt; dialect differences and leak the rest — types, migrations, transactions, indexes. In 2026 the switch is cheaper &lt;em&gt;without&lt;/em&gt; an ORM: “port &lt;code dir=&quot;auto&quot;&gt;db.ts&lt;/code&gt; to Postgres” and the AI rewrites the queries in the new dialect. Same task an ORM migration would have been, minus the ORM.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;“Dynamic query builders.”&lt;/strong&gt; This one has a kernel of truth — a search page with 15 optional filters does need composable WHERE clauses. But it needs a &lt;em&gt;query builder&lt;/em&gt; (kysely, or a 20-line helper that appends conditions), not an ORM. “Dynamic queries” justified the query builder, never the ORM. And in 2026, the AI can generate the conditional SQL itself.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of these apply to Dulak’s scope: a known schema, a single database, queries that don’t change shape at runtime. An ORM would be paying a tax for features we don’t use — and the one feature it was for (humans writing SQL) is no longer rendered by anyone.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-short-version&quot;&gt;The short version&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;ORMs solved the problem of humans writing SQL. AI writes SQL now. What’s left is the cost: a dependency, an abstraction layer, and a debugging black box between you and your data.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Dulak’s database layer is &lt;code dir=&quot;auto&quot;&gt;db.ts&lt;/code&gt; — prepared statements, typed with generics, zero dependencies. You see the SQL. The AI writing your next feature sees the SQL. The database sees the SQL. Everyone is looking at the same thing, and that thing is the actual query plan.&lt;/p&gt;
&lt;p&gt;When you outgrow SQLite and swap to Postgres, you replace &lt;code dir=&quot;auto&quot;&gt;db.ts&lt;/code&gt; with a Postgres driver and update the SQL syntax. No ORM migration, no model layer to rewrite, no abstraction to fight through. The &lt;a href=&quot;https://dulak.pages.dev/database/performance/&quot;&gt;performance numbers&lt;/a&gt; don’t change because the bottleneck was never the query layer — it was always the network hop you added by choosing a server database.&lt;/p&gt;</content:encoded><category>database</category><category>philosophy</category><category>ai</category></item><item><title>PM2 is a band-aid. Bun doesn&apos;t need it</title><link>https://dulak.pages.dev/blog/why-not-pm2/</link><guid isPermaLink="true">https://dulak.pages.dev/blog/why-not-pm2/</guid><description>PM2 is a Node process manager. Dulak runs on Bun. systemd and Docker already do everything PM2 would do — with zero extra dependencies.</description><pubDate>Sat, 08 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;If you’re coming from the Node.js ecosystem, PM2 is probably muscle memory. You deploy, you run &lt;code dir=&quot;auto&quot;&gt;pm2 start app.js&lt;/code&gt;, you move on. It works. So why doesn’t Dulak recommend it?&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;pm2-solves-a-node-problem&quot;&gt;PM2 solves a Node problem&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;PM2 was built to manage Node.js processes — restart on crash, handle logs, manage cluster mode. It’s a good tool &lt;em&gt;for Node&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;Dulak runs on Bun. Bun is not Node. Using PM2 to manage a Bun process is using a Node tool to manage a non-Node process. It works — PM2 can manage any process — but you’re carrying a dependency that was designed for a different runtime.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;systemd-already-does-this&quot;&gt;systemd already does this&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Every Linux server has systemd built in. No install, no dependency, no extra runtime. Dulak’s &lt;a href=&quot;https://dulak.pages.dev/deployment/vps/&quot;&gt;VPS deployment guide&lt;/a&gt; ships a systemd unit file that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Restarts the process on crash (&lt;code dir=&quot;auto&quot;&gt;Restart=always&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Handles graceful shutdown via SIGTERM (Dulak drains in-flight requests and closes the DB)&lt;/li&gt;
&lt;li&gt;Manages logs via journald&lt;/li&gt;
&lt;li&gt;Starts on boot (&lt;code dir=&quot;auto&quot;&gt;WantedBy=multi-user.target&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;PM2 duplicates every one of these features. The difference is systemd is already there and PM2 is a dependency you install.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;docker-already-does-this&quot;&gt;Docker already does this&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;If you’re deploying with Docker — and Dulak ships a &lt;a href=&quot;https://dulak.pages.dev/deployment/docker/&quot;&gt;multi-stage Dockerfile&lt;/a&gt; — the container runtime handles restarts:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;services&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;app&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;restart&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;unless-stopped&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;That’s it. No PM2 inside the container, no process manager layer. Docker restarts the container if it exits. Dulak handles SIGTERM for graceful shutdown.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-philosophy-angle&quot;&gt;The philosophy angle&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Dulak’s philosophy is “zero-dependency where cheap.” systemd is zero-dependency — it’s already on every Linux server. Docker is zero-dependency for containerized deploys — the runtime handles it.&lt;/p&gt;
&lt;p&gt;PM2 is a dependency you install to solve a problem that’s already solved. It adds:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A Node.js runtime (to run PM2 itself, even though your app runs on Bun)&lt;/li&gt;
&lt;li&gt;A configuration layer (&lt;code dir=&quot;auto&quot;&gt;ecosystem.config.js&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;A CLI tool to learn and debug&lt;/li&gt;
&lt;li&gt;Another thing that can break&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;h2 id=&quot;when-pm2-makes-sense&quot;&gt;When PM2 makes sense&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;To be fair, PM2 has legitimate use cases:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;You’re on a shared host without systemd access&lt;/strong&gt; — some budget VPS providers restrict systemd. PM2 works without it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;You’re migrating a Node.js app and already have PM2 infrastructure&lt;/strong&gt; — if your team knows PM2 and your monitoring is wired to it, switching has a cost.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;You need cluster mode for a Node app&lt;/strong&gt; — PM2’s cluster mode is genuinely useful for Node.js multi-core scaling.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of these apply to Dulak. Dulak is Bun, not Node. Dulak ships with systemd and Docker configs. Dulak handles SIGTERM gracefully.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-short-version&quot;&gt;The short version&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;systemd for bare metal. Docker for containers. PM2 for Node.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Dulak is not Node.&lt;/p&gt;</content:encoded><category>deployment</category><category>bun</category><category>philosophy</category></item><item><title>SQLite beats Postgres — we benchmarked it</title><link>https://dulak.pages.dev/blog/why-sqlite-not-postgres/</link><guid isPermaLink="true">https://dulak.pages.dev/blog/why-sqlite-not-postgres/</guid><description>SQLite is a file, not a server. No install, no connection pool, no credentials. 52K reads/s with WAL mode — enough for most apps, and a deliberate swap point when it isn&apos;t.</description><pubDate>Sat, 08 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Every boilerplate comparison eventually reaches the database question: “Why SQLite? Shouldn’t a production app use Postgres or MySQL?”&lt;/p&gt;
&lt;p&gt;It’s a fair question — and the answer isn’t “SQLite is better than Postgres.” It’s “SQLite is the right &lt;em&gt;starting&lt;/em&gt; database, and Dulak is a &lt;em&gt;starting&lt;/em&gt; point.”&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;zero-infrastructure&quot;&gt;Zero infrastructure&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Postgres and MySQL are servers. They run as separate processes, listen on ports, require connection pools, and need credentials managed. Before you write a single query, you need to:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Install the database server&lt;/li&gt;
&lt;li&gt;Configure it (memory, connections, WAL, replication)&lt;/li&gt;
&lt;li&gt;Create a database and user&lt;/li&gt;
&lt;li&gt;Set up credentials (env vars, secrets manager)&lt;/li&gt;
&lt;li&gt;Wire a driver into your app (&lt;code dir=&quot;auto&quot;&gt;pg&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;mysql2&lt;/code&gt;, etc.)&lt;/li&gt;
&lt;li&gt;Configure a connection pool&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;SQLite is a file. &lt;code dir=&quot;auto&quot;&gt;bun:sqlite&lt;/code&gt; is built into Bun. There is no step 1.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; { Database } &lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;bun:sqlite&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;db&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Database&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;app.sqlite&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;That’s the entire setup. No server process, no port, no credentials, no driver dependency, no connection pool. The database is a file on disk.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;fast-enough-for-most-apps&quot;&gt;Fast enough for most apps&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Dulak ships with four PRAGMAs that make SQLite fast:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;db&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;exec&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;PRAGMA journal_mode = WAL&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;db&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;exec&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;PRAGMA synchronous = NORMAL&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;db&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;exec&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;PRAGMA busy_timeout = 5000&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;db&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;exec&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;PRAGMA foreign_keys = ON&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;With these settings, Dulak serves &lt;strong&gt;52,000 reads per second through the full HTTP stack&lt;/strong&gt; (point read, &lt;code dir=&quot;auto&quot;&gt;GET /users/:id&lt;/code&gt;) and &lt;strong&gt;19,600 creates per second&lt;/strong&gt; (&lt;code dir=&quot;auto&quot;&gt;POST /users&lt;/code&gt;, also via HTTP). Drop the HTTP layer and raw &lt;code dir=&quot;auto&quot;&gt;bun:sqlite&lt;/code&gt; reaches &lt;strong&gt;526,000 reads/s&lt;/strong&gt; and &lt;strong&gt;95,000 writes/s&lt;/strong&gt; — the engine ceiling, not what your users experience. For comparison, the no-database health check hits 66K req/s. The database adds only 0.2ms to a 0.76ms HTTP base.&lt;/p&gt;
&lt;p&gt;Most apps never reach 1,000 req/s. SQLite handles 52× that on a laptop. The “SQLite is slow” reputation comes from default settings (&lt;code dir=&quot;auto&quot;&gt;rollback journal&lt;/code&gt; + &lt;code dir=&quot;auto&quot;&gt;FULL&lt;/code&gt; synchronous mode) that run at 3.5K writes/s — 27× slower than WAL + &lt;code dir=&quot;auto&quot;&gt;NORMAL&lt;/code&gt;.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;faster-than-postgresmysql-on-a-single-server&quot;&gt;Faster than Postgres/MySQL on a single server&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;This is the part that surprises people: on a single server, SQLite is &lt;strong&gt;always&lt;/strong&gt; faster than Postgres or MySQL for raw query latency. Not sometimes — always. Four reasons:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;No network hop&lt;/strong&gt; — Postgres/MySQL require a TCP round-trip per query, even on localhost (0.5–1ms). SQLite reads from a memory-mapped WAL file in the same process. Zero network latency.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No connection pool overhead&lt;/strong&gt; — Postgres/MySQL need a pool (acquire/release per query). SQLite uses a single in-process handle with prepared statements cached at module load.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No wire protocol&lt;/strong&gt; — Postgres/MySQL serialize query text → binary → decode result rows over a wire protocol. SQLite reads pages directly from an mmap’d file into native structs via Bun’s native binding. Zero copy.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No IPC context switch&lt;/strong&gt; — Postgres/MySQL run as separate server processes. Every query crosses a process boundary. SQLite runs in-process, on the same event loop as your HTTP server.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Postgres and MySQL only win when you need things SQLite fundamentally cannot provide: multi-server horizontal scaling, concurrent parallel writers, or a more sophisticated query optimizer for very large datasets. On a single server with a single app process, the in-process database will always beat the network-attached one.&lt;/p&gt;
&lt;p&gt;We measured it on the same MacBook Pro (M4) — same schema, same 50K
operations, direct access (no HTTP): SQLite does &lt;strong&gt;84.8K autocommit
inserts/s and 561K point reads/s&lt;/strong&gt;; Postgres 14.17 does &lt;strong&gt;12K inserts/s
and 29.8K reads/s&lt;/strong&gt; (7× and 19× slower); MariaDB 12.0.2 does &lt;strong&gt;11.9K
inserts/s and 26.9K reads/s&lt;/strong&gt; (7× and 21× slower). Even with
&lt;code dir=&quot;auto&quot;&gt;synchronous_commit = off&lt;/code&gt; (Postgres’ version of the &lt;code dir=&quot;auto&quot;&gt;NORMAL&lt;/code&gt;
tradeoff), writes stay 3× behind. See the &lt;a href=&quot;https://dulak.pages.dev/database/performance/#sqlite-vs-postgres--mariadb-on-the-same-machine&quot;&gt;full numbers&lt;/a&gt;.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;backup-is-copying-a-file&quot;&gt;Backup is copying a file&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Postgres backup means &lt;code dir=&quot;auto&quot;&gt;pg_dump&lt;/code&gt;, or setting up replication, or configuring a managed service. MySQL is the same with &lt;code dir=&quot;auto&quot;&gt;mysqldump&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;SQLite backup is &lt;code dir=&quot;auto&quot;&gt;cp app.sqlite backup.sqlite&lt;/code&gt;. For continuous replication, &lt;a href=&quot;https://litestream.io/&quot;&gt;Litestream&lt;/a&gt; streams WAL frames to S3-compatible storage — zero downtime, zero app code changes, ~$0.01/month for a typical database. See the &lt;a href=&quot;https://dulak.pages.dev/database/replication/&quot;&gt;replication guide&lt;/a&gt; for the full setup.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-sqlite-cannot-do&quot;&gt;What SQLite cannot do&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;This isn’t a one-sided argument. SQLite has real limitations:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Single writer&lt;/strong&gt; — WAL allows concurrent readers, but only one writer at a time. &lt;code dir=&quot;auto&quot;&gt;busy_timeout&lt;/code&gt; handles contention, but if your app is write-heavy with high concurrency, Postgres handles this better.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No horizontal scaling&lt;/strong&gt; — you can’t run multiple Dulak instances against the same SQLite file across different servers. Postgres/MySQL allow shared database access from multiple app instances.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No built-in replication&lt;/strong&gt; — Litestream provides &lt;em&gt;backup&lt;/em&gt;, not &lt;em&gt;high availability&lt;/em&gt;. If the server dies, you restore to a new server and restart. There’s downtime. Postgres streaming replication gives you zero-downtime failover.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No fine-grained access control&lt;/strong&gt; — SQLite has no users, roles, or row-level security. If you need multi-tenant access control at the database level, Postgres is the right tool.&lt;/li&gt;
&lt;/ul&gt;
&lt;div&gt;&lt;h2 id=&quot;when-is-the-swap-actually-worth-it&quot;&gt;When is the swap actually worth it?&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The honest answer: &lt;strong&gt;when no single server can handle your traffic anymore.&lt;/strong&gt; That’s a much higher bar than most people assume.&lt;/p&gt;
&lt;p&gt;Dulak serves 52K reads/s on a single Bun process on a laptop. A production VPS with NVMe and more cores will do better. Even at 52K req/s, that’s &lt;strong&gt;4.5 billion requests per day&lt;/strong&gt; — and that’s the &lt;em&gt;database-bound&lt;/em&gt; endpoint, not the health check. Most web apps consider 100K requests per day “high traffic.” You’d need to be serving hundreds of thousands of requests per second before SQLite’s single-writer constraint becomes the bottleneck, not the HTTP layer, not the app logic, not the network.&lt;/p&gt;
&lt;p&gt;The real reasons to switch to Postgres are architectural, not performance:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;You need multiple app servers&lt;/strong&gt; — horizontal scaling means multiple processes writing to the same database. SQLite can’t do this; Postgres/MySQL can.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;You need zero-downtime failover&lt;/strong&gt; — Litestream gives you backup, not HA. If the server dies, there’s downtime during restore. Postgres streaming replication gives you instant failover.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;You need row-level security or fine-grained access control&lt;/strong&gt; — SQLite has no users, roles, or RLS.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Notice none of these are “SQLite is too slow.” They’re “my architecture outgrew single-server.” If you’re still on one server — even a busy one — SQLite is not your bottleneck.&lt;/p&gt;
&lt;p&gt;And when that day comes, the swap is deliberate: replace &lt;code dir=&quot;auto&quot;&gt;bun:sqlite&lt;/code&gt; with a Postgres driver, update &lt;code dir=&quot;auto&quot;&gt;db.ts&lt;/code&gt;, adjust SQL syntax differences. No ORM means no abstraction layer to migrate &lt;em&gt;through&lt;/em&gt;. Routes, auth, validation, SSR — none of it changes.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;why-not-start-with-postgres-then&quot;&gt;Why not start with Postgres then?&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Because starting with Postgres means starting with infrastructure. Dulak’s philosophy is “your first week should be business logic, not infrastructure.” If you start with SQLite and never outgrow it — which most apps don’t — you’ve saved weeks of operational overhead for free. If you do outgrow it, the swap is a scheduled migration, not a fire drill.&lt;/p&gt;
&lt;p&gt;Starting with Postgres “just in case” is the same logic as adding a cache service “just in case” — it’s solving a problem you don’t have yet, at the cost of complexity you pay for from day one.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;common-sqlite-myths&quot;&gt;Common SQLite myths&lt;/h2&gt;&lt;/div&gt;
&lt;div&gt;&lt;h3 id=&quot;sqlite-is-only-for-testing-and-prototypes&quot;&gt;“SQLite is only for testing and prototypes”&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;SQLite is the &lt;a href=&quot;https://www.sqlite.org/mostdeployed.html&quot;&gt;most deployed database in the world&lt;/a&gt; — it runs in every Android device, every iOS device, every web browser (via WebSQL/WASM), every macOS installation, and countless production apps. It’s not a toy database; it’s a battle-tested engine that handles exabyte-scale workloads at Apple, Google, and Mozilla. The “prototype only” reputation comes from people who tried it with default settings and never tuned the PRAGMAs.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;sqlite-cant-handle-concurrent-access&quot;&gt;“SQLite can’t handle concurrent access”&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;WAL mode allows &lt;strong&gt;unlimited concurrent readers&lt;/strong&gt; alongside one writer. Readers never block, and the writer never blocks readers. The only constraint is one writer at a time — and &lt;code dir=&quot;auto&quot;&gt;busy_timeout&lt;/code&gt; handles contention by waiting up to 5 seconds (configurable) before returning &lt;code dir=&quot;auto&quot;&gt;SQLITE_BUSY&lt;/code&gt;. For a web app where most requests are reads, this is a non-issue. Dulak serves 52K concurrent reads/s with zero lock contention.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;sqlite-loses-data-on-power-failure&quot;&gt;“SQLite loses data on power failure”&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;With &lt;code dir=&quot;auto&quot;&gt;synchronous = FULL&lt;/code&gt;, SQLite is as durable as any database — every commit is fsync’d to disk before acknowledging. Dulak uses &lt;code dir=&quot;auto&quot;&gt;synchronous = NORMAL&lt;/code&gt; by default, which trades the &lt;em&gt;last few WAL transactions&lt;/em&gt; for 27× write throughput. The database &lt;strong&gt;never corrupts&lt;/strong&gt; — you might lose the last few milliseconds of writes on a power loss, but the file is always consistent. For financial records, switch to &lt;code dir=&quot;auto&quot;&gt;FULL&lt;/code&gt;. For a web app, &lt;code dir=&quot;auto&quot;&gt;NORMAL&lt;/code&gt; is the right tradeoff.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;sqlite-doesnt-scale&quot;&gt;“SQLite doesn’t scale”&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;“Scale” means different things. SQLite scales to &lt;strong&gt;terabytes&lt;/strong&gt; of data and &lt;strong&gt;billions of rows&lt;/strong&gt; in a single file — the format handles it fine. What it doesn’t scale to is &lt;strong&gt;multiple servers writing simultaneously&lt;/strong&gt;. If “scale” means “more servers,” you need Postgres. If “scale” means “more data on one server,” SQLite handles it.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;youll-need-to-migrate-to-postgres-eventually&quot;&gt;“You’ll need to migrate to Postgres eventually”&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Most apps never reach the point where SQLite is the bottleneck. The apps that do outgrow SQLite are the ones with specific needs: multi-server architecture, high-concurrency writes, or advanced replication. If you build a successful app that hits those limits, that’s a good problem to have — and the migration is a scheduled project, not an emergency. Starting with Postgres “just in case” means paying infrastructure tax from day one for a migration that probably never happens.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-short-version&quot;&gt;The short version&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;SQLite until no single server can handle your traffic — and that’s hundreds of thousands of requests per second, not hundreds.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The numbers: &lt;a href=&quot;https://dulak.pages.dev/database/performance/#sqlite-vs-postgres--mariadb-on-the-same-machine&quot;&gt;526K reads/s and 95K writes/s direct&lt;/a&gt; on a laptop — 7–40× faster than Postgres and MariaDB on the same machine, measured the same way. The backup: copy a file. The swap: architectural, not performance — and scheduled, not emergency. The cost: zero infrastructure until you need it.&lt;/p&gt;</content:encoded><category>database</category><category>sqlite</category><category>philosophy</category></item><item><title>SSR vs SPA: 50ms to content, or 2 seconds of blank screen</title><link>https://dulak.pages.dev/blog/ssr-vs-spa/</link><guid isPermaLink="true">https://dulak.pages.dev/blog/ssr-vs-spa/</guid><description>SSR ships HTML on the first byte — 50ms to content. SPA ships an empty div and a JS bundle — 500ms to 2s on a budget phone. SSR is crawlable, mobile-friendly, and has no hydration waterfall. Dulak does it in-process, no separate server.</description><pubDate>Sat, 08 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;SSR sends rendered HTML. SPA sends an empty div and a JavaScript bundle. That’s the difference, and it’s not theoretical — it’s 50ms vs 2 seconds on a budget phone, and it’s whether Google can read your page or queues it for a rendering pass that might take weeks.&lt;/p&gt;
&lt;p&gt;The &lt;a href=&quot;https://dulak.pages.dev/blog/why-inertia-not-spa/&quot;&gt;Inertia post&lt;/a&gt; covered the architecture: one router, one validator, no API layer. This post is about the rendering strategy — what the browser receives on the first byte, and why it matters.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-is-spa&quot;&gt;What is SPA?&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;SPA&lt;/strong&gt; (Single Page Application) is a rendering strategy where the server sends a bare HTML shell — typically just &lt;code dir=&quot;auto&quot;&gt;&amp;#x3C;div id=&quot;app&quot;&gt;&amp;#x3C;/div&gt;&lt;/code&gt; and a &lt;code dir=&quot;auto&quot;&gt;&amp;#x3C;script&gt;&lt;/code&gt; tag — and the browser downloads a JavaScript bundle, executes it, and renders the entire UI on the client.&lt;/p&gt;
&lt;p&gt;The “single page” part means the browser loads one HTML document, then JavaScript takes over: routing, data fetching, rendering, and state management all happen in the browser. Navigating between pages doesn’t trigger a full page reload — JavaScript swaps content in and out of the DOM.&lt;/p&gt;
&lt;p&gt;Examples: React apps built with Vite (no SSR), Vue apps with Vue Router, Angular apps. The server is a static file host or a JSON API — it never produces HTML with content in it.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-is-ssr&quot;&gt;What is SSR?&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;SSR&lt;/strong&gt; (Server-Side Rendering) is a rendering strategy where the server produces full HTML — with content, headings, text, meta tags — on every request. The browser receives a complete document and can paint it immediately, before any JavaScript loads.&lt;/p&gt;
&lt;p&gt;This is how the web worked before SPAs: PHP, Rails, Django, Express with a template engine — the server reads from the database, renders HTML, sends it. The browser displays it. JavaScript, if any, adds interactivity on top of the already-visible page.&lt;/p&gt;
&lt;p&gt;Modern SSR (Next.js, Nuxt, SvelteKit, Dulak) adds a hydration step: after the HTML is visible, JavaScript loads in the background and “attaches” interactivity to the existing DOM — event listeners, state, client-side routing. The user sees content first, interactivity arrives second.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-difference-in-one-sentence&quot;&gt;The difference in one sentence&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;SPA: the browser receives an empty shell, JavaScript renders everything. SSR: the server renders everything, the browser receives a complete page.&lt;/p&gt;
&lt;p&gt;The rest of this post explains why that difference matters — for speed, for SEO, for mobile users, and for code complexity.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-blank-screen-problem&quot;&gt;The blank screen problem&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Here’s what a SPA sends on first load:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;div&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;/span&gt;&lt;span&gt;id&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;app&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;&lt;span&gt;&gt;&amp;#x3C;/&lt;/span&gt;&lt;span&gt;div&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;script&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;/span&gt;&lt;span&gt;type&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;module&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;src&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;/assets/app-a1b2c3.js&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;&lt;span&gt;&gt;&amp;#x3C;/&lt;/span&gt;&lt;span&gt;script&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;That’s it. The user’s browser receives an empty &lt;code dir=&quot;auto&quot;&gt;&amp;#x3C;div&gt;&lt;/code&gt; and a script tag. Nothing is visible until the browser downloads the JS bundle, parses it, executes it, and the framework mounts and renders. The timeline:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Server response&lt;/strong&gt; — ~50ms (TLS + round trip + server processing)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;JS download&lt;/strong&gt; — 200KB bundle over 3G: ~800ms. Over 4G: ~200ms.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;JS parse + compile&lt;/strong&gt; — 200KB of JS on a budget phone: ~300–500ms. On a desktop: ~50ms.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Framework init + render&lt;/strong&gt; — React mounts, runs effects, fetches data: ~200–500ms.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Total time to first contentful paint: &lt;strong&gt;500ms on a fast desktop, 1.5–2 seconds on a budget phone over 3G.&lt;/strong&gt; The user stares at a blank white screen the entire time.&lt;/p&gt;
&lt;p&gt;Here’s what Dulak sends on first load (public route, SSR enabled):&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;&amp;#x3C;!&lt;/span&gt;&lt;span&gt;DOCTYPE&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;/span&gt;&lt;span&gt;html&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;html&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;/span&gt;&lt;span&gt;lang&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;en&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;head&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;meta&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;/span&gt;&lt;span&gt;charset&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;utf-8&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; /&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;title&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;Landing — Dulak&lt;/span&gt;&lt;span&gt;&lt;span&gt;&amp;#x3C;/&lt;/span&gt;&lt;span&gt;title&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;link&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;/span&gt;&lt;span&gt;rel&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;stylesheet&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;href&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;/assets/app-a1b2c3.css&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; /&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;&amp;#x3C;/&lt;/span&gt;&lt;span&gt;head&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;body&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;div&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;/span&gt;&lt;span&gt;id&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;app&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;data-server-rendered&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;h1&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;Dulak&lt;/span&gt;&lt;span&gt;&lt;span&gt;&amp;#x3C;/&lt;/span&gt;&lt;span&gt;h1&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;p&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;The deliberately boring full-stack boilerplate.&lt;/span&gt;&lt;span&gt;&lt;span&gt;&amp;#x3C;/&lt;/span&gt;&lt;span&gt;p&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;&amp;#x3C;!-- full rendered HTML here --&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;&lt;span&gt;&amp;#x3C;/&lt;/span&gt;&lt;span&gt;div&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;script&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;/span&gt;&lt;span&gt;type&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;module&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;src&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;/assets/app-a1b2c3.js&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;&lt;span&gt;&gt;&amp;#x3C;/&lt;/span&gt;&lt;span&gt;script&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;&amp;#x3C;/&lt;/span&gt;&lt;span&gt;body&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;&amp;#x3C;/&lt;/span&gt;&lt;span&gt;html&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The HTML arrives with content in it. The browser parses HTML and paints it immediately — before the JS bundle even starts downloading. First contentful paint = server response time, ~50ms. The JS bundle loads in the background and Inertia takes over interactivity after the page is already visible.&lt;/p&gt;
&lt;p&gt;The user sees content on the first byte. Not after JS downloads. Not after JS parses. Not after React mounts. &lt;strong&gt;On the first byte.&lt;/strong&gt;&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;seo-and-crawlability&quot;&gt;SEO and crawlability&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Search engines crawl HTML. When Googlebot fetches a server-rendered page, it gets the full content in the HTTP response. The page is indexable on the first fetch — title, headings, text, links, meta tags, all present in the HTML.&lt;/p&gt;
&lt;p&gt;When Googlebot fetches a SPA, it gets &lt;code dir=&quot;auto&quot;&gt;&amp;#x3C;div id=&quot;app&quot;&gt;&amp;#x3C;/div&gt;&lt;/code&gt;. The content is behind JavaScript execution. Google &lt;em&gt;can&lt;/em&gt; render JS — it uses a headless Chromium to execute the page’s scripts and index the resulting DOM. But this is a two-phase process:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;First pass&lt;/strong&gt; — Googlebot fetches the HTML, sees an empty div, queues the page for rendering.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Second pass&lt;/strong&gt; — Google’s rendering queue (which has a backlog) executes the JS, renders the DOM, and indexes the content.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The second pass can be &lt;strong&gt;days or weeks later&lt;/strong&gt;. And it’s less reliable — JS execution can fail, time out, or produce different output than expected. Google’s own documentation says: “Rendering JavaScript can be slow and we recommend server-side rendering or pre-rendering where possible.”&lt;/p&gt;
&lt;p&gt;And Google is the &lt;em&gt;best case&lt;/em&gt;. Other crawlers — Bing’s, social media bots (Twitter, Facebook, Slack, Discord), SEO auditors, accessibility tools — many of them don’t execute JavaScript at all. A SPA’s &lt;code dir=&quot;auto&quot;&gt;&amp;#x3C;div id=&quot;app&quot;&gt;&amp;#x3C;/div&gt;&lt;/code&gt; is all they see. No Open Graph tags, no meta descriptions, no content. Your link preview is blank. Your page doesn’t exist to half the internet’s crawlers.&lt;/p&gt;
&lt;p&gt;Dulak’s SSR sends full HTML with rendered content, &lt;code dir=&quot;auto&quot;&gt;&amp;#x3C;title&gt;&lt;/code&gt; tags, and meta information on every public route. Every crawler sees the page. No rendering queue, no two-phase indexing, no “wait for the JS to execute.” The HTML is the content.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-mobile-js-tax&quot;&gt;The mobile JS tax&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;JavaScript is the most expensive byte on the web. Here’s why:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;HTML&lt;/strong&gt; is parsed incrementally as it streams in. The browser can paint partial HTML before the full document loads.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CSS&lt;/strong&gt; is parsed and matched against the DOM. Also relatively cheap.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;JavaScript&lt;/strong&gt; must be downloaded, parsed, compiled, and executed before it does anything. On mobile devices, this is 2–10× slower than on desktop.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A 200KB JS bundle (gzipped) is not 200KB of work. It’s 200KB of source that expands to 1MB+ of parsed code, which the browser must compile to native, execute, and garbage-collect. On a 2020-era budget Android phone with a slow CPU and limited memory, parsing 200KB of JS takes 300–500ms. On a 2018 phone, it can take over a second.&lt;/p&gt;
&lt;p&gt;The SPA industry’s answer to this is the “JavaScript budget” — a constant battle to keep bundles small. Code-splitting, tree-shaking, lazy-loading route chunks, dynamic imports. You spend engineering time fighting bundle size because every KB of JS is a KB of delay on mobile.&lt;/p&gt;
&lt;p&gt;SSR sidesteps the problem. The server renders HTML — the browser’s native, optimized format. The JS bundle still loads (Inertia needs it for interactivity), but it’s not on the critical path. The user sees content immediately. The JS loads in the background. If the JS takes 2 seconds to load on 3G, the user has been reading the page for 2 seconds already.&lt;/p&gt;
&lt;p&gt;Dulak takes this further: SSR is &lt;strong&gt;skipped for authenticated routes&lt;/strong&gt;. Behind a login wall, there’s no SEO benefit and no public crawler. The client hydrates and replaces server HTML anyway, so SSR is pure server CPU waste. Public pages get full SSR; authenticated pages ship an empty shell with the page payload inlined as JSON. Less server work, same UX.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// inertia.ts — the rendering decision&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (config&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;ssr&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&amp;#x26;&amp;#x26;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;!&lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;c&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;user&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;// Public route → full SSR HTML, content on first byte&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;rendered&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;renderPage&lt;/span&gt;&lt;span&gt;(page);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;html&lt;/span&gt;&lt;span&gt;(rendered&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;head&lt;/span&gt;&lt;span&gt;, rendered&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;body&lt;/span&gt;&lt;span&gt;, options&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;status&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;??&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;200&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// Authenticated route → empty shell + JSON payload, client renders&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;html&lt;/span&gt;&lt;span&gt;([], &lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;clientBody&lt;/span&gt;&lt;span&gt;(page), options&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;status&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;??&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;200&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;div&gt;&lt;h2 id=&quot;no-hydration-waterfall&quot;&gt;No hydration waterfall&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Here’s what Next.js and Nuxt do:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Server renders HTML&lt;/strong&gt; — React renders the component tree to HTML on the server.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;HTML sent to client&lt;/strong&gt; — user sees content (good).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;JS bundle downloads&lt;/strong&gt; — in the background.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Client hydrates&lt;/strong&gt; — React re-renders the &lt;em&gt;exact same component tree&lt;/em&gt; on the client, walks the DOM, attaches event listeners, and reconciles the server HTML with the client virtual DOM.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Step 4 is the hydration waterfall. The client does the same work the server just did — renders the same components with the same props — to attach interactivity. If the server and client render different output, you get a hydration mismatch warning and React throws away the server HTML and re-renders from scratch. You’ve paid for rendering twice and gotten nothing extra.&lt;/p&gt;
&lt;p&gt;Dulak’s Inertia SSR is different. The server renders HTML with &lt;code dir=&quot;auto&quot;&gt;react-dom/server&lt;/code&gt;:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// ssr.tsx — runs inside the Hono process&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;export&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;async&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;function&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;renderPage&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;page&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&lt;span&gt;Page&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;createInertiaApp&lt;/span&gt;&lt;span&gt;({&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;page&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;render: renderToString&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;resolve&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;name&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; pages[&lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt;./pages/&lt;/span&gt;&lt;span&gt;${&lt;/span&gt;&lt;span&gt;name&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;.tsx&lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span&gt;?.&lt;/span&gt;&lt;span&gt;default&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;??&lt;/span&gt;&lt;span&gt; notFoundPage&lt;/span&gt;&lt;span&gt;!&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;setup&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&lt;span&gt;{ &lt;/span&gt;&lt;span&gt;App&lt;/span&gt;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;props&lt;/span&gt;&lt;span&gt; }&lt;/span&gt;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;App&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;span&gt;...&lt;/span&gt;&lt;span&gt;props&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt; /&gt;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;title&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;title&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; title &lt;/span&gt;&lt;span&gt;?&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt;${&lt;/span&gt;&lt;span&gt;title&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt; — Dulak&lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Dulak&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;});&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The client doesn’t re-render. When the JS loads, Inertia sees &lt;code dir=&quot;auto&quot;&gt;data-server-rendered=&quot;true&quot;&lt;/code&gt; on the mount point and calls &lt;code dir=&quot;auto&quot;&gt;hydrateRoot&lt;/code&gt; instead of &lt;code dir=&quot;auto&quot;&gt;createRoot&lt;/code&gt;:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// app.tsx — client entry&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;setup&lt;/span&gt;&lt;span&gt;({ el, App, props }) {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;element&lt;/span&gt;&lt;span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;App&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;span&gt;...&lt;/span&gt;&lt;span&gt;props&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt; /&gt;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (el&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;hasAttribute&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;data-server-rendered&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)) {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;hydrateRoot&lt;/span&gt;&lt;span&gt;(el, element);  &lt;/span&gt;&lt;span&gt;// attach, don&apos;t re-render&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;} &lt;/span&gt;&lt;span&gt;else&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;createRoot&lt;/span&gt;&lt;span&gt;(el)&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;render&lt;/span&gt;&lt;span&gt;(element);  &lt;/span&gt;&lt;span&gt;// fresh render (no SSR)&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;hydrateRoot&lt;/code&gt; attaches event listeners to the existing DOM. It doesn’t throw away the server HTML and re-render. It doesn’t walk the tree and reconcile. It takes the DOM that’s already on screen and makes it interactive. The server did the rendering; the client does the wiring. One render, not two.&lt;/p&gt;
&lt;p&gt;This is the key difference from Next.js/Nuxt SSR: those frameworks hydrate by re-rendering. Inertia hydrates by attaching. Less work, less CPU, less chance of mismatch.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;in-process-ssr-not-a-separate-server&quot;&gt;In-process SSR, not a separate server&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Next.js needs a Node.js server for SSR. Nuxt needs a Node.js server. SvelteKit needs a Node.js server. The SSR runtime is a separate process from your API, your database layer, your background jobs. You deploy two things (at minimum), manage two runtimes, and debug across process boundaries.&lt;/p&gt;
&lt;p&gt;Dulak renders SSR in the &lt;strong&gt;same Bun process&lt;/strong&gt; as the HTTP server and the database. The &lt;code dir=&quot;auto&quot;&gt;renderPage&lt;/code&gt; function in &lt;code dir=&quot;auto&quot;&gt;src/client/ssr.tsx&lt;/code&gt; runs inside the Hono request handler. The Inertia adapter calls it directly:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// inertia.ts — render() method, inside the HTTP handler&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;async &lt;/span&gt;&lt;span&gt;render&lt;/span&gt;&lt;span&gt;(component, props, options) {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;page&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;page&lt;/span&gt;&lt;span&gt;(component&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;props);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;isXhr&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;json&lt;/span&gt;&lt;span&gt;(page, options&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;status&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;??&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;200&lt;/span&gt;&lt;span&gt;);  &lt;/span&gt;&lt;span&gt;// Inertia XHR → JSON&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (config&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;ssr&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&amp;#x26;&amp;#x26;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;!&lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;c&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;user&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;rendered&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;renderPage&lt;/span&gt;&lt;span&gt;(page);  &lt;/span&gt;&lt;span&gt;// SSR, same process&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;html&lt;/span&gt;&lt;span&gt;(rendered&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;head&lt;/span&gt;&lt;span&gt;, rendered&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;body&lt;/span&gt;&lt;span&gt;, options&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;status&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;??&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;200&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;html&lt;/span&gt;&lt;span&gt;([], &lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;clientBody&lt;/span&gt;&lt;span&gt;(page), options&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;status&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;??&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;200&lt;/span&gt;&lt;span&gt;);  &lt;/span&gt;&lt;span&gt;// empty shell&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;No second runtime. No inter-process communication. No separate deployment. No SSR server to scale independently. The HTTP server, the database (&lt;code dir=&quot;auto&quot;&gt;bun:sqlite&lt;/code&gt;), and the SSR renderer all share one event loop, one process, one deployment.&lt;/p&gt;
&lt;p&gt;This works because Bun runs &lt;code dir=&quot;auto&quot;&gt;react-dom/server&lt;/code&gt; natively. There’s no need for a separate Node process — Bun handles the React SSR render in-process, on the same event loop that handles HTTP requests and database queries. The render takes single-digit milliseconds for a typical page. No bottleneck, no separate infrastructure to manage.&lt;/p&gt;
&lt;p&gt;The middleware wires it all together per request:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// inertia-middleware.ts — one adapter per request, same process&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;export const &lt;/span&gt;&lt;span&gt;inertiaMiddleware&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;assets&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; =&gt; async &lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;c&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;next&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; =&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;sessionToken&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;getCookie&lt;/span&gt;&lt;span&gt;(c&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;SESSION_COOKIE&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;user&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;resolveUser&lt;/span&gt;&lt;span&gt;(sessionToken)&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;c&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;set&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;inertia&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Inertia&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{ request: &lt;/span&gt;&lt;span&gt;c&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;req&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;raw&lt;/span&gt;&lt;span&gt;, headers: &lt;/span&gt;&lt;span&gt;...&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;user&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;flash&lt;/span&gt;&lt;span&gt; }, &lt;/span&gt;&lt;span&gt;assets))&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;await &lt;/span&gt;&lt;span&gt;next&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;One process. One middleware. One adapter per request. The route handler calls &lt;code dir=&quot;auto&quot;&gt;c.var.inertia.render(...)&lt;/code&gt; and gets back a &lt;code dir=&quot;auto&quot;&gt;Response&lt;/code&gt; — HTML for browser visits, JSON for Inertia XHR. See the &lt;a href=&quot;https://dulak.pages.dev/architecture/request-lifecycle/&quot;&gt;request lifecycle&lt;/a&gt; for the full flow.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-2026-angle-less-code-for-ai-to-break&quot;&gt;The 2026 angle: less code for AI to break&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Here’s where this gets interesting. In 2026, a growing share of code is AI-generated. The cost of &lt;em&gt;producing&lt;/em&gt; code is approaching zero. The cost of &lt;em&gt;maintaining&lt;/em&gt; code is not.&lt;/p&gt;
&lt;p&gt;A SPA architecture requires AI to generate and maintain:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Client-side routing logic (route definitions, guards, lazy loading, code splitting)&lt;/li&gt;
&lt;li&gt;State management (stores, reducers, selectors, cache invalidation)&lt;/li&gt;
&lt;li&gt;An API layer (endpoints, serialization, pagination, error handling)&lt;/li&gt;
&lt;li&gt;Hydration logic (server/client reconciliation, mismatch handling)&lt;/li&gt;
&lt;li&gt;Data fetching (loading states, error states, refetching, optimistic updates)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each of these is code that AI must generate correctly, and then maintain correctly when requirements change. Each is a surface where AI can introduce bugs — a route guard that doesn’t match the server, a state shape that drifts from the API contract, a hydration mismatch that only appears in production.&lt;/p&gt;
&lt;p&gt;SSR is simpler code. The server renders HTML from database state. The client displays it. There’s less to generate, less to maintain, less to break. When code is free to produce, the rational choice is the architecture that produces &lt;em&gt;less&lt;/em&gt; code — not because the code is expensive to write, but because it’s expensive to keep correct.&lt;/p&gt;
&lt;p&gt;Dulak’s entire client-side rendering logic is 27 lines. The SSR entry point is 22 lines. The rendering decision in the adapter is 4 lines. That’s the full rendering strategy — server renders HTML, client attaches interactivity. An AI maintaining this codebase has very few surfaces to get wrong.&lt;/p&gt;
&lt;p&gt;The SPA complexity isn’t there because it’s necessary — it’s there because the architecture demands it. Choose a different architecture and the complexity doesn’t exist. You can’t break code that was never written.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;when-a-spa-rendering-strategy-makes-sense&quot;&gt;When a SPA rendering strategy makes sense&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;SSR isn’t universally better. SPAs are the right rendering choice when:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The app is already a SPA&lt;/strong&gt; — if you’ve built Figma or Google Maps, the client is the application. SSR adds nothing because the content is generated by user interaction, not by the server.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;You’re on the edge&lt;/strong&gt; — Cloudflare Workers, Vercel Edge Functions. No long-lived process means no in-process SSR. You’d need a separate SSR server, which defeats the point.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Content is fully dynamic and client-generated&lt;/strong&gt; — a collaborative whiteboard, a real-time trading dashboard. The server can’t pre-render what doesn’t exist until the user interacts.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Dulak is for server-rendered web apps — dashboards, admin panels, SaaS products, content sites. Apps where the server knows the state and can render it to HTML. If your app’s content exists before the user interacts, SSR is the right rendering strategy.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-short-version&quot;&gt;The short version&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Ship HTML, not a div.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;A SPA sends &lt;code dir=&quot;auto&quot;&gt;&amp;#x3C;div id=&quot;app&quot;&gt;&amp;#x3C;/div&gt;&lt;/code&gt; and a JS bundle. The user waits. A crawler waits (or gives up). A budget phone chokes on the JS parse. Dulak sends rendered HTML on the first byte — content visible immediately, crawlers see everything, mobile users aren’t paying the JS tax.&lt;/p&gt;
&lt;p&gt;And unlike Next.js/Nuxt, there’s no hydration waterfall — Inertia attaches interactivity to existing DOM, it doesn’t re-render. No separate SSR server — it runs in the same Bun process as your HTTP server and database. No second deployment, no inter-process communication, no hydration mismatch debugging.&lt;/p&gt;
&lt;p&gt;The &lt;a href=&quot;https://dulak.pages.dev/blog/why-inertia-not-spa/&quot;&gt;Inertia post&lt;/a&gt; explains why you don’t need a client-side router, an API layer, or Redux. This post explains why the user sees content faster, Google can read your page, and budget phones don’t choke. Same architecture, different argument. See the &lt;a href=&quot;https://dulak.pages.dev/architecture/request-lifecycle/&quot;&gt;request lifecycle&lt;/a&gt; for how it all fits together in one process.&lt;/p&gt;</content:encoded><category>ssr</category><category>performance</category><category>philosophy</category></item><item><title>Tailwind is a build step you don&apos;t need</title><link>https://dulak.pages.dev/blog/why-vanilla-css-not-tailwind/</link><guid isPermaLink="true">https://dulak.pages.dev/blog/why-vanilla-css-not-tailwind/</guid><description>Tailwind is powerful, but it adds a build step and a mental model. Vanilla CSS in 2026 has native nesting, custom properties, grid, and flexbox. The problems Tailwind solved are mostly gone — so Dulak ships vanilla CSS by default.</description><pubDate>Sat, 08 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Let’s get this out of the way first: &lt;strong&gt;Dulak offers Tailwind templates.&lt;/strong&gt; You can run &lt;code dir=&quot;auto&quot;&gt;bun create dulak@latest my-app --template react-tailwind&lt;/code&gt; and get React 19 + Tailwind CSS v4 with the same auth, SSR, and test suite. Same for Svelte and Vue. This is not a post about why Tailwind is bad. It’s a post about why vanilla CSS is the &lt;em&gt;default&lt;/em&gt; — and why that default is a deliberate choice, not a lazy one.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;tailwind-adds-a-build-dependency&quot;&gt;Tailwind adds a build dependency&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Tailwind is a utility-class framework that generates CSS at build time. It’s powerful — you compose styles inline without touching a separate file, and the compiler purges unused classes so the output stays small. But “generates CSS at build time” means you need a build step.&lt;/p&gt;
&lt;p&gt;For Dulak’s Tailwind templates, that means adding two dependencies (&lt;code dir=&quot;auto&quot;&gt;tailwindcss&lt;/code&gt; + &lt;code dir=&quot;auto&quot;&gt;@tailwindcss/cli&lt;/code&gt;), a &lt;code dir=&quot;auto&quot;&gt;tailwind.css&lt;/code&gt; input file, a &lt;code dir=&quot;auto&quot;&gt;dev:css&lt;/code&gt; watch script, and a pre-build step in your asset pipeline. The build time goes from 0.2s to 0.4s. The dev server restart adds ~350ms. None of these are catastrophic — but they’re all costs you pay from day one for a feature you may not need.&lt;/p&gt;
&lt;p&gt;Vanilla CSS has none of this. No PostCSS config, no purge config, no content scanning, no CLI step. The CSS you write is the CSS that ships. &lt;code dir=&quot;auto&quot;&gt;Bun.build&lt;/code&gt; bundles all imported &lt;code dir=&quot;auto&quot;&gt;.css&lt;/code&gt; files into one stylesheet via the import graph — same as it bundles your &lt;code dir=&quot;auto&quot;&gt;.tsx&lt;/code&gt; files. Zero extra tooling.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;co-located-css-not-a-global-soup&quot;&gt;Co-located CSS, not a global soup&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The strongest argument against vanilla CSS is the old experience: one giant &lt;code dir=&quot;auto&quot;&gt;styles.css&lt;/code&gt; file, 2000 lines, nobody knows what’s still used, specificity wars, &lt;code dir=&quot;auto&quot;&gt;!important&lt;/code&gt; everywhere. That was the CSS of 2012. Dulak doesn’t do that.&lt;/p&gt;
&lt;p&gt;Dulak’s pattern is &lt;strong&gt;co-located CSS&lt;/strong&gt;. Each component imports its own &lt;code dir=&quot;auto&quot;&gt;.css&lt;/code&gt; file. &lt;code dir=&quot;auto&quot;&gt;styles.css&lt;/code&gt; holds only global base — design tokens, reset, and shared UI primitives. Everything else lives next to the component that uses it:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;src/client/&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;styles.css           # tokens, reset, .btn, .badge, .panel, .table, .avatar&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;components/&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Brand.css          # .brand, .brand-mark&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Field.css          # .field, .field-hint, .field-error&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Layout.css&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;AuthLayout.css&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;pages/&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Dashboard.css      # .stats, .stat-card, .stat-value&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Admin.css&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;Profile.css&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Here’s what &lt;code dir=&quot;auto&quot;&gt;Dashboard.css&lt;/code&gt; actually looks like:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;/* Dashboard: stat cards. */&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;.stats&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;display&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;grid&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;grid-template-columns&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;repeat&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;auto-fit&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;minmax&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&lt;span&gt;180&lt;/span&gt;&lt;span&gt;px&lt;/span&gt;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;fr&lt;/span&gt;&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;gap&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;rem&lt;/span&gt;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;margin&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&lt;span&gt;1.5&lt;/span&gt;&lt;span&gt;rem&lt;/span&gt;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;.stat-card&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;background&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;--surface&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;border&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;px&lt;/span&gt;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;solid&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;--border&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;border-radius&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;--radius&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;padding&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&lt;span&gt;1.25&lt;/span&gt;&lt;span&gt;rem&lt;/span&gt;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;That’s it. No utility classes, no &lt;code dir=&quot;auto&quot;&gt;@apply&lt;/code&gt;, no config. You read it, you understand it, you move on. The design tokens (&lt;code dir=&quot;auto&quot;&gt;--surface&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;--border&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;--radius&lt;/code&gt;) come from &lt;code dir=&quot;auto&quot;&gt;styles.css&lt;/code&gt;, where they’re defined once for light and dark themes:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;:root&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;data-theme&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;light&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;--bg&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&lt;span&gt;#&lt;/span&gt;&lt;span&gt;f6f7fb&lt;/span&gt;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;--surface&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&lt;span&gt;#&lt;/span&gt;&lt;span&gt;ffffff&lt;/span&gt;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;--border&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&lt;span&gt;#&lt;/span&gt;&lt;span&gt;e4e7ef&lt;/span&gt;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;--primary&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&lt;span&gt;#&lt;/span&gt;&lt;span&gt;059669&lt;/span&gt;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;--radius&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&lt;span&gt;12&lt;/span&gt;&lt;span&gt;px&lt;/span&gt;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;data-theme&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;dark&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;--bg&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&lt;span&gt;#&lt;/span&gt;&lt;span&gt;0f1117&lt;/span&gt;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;--surface&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&lt;span&gt;#&lt;/span&gt;&lt;span&gt;171a23&lt;/span&gt;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;--border&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&lt;span&gt;#&lt;/span&gt;&lt;span&gt;2d3444&lt;/span&gt;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;--primary&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&lt;span&gt;#&lt;/span&gt;&lt;span&gt;10b981&lt;/span&gt;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Dark mode is a &lt;code dir=&quot;auto&quot;&gt;[data-theme]&lt;/code&gt; attribute on &lt;code dir=&quot;auto&quot;&gt;&amp;#x3C;html&gt;&lt;/code&gt;. No framework, no plugin, no &lt;code dir=&quot;auto&quot;&gt;dark:&lt;/code&gt; prefix. The tokens cascade. Every component that uses &lt;code dir=&quot;auto&quot;&gt;var(--surface)&lt;/code&gt; gets the right value automatically.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-css-is-hard-myth&quot;&gt;The “CSS is hard” myth&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;CSS was hard in 2015. Browser inconsistencies were real — flexbox had vendor prefixes, grid didn’t exist, &lt;code dir=&quot;auto&quot;&gt;calc()&lt;/code&gt; was flaky, and IE9 was still a thing you had to support. Frameworks like Bootstrap and Tailwind emerged to paper over those inconsistencies. They gave you a consistent API on top of an inconsistent platform.&lt;/p&gt;
&lt;p&gt;It’s 2026. The platform caught up:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;CSS Grid&lt;/strong&gt; — &lt;code dir=&quot;auto&quot;&gt;grid-template-columns: repeat(auto-fit, minmax(180px, 1fr))&lt;/code&gt; gives you a responsive card layout in one line. No &lt;code dir=&quot;auto&quot;&gt;grid-cols-4&lt;/code&gt; breakpoint juggling.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Custom properties&lt;/strong&gt; — &lt;code dir=&quot;auto&quot;&gt;var(--primary)&lt;/code&gt; is a design token. Change it in one place, it updates everywhere. No &lt;code dir=&quot;auto&quot;&gt;@apply&lt;/code&gt; or theme config needed.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Native nesting&lt;/strong&gt; — CSS now supports &lt;code dir=&quot;auto&quot;&gt;&amp;#x26; .child&lt;/code&gt; nesting natively in all modern browsers. No Sass required.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;:focus-visible&lt;/code&gt;&lt;/strong&gt; — accessible focus styles without a plugin.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code dir=&quot;auto&quot;&gt;color-mix()&lt;/code&gt;&lt;/strong&gt; — blend colors at runtime without a preprocessor.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The problems Tailwind solved — browser inconsistencies, lack of a grid system, no design tokens — are mostly gone. What’s left is the utility-class API itself, which is a &lt;em&gt;preference&lt;/em&gt;, not a &lt;em&gt;solution&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;And in 2026, there’s a deeper reason to prefer vanilla CSS: code is AI-generated. Tailwind’s other pitch was developer speed — &lt;code dir=&quot;auto&quot;&gt;p-4 text-center&lt;/code&gt; is faster to type than writing a &lt;code dir=&quot;auto&quot;&gt;.card&lt;/code&gt; class and a CSS rule. But when AI generates your CSS, that friction is gone. The AI writes vanilla CSS just as easily as it writes Tailwind classes. The “faster to write” argument was a human-friction argument, and human friction is no longer the bottleneck. What matters now is what ships: vanilla CSS is zero-dependency, zero-build-step, and readable by anyone. Tailwind ships a build dependency and a translation layer. When code is free to produce, you optimize for what runs, not what’s easy to type.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;readability-cuts-both-ways&quot;&gt;Readability cuts both ways&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Tailwind’s pitch is that you don’t context-switch between HTML and CSS. Everything is in one place. That’s true — and for small components, it’s genuinely nice.&lt;/p&gt;
&lt;p&gt;Here’s the same stat card in Tailwind:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;div&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;/span&gt;&lt;span&gt;className&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-4 my-6&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;div&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;/span&gt;&lt;span&gt;className&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;bg-white dark:bg-surface border border-gray-200 rounded-xl p-5&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;span&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;/span&gt;&lt;span&gt;className&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;text-xl font-bold&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;span&gt;42&lt;/span&gt;&lt;span&gt;&lt;span&gt;&amp;#x3C;/&lt;/span&gt;&lt;span&gt;span&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;&lt;span&gt;&amp;#x3C;/&lt;/span&gt;&lt;span&gt;div&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;&amp;#x3C;/&lt;/span&gt;&lt;span&gt;div&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;And here it is in Dulak’s vanilla CSS:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;div&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;/span&gt;&lt;span&gt;className&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;stats&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;div&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;/span&gt;&lt;span&gt;className&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;stat-card&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;span&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;/span&gt;&lt;span&gt;className&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;stat-value&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;span&gt;42&lt;/span&gt;&lt;span&gt;&lt;span&gt;&amp;#x3C;/&lt;/span&gt;&lt;span&gt;span&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;&lt;span&gt;&amp;#x3C;/&lt;/span&gt;&lt;span&gt;div&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;&amp;#x3C;/&lt;/span&gt;&lt;span&gt;div&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The Tailwind version is longer, the class names are denser, and the responsive grid syntax is &lt;code dir=&quot;auto&quot;&gt;grid-cols-[repeat(auto-fit,minmax(180px,1fr))]&lt;/code&gt; — which is the CSS syntax wrapped in brackets. You’re writing CSS anyway, just through an abstraction layer.&lt;/p&gt;
&lt;p&gt;The vanilla version has a &lt;code dir=&quot;auto&quot;&gt;.stats&lt;/code&gt; class that maps to a 4-line CSS rule in &lt;code dir=&quot;auto&quot;&gt;Dashboard.css&lt;/code&gt;. Anyone who knows CSS can read it. Anyone who knows Tailwind has to learn the utility API first — &lt;code dir=&quot;auto&quot;&gt;gap-4&lt;/code&gt; means &lt;code dir=&quot;auto&quot;&gt;gap: 1rem&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;rounded-xl&lt;/code&gt; means &lt;code dir=&quot;auto&quot;&gt;border-radius: 0.75rem&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;my-6&lt;/code&gt; means &lt;code dir=&quot;auto&quot;&gt;margin: 1.5rem 0&lt;/code&gt;. It’s a translation layer between you and the CSS you’re already writing.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;when-tailwind-makes-sense&quot;&gt;When Tailwind makes sense&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;This is where the nuance matters. Dulak ships Tailwind templates because Tailwind has real advantages in specific situations:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Large teams&lt;/strong&gt; — a utility-class system enforces consistency. You can’t accidentally invent a new shade of blue when the palette is fixed. For 10+ developers, that constraint is worth the abstraction tax.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rapid prototyping&lt;/strong&gt; — when you’re iterating on a design and don’t want to name things yet, utility classes are faster. &lt;code dir=&quot;auto&quot;&gt;p-4 text-center font-bold&lt;/code&gt; is quicker than writing a &lt;code dir=&quot;auto&quot;&gt;.card-title&lt;/code&gt; class and a CSS file.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Designers who think in utilities&lt;/strong&gt; — some teams have internalized the Tailwind mental model and are genuinely faster with it. That’s a valid preference.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Dulak’s Tailwind templates use Tailwind v4 via &lt;code dir=&quot;auto&quot;&gt;@tailwindcss/cli&lt;/code&gt; — no PostCSS required. The existing CSS variables bridge cleanly to Tailwind’s theme tokens, and dark mode auto-switches via &lt;code dir=&quot;auto&quot;&gt;[data-theme]&lt;/code&gt;. The same auth, roles, SSR, and test suite work across both vanilla and Tailwind templates. It’s a preference, not a religion.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-philosophy-angle&quot;&gt;The philosophy angle&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Dulak’s philosophy is “zero-dependency where cheap” and “no abstraction tax.” Vanilla CSS is zero-dependency. The build step is zero-config. The CSS you write is the CSS that ships. Co-located CSS gives you the same locality benefit Tailwind sells — styles live next to the component — without the utility-class translation layer.&lt;/p&gt;
&lt;p&gt;The decision was locked on August 3, 2026, and the reasoning was simple: the boilerplate’s UI is small and already built. Tailwind’s speed advantage (composing utilities inline) pays off in projects you fork &lt;em&gt;into&lt;/em&gt;, not in the boilerplate you fork &lt;em&gt;from&lt;/em&gt;. Shipping vanilla CSS by default means one less opinion to undo if you disagree with it — and if you want Tailwind, you don’t migrate: you scaffold the Tailwind template from day one. &lt;code dir=&quot;auto&quot;&gt;--template react-tailwind&lt;/code&gt; is the same auth, SSR, and test suite with Tailwind already wired. There is no migration path because there’s nothing to migrate — the choice is made at &lt;code dir=&quot;auto&quot;&gt;bun create&lt;/code&gt;, not after.&lt;/p&gt;
&lt;p&gt;The default should be the thing that’s easiest to change your mind about — and with the template flag, changing your mind means scaffolding again, not rewriting stylesheets. Vanilla CSS wins that test.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-short-version&quot;&gt;The short version&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Vanilla CSS by default. Tailwind when you want it.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The default is zero-dependency, zero-build-step, readable by anyone who knows CSS. If you prefer Tailwind, you don’t migrate — you pick &lt;code dir=&quot;auto&quot;&gt;bun create dulak@latest my-app --template react-tailwind&lt;/code&gt; from day one and get the same app with Tailwind already wired. The choice is made at scaffold time, not after. Pick the default that’s cheapest to change — and with the template flag, both are one command away. See the &lt;a href=&quot;https://dulak.pages.dev/getting-started/installation/&quot;&gt;installation guide&lt;/a&gt; for all available templates.&lt;/p&gt;</content:encoded><category>css</category><category>philosophy</category><category>design</category></item><item><title>Your 2GB upload keeps failing. Here&apos;s the fix</title><link>https://dulak.pages.dev/blog/how-to-upload-files-over-100mb/</link><guid isPermaLink="true">https://dulak.pages.dev/blog/how-to-upload-files-over-100mb/</guid><description>Files over 100MB fail on a single request — dropped connections, proxy limits, timeouts. The fix is resumable, chunked uploads via tus. Here&apos;s how to wire it: server endpoints, client upload, resume, and progress.</description><pubDate>Sat, 08 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A 2GB video upload over a plain multipart form will fail. Not maybe — it will fail, usually at 90%, and the user starts over. The reasons are structural: a dropped connection kills the whole request, proxies cap body sizes, and one long request can time out.&lt;/p&gt;
&lt;p&gt;The fix is chunking: split the file, upload piece by piece, track progress, resume where you left off. The &lt;a href=&quot;https://tus.io/protocols/resumable-upload&quot;&gt;tus protocol&lt;/a&gt; does exactly this, and Bun has a spec-compliant implementation you can use as-is.&lt;/p&gt;
&lt;p&gt;This tutorial walks through the whole thing — server, client, resume, progress — on Dulak.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;step-1-server--tus-endpoints&quot;&gt;Step 1: server — tus endpoints&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Dulak ships tus at &lt;code dir=&quot;auto&quot;&gt;/uploads&lt;/code&gt; out of the box: creation, resume (HEAD), chunked append (PATCH), termination, checksums. Auth and ownership are enforced on every endpoint. There is nothing to install — it’s three flat modules, zero dependencies.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;&lt;/span&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;# already enabled — nothing to configure&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;curl&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;-X&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;OPTIONS&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;http://localhost:4000/uploads&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The server returns tus capabilities. Uploads land in &lt;code dir=&quot;auto&quot;&gt;UPLOAD_DIR&lt;/code&gt; (default &lt;code dir=&quot;auto&quot;&gt;./data/uploads&lt;/code&gt;), with one SQLite row per upload tracking offset, length, and owner.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;If you’re not on Dulak:&lt;/strong&gt; any tus server works — &lt;code dir=&quot;auto&quot;&gt;tus-node-server&lt;/code&gt;, &lt;code dir=&quot;auto&quot;&gt;tusd&lt;/code&gt;, or a cloud variant. The client code below is protocol-standard and works against any of them.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;step-2-client--a-zero-dependency-upload&quot;&gt;Step 2: client — a zero-dependency upload&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Dulak’s client doesn’t use a tus library — the protocol is simple enough to drive with plain &lt;code dir=&quot;auto&quot;&gt;fetch&lt;/code&gt;, and the &lt;a href=&quot;https://github.com/maulanashalihin/dulak/blob/main/src/client/pages/Profile.tsx&quot;&gt;profile avatar upload&lt;/a&gt; does exactly that. Zero dependencies, consistent with the “no abstraction tax” philosophy.&lt;/p&gt;
&lt;p&gt;The pattern has three moves: &lt;strong&gt;create&lt;/strong&gt; (POST), &lt;strong&gt;resume&lt;/strong&gt; (HEAD), &lt;strong&gt;append&lt;/strong&gt; (PATCH in a loop):&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;CHUNK_SIZE&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;5&lt;/span&gt;&lt;span&gt; * &lt;/span&gt;&lt;span&gt;1024&lt;/span&gt;&lt;span&gt; * &lt;/span&gt;&lt;span&gt;1024&lt;/span&gt;&lt;span&gt;; &lt;/span&gt;&lt;span&gt;// 5MB per request&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;/** tus `Upload-Metadata` values are standard base64. */&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;function&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;toBase64&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;s&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;bytes&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;TextEncoder&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;encode&lt;/span&gt;&lt;span&gt;(s);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;let &lt;/span&gt;&lt;span&gt;bin&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;&quot;&quot;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;for&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;b&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;of&lt;/span&gt;&lt;span&gt; bytes) bin &lt;/span&gt;&lt;span&gt;+=&lt;/span&gt;&lt;span&gt; String&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;fromCharCode&lt;/span&gt;&lt;span&gt;(b);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;btoa&lt;/span&gt;&lt;span&gt;(bin);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;async&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;function&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;createUpload&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;file&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&lt;span&gt;File&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Promise&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;res&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;fetch&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;/uploads&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;method: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;POST&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;headers: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Tus-Resumable&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;1.0.0&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Upload-Length&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;String&lt;/span&gt;&lt;span&gt;(file&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;size&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Upload-Metadata&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt;filename &lt;/span&gt;&lt;span&gt;${&lt;/span&gt;&lt;span&gt;toBase64&lt;/span&gt;&lt;span&gt;(file&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;name&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;,filetype &lt;/span&gt;&lt;span&gt;${&lt;/span&gt;&lt;span&gt;toBase64&lt;/span&gt;&lt;span&gt;(file&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;type&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;!&lt;/span&gt;&lt;span&gt;res&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;ok&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;throw&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Error&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt;Create failed (HTTP &lt;/span&gt;&lt;span&gt;${&lt;/span&gt;&lt;span&gt;res&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;status&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;location&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;res&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;headers&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;get&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Location&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;!&lt;/span&gt;&lt;span&gt;location) &lt;/span&gt;&lt;span&gt;throw&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Error&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Server did not return an upload URL&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; location&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;split&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;/&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;pop&lt;/span&gt;&lt;span&gt;() &lt;/span&gt;&lt;span&gt;??&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;&quot;&lt;/span&gt;&lt;span&gt;; &lt;/span&gt;&lt;span&gt;// the upload id&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;async&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;function&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;getOffset&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;uploadId&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Promise&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;number&lt;/span&gt;&lt;span&gt;&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;res&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;fetch&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt;/uploads/&lt;/span&gt;&lt;span&gt;${&lt;/span&gt;&lt;span&gt;uploadId&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;method: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;HEAD&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;headers: { &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Tus-Resumable&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;1.0.0&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; }&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;!&lt;/span&gt;&lt;span&gt;res&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;ok&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Number&lt;/span&gt;&lt;span&gt;(res&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;headers&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;get&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Upload-Offset&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)) &lt;/span&gt;&lt;span&gt;||&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;0&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;async&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;function&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;runUpload&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;file&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&lt;span&gt;File&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;uploadId&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;createUpload&lt;/span&gt;&lt;span&gt;(file);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;let &lt;/span&gt;&lt;span&gt;offset&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;getOffset&lt;/span&gt;&lt;span&gt;(uploadId); &lt;/span&gt;&lt;span&gt;// 0 on a fresh upload&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;bytes&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Uint8Array&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;await &lt;/span&gt;&lt;span&gt;file&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;arrayBuffer&lt;/span&gt;&lt;span&gt;());&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;while&lt;/span&gt;&lt;span&gt; (offset &lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt; bytes&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;byteLength&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;end&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;Math&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;min&lt;/span&gt;&lt;span&gt;(offset&lt;/span&gt;&lt;span&gt; + &lt;/span&gt;&lt;span&gt;CHUNK_SIZE&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;bytes&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;byteLength&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;res&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;fetch&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt;/uploads/&lt;/span&gt;&lt;span&gt;${&lt;/span&gt;&lt;span&gt;uploadId&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;method: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;PATCH&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;headers: {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Tus-Resumable&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;1.0.0&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Content-Type&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;application/offset+octet-stream&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;        &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Upload-Offset&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;String&lt;/span&gt;&lt;span&gt;(offset)&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;      &lt;/span&gt;&lt;/span&gt;&lt;span&gt;body: &lt;/span&gt;&lt;span&gt;bytes&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;slice&lt;/span&gt;&lt;span&gt;(offset&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;end)&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;!&lt;/span&gt;&lt;span&gt;res&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;ok&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;throw&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Error&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt;Chunk failed (HTTP &lt;/span&gt;&lt;span&gt;${&lt;/span&gt;&lt;span&gt;res&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;status&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;offset &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; end;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;console&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;log&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt;Uploaded &lt;/span&gt;&lt;span&gt;${&lt;/span&gt;&lt;span&gt;Math&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;round&lt;/span&gt;&lt;span&gt;((offset&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;/&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;bytes&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;byteLength&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;*&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;100&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;%&lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; uploadId;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;That’s the whole upload: create once, then PATCH chunks until the offset reaches the file length. Each PATCH is a small, proxy-safe request.&lt;/p&gt;
&lt;div&gt;&lt;h3 id=&quot;why-5mb-chunks&quot;&gt;Why 5MB chunks?&lt;/h3&gt;&lt;/div&gt;
&lt;p&gt;Two constraints meet here:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Proxy limits.&lt;/strong&gt; &lt;a href=&quot;https://dulak.pages.dev/uploads/tus/&quot;&gt;Cloudflare caps request bodies at 100MB&lt;/a&gt; — a single request over that is rejected at the edge. Chunks must stay well under it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Failure cost.&lt;/strong&gt; A dropped connection re-sends only the in-flight chunk. 5MB is small enough that the retry is cheap, large enough that HTTP overhead per chunk stays negligible.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Anything from 1MB to 10MB works; 5MB is a sane default. (Dulak’s avatar upload uses 256KB chunks — fine for small files, too many requests for a 1GB one.)&lt;/p&gt;
&lt;aside&gt;&lt;p&gt;&lt;strong&gt;Prefer a library?&lt;/strong&gt; &lt;code dir=&quot;auto&quot;&gt;tus-js-client&lt;/code&gt; wraps all of this — same protocol, same server, &lt;code dir=&quot;auto&quot;&gt;Upload&lt;/code&gt; + &lt;code dir=&quot;auto&quot;&gt;onProgress&lt;/code&gt; callbacks. The code above is what Dulak actually ships; use the library if you’d rather not own the protocol.&lt;/p&gt;&lt;/aside&gt;
&lt;div&gt;&lt;h2 id=&quot;step-3-resume-after-interruption&quot;&gt;Step 3: resume after interruption&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;This is the whole point of tus. When the connection drops, the upload doesn’t die — it stops, and you can continue. Two pieces make that work:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. Remember the pending upload.&lt;/strong&gt; On creation, save the upload id — on the next page load, the UI offers “Resume upload” instead of starting over:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;PENDING_KEY&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;dulak:avatar:upload&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;type&lt;/span&gt;&lt;span&gt; PendingUpload &lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt; { id&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;; name&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;; size&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;number&lt;/span&gt;&lt;span&gt; };&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// after createUpload() returns the id:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;localStorage&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;setItem&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;PENDING_KEY&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;JSON&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;stringify&lt;/span&gt;&lt;span&gt;({ id: uploadId, name: file&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;name&lt;/span&gt;&lt;span&gt;, size: file&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;size&lt;/span&gt;&lt;span&gt; }));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;2. Resume from the saved id.&lt;/strong&gt; &lt;code dir=&quot;auto&quot;&gt;getOffset()&lt;/code&gt; re-reads the server’s &lt;code dir=&quot;auto&quot;&gt;Upload-Offset&lt;/code&gt;, and the PATCH loop continues from there:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;async&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;function&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;runUpload&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;uploadId&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;file&lt;/span&gt;&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&lt;span&gt;File&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;let &lt;/span&gt;&lt;span&gt;offset&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;getOffset&lt;/span&gt;&lt;span&gt;(uploadId); &lt;/span&gt;&lt;span&gt;// server says where we stopped&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;bytes&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;new&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Uint8Array&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;await &lt;/span&gt;&lt;span&gt;file&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;arrayBuffer&lt;/span&gt;&lt;span&gt;());&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;while&lt;/span&gt;&lt;span&gt; (offset &lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt; bytes&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;byteLength&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;// ... PATCH loop, same as Step 2&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// On file re-selection, if it matches the pending upload:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;pending&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;JSON&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;parse&lt;/span&gt;&lt;span&gt;(localStorage&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;getItem&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;PENDING_KEY&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; ?? &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;null&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (pending &lt;/span&gt;&lt;span&gt;&amp;#x26;&amp;#x26;&lt;/span&gt;&lt;span&gt; pending&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;name&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;===&lt;/span&gt;&lt;span&gt; file&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;name&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;runUpload&lt;/span&gt;&lt;span&gt;(pending&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;id&lt;/span&gt;&lt;span&gt;, file); &lt;/span&gt;&lt;span&gt;// resume — no restart&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;} &lt;/span&gt;&lt;span&gt;else&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;id&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;createUpload&lt;/span&gt;&lt;span&gt;(file);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;localStorage&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;setItem&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;PENDING_KEY&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;JSON&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;stringify&lt;/span&gt;&lt;span&gt;({ id, name: file&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;name&lt;/span&gt;&lt;span&gt;, size: file&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;size&lt;/span&gt;&lt;span&gt; }));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;runUpload&lt;/span&gt;&lt;span&gt;(id, file);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The server is the source of truth for the offset — the client never guesses. A dropped connection at 90% means re-sending only the last 5MB chunk, not the whole file.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;step-4-progress-that-survives-reloads&quot;&gt;Step 4: progress that survives reloads&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The progress bar is easy; making it survive a refresh is the useful part. The offset comes from the server, so the bar snaps to the true position on resume:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// inside the PATCH loop:&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;setProgress&lt;/span&gt;&lt;span&gt;(Math&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;round&lt;/span&gt;&lt;span&gt;((offset &lt;/span&gt;&lt;span&gt;/&lt;/span&gt;&lt;span&gt; bytes&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;byteLength&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;*&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;100&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// on resume, getOffset() returns the real progress before the loop starts&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Because the server tracks &lt;code dir=&quot;auto&quot;&gt;Upload-Offset&lt;/code&gt; in SQLite, a page reload doesn’t lose the position — the HEAD request in &lt;code dir=&quot;auto&quot;&gt;getOffset()&lt;/code&gt; restores it. Clear &lt;code dir=&quot;auto&quot;&gt;PENDING_KEY&lt;/code&gt; when the upload completes and link the file (Dulak POSTs the upload id to &lt;code dir=&quot;auto&quot;&gt;/profile/avatar&lt;/code&gt;).&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;step-5-verify-with-network-throttling&quot;&gt;Step 5: verify with network throttling&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Test the resume path before shipping — it’s the part that’s easy to get wrong:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Open DevTools → Network → throttling, pick “Slow 3G”&lt;/li&gt;
&lt;li&gt;Start uploading a 200MB file&lt;/li&gt;
&lt;li&gt;Kill the connection mid-upload (toggle to Offline)&lt;/li&gt;
&lt;li&gt;Re-enable the network, resume&lt;/li&gt;
&lt;li&gt;Confirm the upload continues from where it stopped, not from zero&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;If it restarts, the server offset and the client’s saved URL don’t match — check that &lt;code dir=&quot;auto&quot;&gt;uploadUrl&lt;/code&gt; is being persisted and passed back correctly.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;when-you-dont-need-any-of-this&quot;&gt;When you don’t need any of this&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;If your files are under ~100MB and the connection is reliable, a plain multipart form is simpler and correct:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;form&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;c&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;req&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;formData&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;file&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;form&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;get&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;file&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; Bun&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;write&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt;${&lt;/span&gt;&lt;span&gt;config&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;upload&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;dir&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;/&lt;/span&gt;&lt;span&gt;${&lt;/span&gt;&lt;span&gt;file&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;name&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt;, file);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Ten lines, no client library, no protocol. The &lt;a href=&quot;https://dulak.pages.dev/uploads/form-data/&quot;&gt;form data guide&lt;/a&gt; covers it. Use tus only when the file is big enough that a failed upload costs real time — that’s the ~100MB line.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-short-version&quot;&gt;The short version&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Small files: form data. Files over ~100MB: tus.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Server: shipped in Dulak at &lt;code dir=&quot;auto&quot;&gt;/uploads&lt;/code&gt; — nothing to install. Client: three &lt;code dir=&quot;auto&quot;&gt;fetch&lt;/code&gt; calls — POST to create, HEAD to get the offset, PATCH in a loop — zero dependencies, exactly what Dulak’s own avatar upload does. Resume: save the upload id, re-read the offset via HEAD. Progress: &lt;code dir=&quot;auto&quot;&gt;bytesSent / bytesTotal&lt;/code&gt; from the PATCH loop. Test with network throttling before you trust it. The &lt;a href=&quot;https://dulak.pages.dev/uploads/tus/&quot;&gt;tus docs&lt;/a&gt; have the full protocol and configuration reference.&lt;/p&gt;</content:encoded><category>uploads</category><category>how-to</category></item><item><title>Your database is already the cache</title><link>https://dulak.pages.dev/blog/why-no-cache-service/</link><guid isPermaLink="true">https://dulak.pages.dev/blog/why-no-cache-service/</guid><description>SQLite with WAL mode serves 526K reads/s and 95K writes/s direct — 52K reads/s even through the full HTTP stack. Adding Redis would be a dependency that solves a problem Dulak doesn&apos;t have.</description><pubDate>Sat, 08 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Every time someone evaluates Dulak, the same question comes up: “Where’s the cache layer?”&lt;/p&gt;
&lt;p&gt;The answer is: you’re looking at it. It’s the database.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;sqlite-is-already-fast-enough&quot;&gt;SQLite is already fast enough&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;bun:sqlite with WAL mode serves &lt;strong&gt;526,000 reads per second&lt;/strong&gt; and &lt;strong&gt;95,000 writes per second&lt;/strong&gt; direct — the raw engine numbers, measured on a MacBook Pro (M4). Through the full HTTP stack — auth, sessions, and request routing included — that’s still &lt;strong&gt;52,000 reads per second&lt;/strong&gt; and 19,600 creates per second. The database is not the bottleneck at any layer.&lt;/p&gt;
&lt;p&gt;For comparison, a typical Redis deployment over the network adds 0.5–1ms of latency per round trip. SQLite reads from a local file with WAL cache — no network hop, no serialization, no connection pool. The database &lt;em&gt;is&lt;/em&gt; the cache.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;prepared-statements-are-already-cached&quot;&gt;Prepared statements are already cached&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Dulak creates all prepared statements once at module load in &lt;code dir=&quot;auto&quot;&gt;db.ts&lt;/code&gt;:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;stmts&lt;/span&gt;&lt;span&gt; = {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;getUserById: &lt;/span&gt;&lt;span&gt;db&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;query&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;SELECT * FROM users WHERE id = ?&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;getSession: &lt;/span&gt;&lt;span&gt;db&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;query&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;SELECT * FROM sessions WHERE token = ?&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;// ...&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;These are reused for every request. The query plan is compiled once and cached in memory. This is the same caching layer that an ORM provides — except it’s zero-dependency and you can see the exact SQL.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;cache-invalidation-is-a-business-logic-problem&quot;&gt;Cache invalidation is a business-logic problem&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A generic cache service doesn’t know what to cache. It doesn’t know which queries are expensive, which results change frequently, and which can be safely stale. Only &lt;em&gt;your&lt;/em&gt; business logic knows that.&lt;/p&gt;
&lt;p&gt;A cache that caches the wrong thing is worse than no cache at all. It serves stale data, causes confusing bugs, and adds a debugging layer between your code and your database. Dulak’s philosophy is “no abstraction tax” — a cache service you haven’t needed yet is exactly that tax.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;when-you-actually-need-a-cache&quot;&gt;When you actually need a cache&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Dulak doesn’t ship a cache because most apps don’t need one at launch. But there are legitimate cases:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Read-heavy workloads with expensive queries&lt;/strong&gt; — complex joins or aggregations that can’t be optimized further. An in-memory LRU for specific query results makes sense.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Outgrowing SQLite&lt;/strong&gt; — if you move to Postgres with a connection pool, Redis for session storage or query caching may help.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;External API rate limits&lt;/strong&gt; — caching third-party API responses to avoid hitting rate limits.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These are all &lt;strong&gt;post-launch decisions&lt;/strong&gt;. You build, you ship, you measure, and &lt;em&gt;then&lt;/em&gt; you add a cache where the data tells you to. Not before.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-real-question&quot;&gt;The real question&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;The real question isn’t “why no cache service?” It’s: &lt;strong&gt;what problem would it solve that SQLite doesn’t already solve?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;If your answer is “I might need it someday” — that’s not a problem. That’s a maybe. Dulak ships solutions to real problems, not maybes.&lt;/p&gt;</content:encoded><category>architecture</category><category>sqlite</category><category>philosophy</category></item><item><title>Your SPA needs a router, an API, and state. Inertia needs none of it</title><link>https://dulak.pages.dev/blog/why-inertia-not-spa/</link><guid isPermaLink="true">https://dulak.pages.dev/blog/why-inertia-not-spa/</guid><description>Inertia gives you SPA feel without SPA architecture. No client-side router, no API layer, no duplicate validation. The server drives the UI; the client is a template engine.</description><pubDate>Sat, 08 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Every time someone evaluates Dulak, the same question comes up: “Where’s the client-side router? Where’s the API layer? Where’s Redux?”&lt;/p&gt;
&lt;p&gt;The answer is: you don’t need them. And you probably never did.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;what-a-spa-actually-costs&quot;&gt;What a SPA actually costs&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;A Single Page Application is not “a fast website.” It’s an architecture, and that architecture has a price. Here’s what you build when you commit to a SPA:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;A client-side router&lt;/strong&gt; — React Router, TanStack Router, Vue Router. You define routes on the client &lt;em&gt;and&lt;/em&gt; on the server. Two routing systems, two sources of truth, two things that can disagree.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;An API layer&lt;/strong&gt; — REST endpoints or GraphQL resolvers. The server stops rendering HTML and becomes a JSON factory. You design the API, version it, document it, and consume it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Client-side state management&lt;/strong&gt; — Redux, Zustand, Pinia. The client fetches data from the API, stores it, caches it, invalidates it, and synchronizes it. This is an entire application running in the browser.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Client-side validation&lt;/strong&gt; — you validate on the client for UX, then validate again on the server because you can’t trust the client. Two validation systems, two sets of rules, two things that drift.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A server that’s just an API&lt;/strong&gt; — no rendering, no templating, no HTML. Just JSON in, JSON out.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That’s two codebases. Client routing &lt;em&gt;and&lt;/em&gt; server routing. Client validation &lt;em&gt;and&lt;/em&gt; server validation. Client state &lt;em&gt;and&lt;/em&gt; server state. You’re maintaining two applications that talk to each other over a wire protocol you invented.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;inertia-is-not-a-spa&quot;&gt;Inertia is not a SPA&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Inertia gives you the thing you actually want from a SPA — no full page reloads — without the architecture you don’t. Here’s how Dulak uses it:&lt;/p&gt;
&lt;p&gt;The server decides what page to show. The server decides what data to pass. The client receives a page payload and renders it. That’s it.&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// pages.routes.ts — the server decides everything&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;app&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;get&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;/dashboard&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, requireAuth, &lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;c&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;c&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;inertia&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;render&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Dashboard&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, { stats: &lt;/span&gt;&lt;span&gt;dashboardStats&lt;/span&gt;&lt;span&gt;() }),&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;One line. The server checks auth, queries the database, builds the props, and tells Inertia to render the &lt;code dir=&quot;auto&quot;&gt;Dashboard&lt;/code&gt; component with those props. No API endpoint to design. No fetch call to write. No state to manage. The route handler &lt;em&gt;is&lt;/em&gt; the controller and the data layer.&lt;/p&gt;
&lt;p&gt;When the user navigates, Inertia makes an XHR request with the &lt;code dir=&quot;auto&quot;&gt;X-Inertia&lt;/code&gt; header. The server responds with a JSON page payload instead of full HTML. Inertia swaps the component on the client. No page reload, no flicker, no white screen. The user experience is indistinguishable from a SPA.&lt;/p&gt;
&lt;p&gt;When the user does a hard refresh or lands from an external link, the server renders full HTML with SSR and the client hydrates. Same route, same handler, same code path — the adapter handles the difference.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;no-client-side-router&quot;&gt;No client-side router&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Dulak’s client entry point is 27 lines:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;span&gt;app.tsx&lt;/span&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;createInertiaApp&lt;/span&gt;&lt;span&gt;({&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;id: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;app&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;resolve,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;setup&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&lt;span&gt;{ &lt;/span&gt;&lt;span&gt;el&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;App&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;props&lt;/span&gt;&lt;span&gt; }&lt;/span&gt;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;const&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;element&lt;/span&gt;&lt;span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;App&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;span&gt;...&lt;/span&gt;&lt;span&gt;props&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt; /&gt;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (el&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;hasAttribute&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;data-server-rendered&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;)) {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;hydrateRoot&lt;/span&gt;&lt;span&gt;(el, element);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;} &lt;/span&gt;&lt;span&gt;else&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;      &lt;/span&gt;&lt;span&gt;createRoot&lt;/span&gt;&lt;span&gt;(el)&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;render&lt;/span&gt;&lt;span&gt;(element);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;title&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;title&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; title &lt;/span&gt;&lt;span&gt;?&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt;${&lt;/span&gt;&lt;span&gt;title&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt; — Dulak&lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Dulak&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;});&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;There is no router here. No route definitions, no route guards, no lazy-loaded route chunks, no &lt;code dir=&quot;auto&quot;&gt;&amp;#x3C;Outlet&gt;&lt;/code&gt; components. The page registry is a plain object mapping component names to modules — that’s it:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;export const &lt;/span&gt;&lt;span&gt;pages&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Record&lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;string&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;PageModule&lt;/span&gt;&lt;span&gt;&gt; = {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;./pages/Dashboard.tsx&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;: { default: &lt;/span&gt;&lt;span&gt;Dashboard&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;./pages/Login.tsx&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;: { default: &lt;/span&gt;&lt;span&gt;Login&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;// ...&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;Routing lives on the server, where it belongs. The server already knows the URL, the session, the user’s role, and the database state. Why duplicate that logic on the client? In Dulak, a route guard is a server-side middleware:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;app&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;get&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;/admin&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;requireRole&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;admin&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;), &lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;c&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;c&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;var&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;inertia&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;render&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Admin&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;, { users }),&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;If you’re not an admin, you never get the page. No client-side redirect, no flash of unauthorized content, no route guard to keep in sync with the server. The server is the source of truth.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;no-api-layer&quot;&gt;No API layer&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;In a SPA, the server exposes endpoints and the client consumes them. You design the contract, serialize the data, handle pagination, deal with loading states and error states.&lt;/p&gt;
&lt;p&gt;In Dulak, the server passes props directly to the page component. The &lt;code dir=&quot;auto&quot;&gt;Dashboard&lt;/code&gt; component receives &lt;code dir=&quot;auto&quot;&gt;stats&lt;/code&gt; as a prop — the same way a server-rendered template receives variables. No &lt;code dir=&quot;auto&quot;&gt;useEffect&lt;/code&gt;, no &lt;code dir=&quot;auto&quot;&gt;fetch&lt;/code&gt;, no &lt;code dir=&quot;auto&quot;&gt;useState&lt;/code&gt; for loading:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;export&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;default&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;function&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;Dashboard&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&lt;span&gt;{ &lt;/span&gt;&lt;span&gt;stats&lt;/span&gt;&lt;span&gt; }&lt;/span&gt;&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; { stats&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;DashboardStats&lt;/span&gt;&lt;span&gt; }&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;// stats is just here. No fetching, no loading state.&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;div&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;span&gt;stats&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;userCount&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt; users&lt;/span&gt;&lt;span&gt;&lt;span&gt;&amp;#x3C;/&lt;/span&gt;&lt;span&gt;div&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;For form submissions, Inertia’s &lt;code dir=&quot;auto&quot;&gt;useForm&lt;/code&gt; helper handles the POST and the response. The server validates, processes, and redirects. The client doesn’t need to know the API shape — there is no API:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const { &lt;/span&gt;&lt;span&gt;data&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;setData&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;post&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;processing&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;errors&lt;/span&gt;&lt;span&gt; } = &lt;/span&gt;&lt;span&gt;useForm&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;email: &lt;/span&gt;&lt;span&gt;&apos;&apos;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;password: &lt;/span&gt;&lt;span&gt;&apos;&apos;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;
&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;submit&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;e&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&lt;span&gt;FormEvent&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;&lt;span&gt; =&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;e&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;preventDefault&lt;/span&gt;&lt;span&gt;()&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;post&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;/login&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;;  &lt;/span&gt;&lt;span&gt;// that&apos;s a route, not an API endpoint&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;&lt;code dir=&quot;auto&quot;&gt;post(&apos;/login&apos;)&lt;/code&gt; hits the same Hono route that serves the login page. No API contract to maintain.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;validation-in-one-place&quot;&gt;Validation in one place&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;In a SPA, you validate on the client (for instant feedback) and on the server (because you can’t trust the client). Two schemas, two rule sets, two things that drift out of sync.&lt;/p&gt;
&lt;p&gt;Dulak validates once — on the server, with TypeBox:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;registerBody&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;t&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;Object&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;name: &lt;/span&gt;&lt;span&gt;t&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;String&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{ minLength: &lt;/span&gt;&lt;span&gt;2&lt;/span&gt;&lt;span&gt;, maxLength: &lt;/span&gt;&lt;span&gt;80&lt;/span&gt;&lt;span&gt; }&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;email: &lt;/span&gt;&lt;span&gt;t&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;String&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{ format: &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;email&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; }&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;password: &lt;/span&gt;&lt;span&gt;t&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;String&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;{ minLength: &lt;/span&gt;&lt;span&gt;8&lt;/span&gt;&lt;span&gt;, maxLength: &lt;/span&gt;&lt;span&gt;72&lt;/span&gt;&lt;span&gt; }&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;},&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;{ additionalProperties: &lt;/span&gt;&lt;span&gt;false&lt;/span&gt;&lt;span&gt; },&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;When validation fails, the server returns a 422 with field errors in the Inertia page payload. The client displays them via &lt;code dir=&quot;auto&quot;&gt;errors&lt;/code&gt; from &lt;code dir=&quot;auto&quot;&gt;useForm&lt;/code&gt;:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;Field&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;id&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;email&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;label&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Email&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;error&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;span&gt;errors&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;email&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;&gt;&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;One schema. One validation pass. One set of error messages. The client doesn’t validate — it just displays what the server says. If you change a rule (password minimum from 8 to 12), you change it in one file. Not two.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;ssr-is-in-process-not-a-separate-server&quot;&gt;SSR is in-process, not a separate server&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Most SSR setups run a separate Node process for server-side rendering. You deploy your API server, your SSR server, and your static assets. Three things to manage, three things that can disagree on versions.&lt;/p&gt;
&lt;p&gt;Dulak renders React on the server &lt;em&gt;in the same Bun process&lt;/em&gt; that handles HTTP:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;// ssr.tsx — runs inside the Hono process&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;export&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;async&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;function&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;renderPage&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;page&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&lt;span&gt;Page&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;createInertiaApp&lt;/span&gt;&lt;span&gt;({&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;page&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;    &lt;/span&gt;&lt;/span&gt;&lt;span&gt;render: renderToString&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;resolve&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;name&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; pages[&lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt;./pages/&lt;/span&gt;&lt;span&gt;${&lt;/span&gt;&lt;span&gt;name&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;.tsx&lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;span&gt;?.&lt;/span&gt;&lt;span&gt;default&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;??&lt;/span&gt;&lt;span&gt; notFoundPage&lt;/span&gt;&lt;span&gt;!&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;setup&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&lt;span&gt;{ &lt;/span&gt;&lt;span&gt;App&lt;/span&gt;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;props&lt;/span&gt;&lt;span&gt; }&lt;/span&gt;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&amp;#x3C;&lt;/span&gt;&lt;span&gt;App&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;span&gt;...&lt;/span&gt;&lt;span&gt;props&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt; /&gt;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;title&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;title&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;=&gt;&lt;/span&gt;&lt;span&gt; title &lt;/span&gt;&lt;span&gt;?&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt;${&lt;/span&gt;&lt;span&gt;title&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt; — Dulak&lt;/span&gt;&lt;span&gt;`&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;Dulak&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;});&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;The &lt;code dir=&quot;auto&quot;&gt;render()&lt;/code&gt; method in the Inertia adapter decides what to do based on the request:&lt;/p&gt;
&lt;div&gt;&lt;figure&gt;&lt;figcaption&gt;&lt;/figcaption&gt;&lt;pre&gt;&lt;code&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;async &lt;/span&gt;&lt;span&gt;render&lt;/span&gt;&lt;span&gt;(component, props, options) {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;page&lt;/span&gt;&lt;span&gt; = &lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;page&lt;/span&gt;&lt;span&gt;(component&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;props);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;isXhr&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;// Inertia client request → JSON payload&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;json&lt;/span&gt;&lt;span&gt;(page, options&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;status&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;??&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;200&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (config&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;ssr&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;&amp;#x26;&amp;#x26;&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;!&lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;c&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;user&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;// Browser visit, public route → full SSR HTML&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;const &lt;/span&gt;&lt;span&gt;rendered&lt;/span&gt;&lt;span&gt; = await &lt;/span&gt;&lt;span&gt;renderPage&lt;/span&gt;&lt;span&gt;(page);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;    &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;html&lt;/span&gt;&lt;span&gt;(rendered&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;head&lt;/span&gt;&lt;span&gt;, rendered&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;body&lt;/span&gt;&lt;span&gt;, options&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;status&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;??&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;200&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;&lt;span&gt;  &lt;/span&gt;&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;// Authenticated route → empty shell + JSON, client renders from scratch&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;  &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;html&lt;/span&gt;&lt;span&gt;([], &lt;/span&gt;&lt;span&gt;this&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;clientBody&lt;/span&gt;&lt;span&gt;(page), options&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;status&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;??&lt;/span&gt;&lt;span&gt; &lt;/span&gt;&lt;span&gt;200&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;div&gt;&lt;div&gt;&lt;span&gt;}&lt;/span&gt;&lt;/div&gt;&lt;/div&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;/div&gt;
&lt;p&gt;No separate SSR server. No hydration mismatch debugging across processes. One process, one deployment, one thing to manage. And Dulak skips SSR for authenticated routes — no SEO benefit behind a login wall, and the client hydrates and replaces server HTML anyway. Public pages get full SSR for crawlers; authenticated pages ship an empty shell. Less server CPU, same UX.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-mental-model-server-driven-ui&quot;&gt;The mental model: server-driven UI&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;Here’s the shift in thinking: &lt;strong&gt;Inertia is server-driven UI.&lt;/strong&gt; The server is the application. The client is a template engine.&lt;/p&gt;
&lt;p&gt;In a SPA, the client is the application. It fetches data, manages state, handles routing, and renders. The server is a data API.&lt;/p&gt;
&lt;p&gt;In Dulak, the server is the application. It handles routing, auth, validation, database queries, and decides what page to render with what data. The client receives a component name and props, and renders them. That’s the client’s entire job.&lt;/p&gt;
&lt;p&gt;This is why Dulak doesn’t need Redux, Zustand, React Router, or an API layer. Those tools solve problems that Inertia’s architecture doesn’t create.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;when-a-spa-actually-makes-sense&quot;&gt;When a SPA actually makes sense&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;This isn’t a one-sided argument. SPAs are the right choice for certain apps:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Rich interactive apps&lt;/strong&gt; — Figma, Google Maps, video editors. These apps have complex client-side state (canvas, drag-and-drop, real-time collaboration) that doesn’t map to page transitions. The client &lt;em&gt;is&lt;/em&gt; the application.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Edge-deployed apps&lt;/strong&gt; — if you’re running on Cloudflare Workers or Vercel Edge, you might not have a long-lived server process. A SPA with an API backend fits that model.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Offline-first apps&lt;/strong&gt; — if your app needs to work without a network connection, you need client-side state, client-side storage, and client-side sync logic. Inertia doesn’t help here.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Dulak is for server-rendered web apps — dashboards, admin panels, SaaS products, content sites. Apps where the server is the source of truth and pages are rendered from database state. If you’re building Figma, you’re in the wrong place.&lt;/p&gt;
&lt;div&gt;&lt;h2 id=&quot;the-short-version&quot;&gt;The short version&lt;/h2&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Inertia until you need a canvas.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The SPA architecture exists to solve specific problems: rich client-side interaction, edge deployment, offline support. If you don’t have those problems, you’re paying the SPA tax — two routers, two validators, an API layer, and a state management library — for a navigation feel you get for free with Inertia.&lt;/p&gt;
&lt;p&gt;Dulak ships one router (server), one validator (server, TypeBox), one state source (server, props), and SSR in the same process. The client renders what the server tells it to render. See the &lt;a href=&quot;https://dulak.pages.dev/architecture/request-lifecycle/&quot;&gt;request lifecycle&lt;/a&gt; for the full flow.&lt;/p&gt;</content:encoded><category>inertia</category><category>architecture</category><category>philosophy</category></item></channel></rss>