<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ryota.I</title>
    <description>The latest articles on DEV Community by Ryota.I (@paveg).</description>
    <link>https://dev.to/paveg</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F45552%2Fa0abc5b9-3298-49d5-b876-2c4589091d9f.jpg</url>
      <title>DEV Community: Ryota.I</title>
      <link>https://dev.to/paveg</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/paveg"/>
    <language>en</language>
    <item>
      <title>Stop Double-Charging Users: Idempotency Middleware for Hono</title>
      <dc:creator>Ryota.I</dc:creator>
      <pubDate>Fri, 27 Feb 2026 10:15:56 +0000</pubDate>
      <link>https://dev.to/paveg/stop-double-charging-users-idempotency-middleware-for-hono-2p9o</link>
      <guid>https://dev.to/paveg/stop-double-charging-users-idempotency-middleware-for-hono-2p9o</guid>
      <description>&lt;p&gt;A user taps "Pay" on their phone. The request times out.They tap again.&lt;/p&gt;

&lt;p&gt;Your server happily processes both — and charges them twice.&lt;/p&gt;

&lt;p&gt;This isn't a hypothetical. Mobile retries, load balancer re-sends, impatient double-clicks — any of these can trigger duplicate POST requests.&lt;br&gt;
Stripe solved this years ago with the &lt;code&gt;Idempotency-Key&lt;/code&gt; header: send the same key twice, get the same response without re-executing the handler.&lt;/p&gt;

&lt;p&gt;Hono didn't have this. So I built it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Quick Start
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;hono-idempotency
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Hono&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hono&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;idempotency&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hono-idempotency&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;memoryStore&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hono-idempotency/stores/memory&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Hono&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/*&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;idempotency&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;store&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;memoryStore&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/payments&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Runs once per unique Idempotency-Key.&lt;/span&gt;
  &lt;span class="c1"&gt;// Retries return the cached response.&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pay_123&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;succeeded&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;201&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The second request with the same key skips the handler entirely and returns the cached response with an &lt;code&gt;Idempotency-Replayed: true&lt;/code&gt; header.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# First request — handler executes&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://localhost:3000/api/payments &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Idempotency-Key: abc-123"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"amount": 1000}'&lt;/span&gt;
&lt;span class="c"&gt;# =&amp;gt; 201 {"id":"pay_123","status":"succeeded"}&lt;/span&gt;

&lt;span class="c"&gt;# Second request — cached response&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://localhost:3000/api/payments &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Idempotency-Key: abc-123"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"amount": 1000}'&lt;/span&gt;
&lt;span class="c"&gt;# =&amp;gt; 201 {"id":"pay_123","status":"succeeded"}&lt;/span&gt;
&lt;span class="c"&gt;# (Idempotency-Replayed: true)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why Not Just Write It Yourself (or Ask AI)?
&lt;/h2&gt;

&lt;p&gt;You could ask an LLM to generate idempotency middleware, or write it from scratch. You'd get something functional quickly. But in application development, there's no reason to reinvent the wheel when a reliable, transparent solution already exists.&lt;/p&gt;

&lt;p&gt;A well-tested library with full source code, 100% test coverage, and an open commit history gives you something that one-off code doesn't: &lt;strong&gt;confidence that edge cases have been found and fixed.&lt;/strong&gt; Here are some that are easy to miss:&lt;/p&gt;

&lt;h3&gt;
  
  
  Set-Cookie leaks across users
&lt;/h3&gt;

&lt;p&gt;When replaying a cached response, you must exclude &lt;code&gt;Set-Cookie&lt;/code&gt; headers. Otherwise, User B retrying with the same key gets User A's session cookie — a security vulnerability that only surfaces in multi-user replay scenarios.&lt;/p&gt;

&lt;h3&gt;
  
  
  Non-2xx responses must not be cached
&lt;/h3&gt;

&lt;p&gt;If a payment fails with a 500, should the client get 500 forever? Following Stripe's pattern, the key is deleted on non-2xx so the client can retry. Caching failures locks users out of recovery.&lt;/p&gt;

&lt;h3&gt;
  
  
  Key injection via delimiters
&lt;/h3&gt;

&lt;p&gt;Store keys use &lt;code&gt;:&lt;/code&gt; as a delimiter: &lt;code&gt;POST:/api/payments:user-key-123&lt;/code&gt;. A malicious key like &lt;code&gt;evil:POST:/api/admin&lt;/code&gt; could collide with other routes or tenants. &lt;code&gt;encodeURIComponent&lt;/code&gt; prevents this — a subtle attack vector that rarely shows up in generated or hand-written code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hook error isolation
&lt;/h3&gt;

&lt;p&gt;Observability hooks (&lt;code&gt;onCacheHit&lt;/code&gt; / &lt;code&gt;onCacheMiss&lt;/code&gt;) should never break idempotency guarantees. The middleware wraps all hooks in a &lt;code&gt;safeHook()&lt;/code&gt; that swallows errors — hooks are for observability, not control flow.&lt;/p&gt;

&lt;h3&gt;
  
  
  Optimistic locking per store
&lt;/h3&gt;

&lt;p&gt;Concurrent requests with the same key must not both acquire the lock. Redis needs &lt;code&gt;SET NX EX&lt;/code&gt;, D1 needs &lt;code&gt;INSERT OR IGNORE&lt;/code&gt;, Durable Objects use single-writer guarantees. Each store has different atomicity primitives, and getting them wrong means duplicate processing — the exact problem you're trying to solve.&lt;/p&gt;

&lt;p&gt;That said, this isn't a blanket "always use OSS" argument. Libraries can have slow release cycles, unresponsive maintainers, or stale issues. And the rise of AI-generated "slop" — low-quality issues and PRs flooding repositories — has added a new burden on maintainers, making it harder to distinguish signal from noise. How to build reliable software in this landscape is still an open question.&lt;/p&gt;

&lt;p&gt;What I can say is: when a library is actively maintained, has transparent source code and high test coverage, and solves a problem with non-obvious edge cases — using it beats reinventing it. Unless your environment prohibits external dependencies (some FDE roles or regulated contexts), &lt;strong&gt;64 commits worth of edge-case fixes are a better starting point than a blank file.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Design Decisions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why not cache failed responses?
&lt;/h3&gt;

&lt;p&gt;The IETF draft doesn't specify this, but Stripe's implementation is clear: non-2xx responses are not cached. If a payment fails due to a transient error (database timeout, third-party API flake), the client should be able to retry with the same key and get a fresh attempt. Caching failures would force clients to generate new keys for every retry — defeating the purpose of idempotency.&lt;/p&gt;

&lt;h3&gt;
  
  
  Request fingerprinting prevents misuse
&lt;/h3&gt;

&lt;p&gt;Same key + different request body = &lt;code&gt;422 Fingerprint Mismatch&lt;/code&gt;. Without this, a client could accidentally (or maliciously) reuse a key for a completely different operation. The default fingerprint is SHA-256 of &lt;code&gt;method + path + body&lt;/code&gt; using the Web Crypto API — available on every runtime Hono supports.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why client-generated keys?
&lt;/h3&gt;

&lt;p&gt;Server-generated idempotency keys can't distinguish between "retry the same payment" and "make a second payment for the same amount." Only the client knows whether it's retrying or making a new request. That's why Stripe, the IETF draft, and this middleware all use client-provided keys.&lt;/p&gt;

&lt;h3&gt;
  
  
  RFC 9457 error responses
&lt;/h3&gt;

&lt;p&gt;Every error returns &lt;code&gt;application/problem+json&lt;/code&gt; with a machine-readable &lt;code&gt;code&lt;/code&gt; field:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Code&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MISSING_KEY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;required: true&lt;/code&gt; and no header sent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;KEY_TOO_LONG&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;td&gt;Exceeds &lt;code&gt;maxKeyLength&lt;/code&gt; (default 256)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CONFLICT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;409&lt;/td&gt;
&lt;td&gt;Another request is still processing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;FINGERPRINT_MISMATCH&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;422&lt;/td&gt;
&lt;td&gt;Same key, different body&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Production-Ready Stores
&lt;/h2&gt;

&lt;p&gt;Start with &lt;code&gt;memoryStore()&lt;/code&gt; for development. Pick your production store based on your runtime:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Store&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;th&gt;Lock atomicity&lt;/th&gt;
&lt;th&gt;TTL&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Memory&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Dev / single-instance&lt;/td&gt;
&lt;td&gt;In-process Map&lt;/td&gt;
&lt;td&gt;Sweep on access&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Redis&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Node.js / serverless&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;SET NX EX&lt;/code&gt; (strongest)&lt;/td&gt;
&lt;td&gt;Automatic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cloudflare KV&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Multi-region, low contention&lt;/td&gt;
&lt;td&gt;Eventual (not atomic)&lt;/td&gt;
&lt;td&gt;Automatic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cloudflare D1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Multi-region, strong consistency&lt;/td&gt;
&lt;td&gt;&lt;code&gt;INSERT OR IGNORE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;SQL filter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Durable Objects&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Cloudflare, strong consistency&lt;/td&gt;
&lt;td&gt;Single-writer&lt;/td&gt;
&lt;td&gt;Manual&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Redis store works with ioredis, node-redis, and @upstash/redis. For Cloudflare Workers, Durable Objects gives you the strongest guarantees; KV is simpler but eventually consistent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pairs Well With
&lt;/h2&gt;

&lt;p&gt;If you're receiving webhooks, pair &lt;code&gt;hono-idempotency&lt;/code&gt; with &lt;a href="https://github.com/paveg/hono-webhook-verify" rel="noopener noreferrer"&gt;hono-webhook-verify&lt;/a&gt; for signature verification:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Webhook received → Verify signature → Idempotently process
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Webhook providers often retry on timeout. Without idempotency, you'd process the same event multiple times. With both middlewares, you get verified-and-deduplicated webhook handling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get Started
&lt;/h2&gt;

&lt;p&gt;hono-idempotency is listed as an official &lt;a href="https://hono.dev/docs/middleware/third-party" rel="noopener noreferrer"&gt;Hono third-party middleware&lt;/a&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;npm:&lt;/strong&gt; &lt;a href="https://www.npmjs.com/package/hono-idempotency" rel="noopener noreferrer"&gt;hono-idempotency&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/paveg/hono-idempotency" rel="noopener noreferrer"&gt;paveg/hono-idempotency&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IETF draft:&lt;/strong&gt; &lt;a href="https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/" rel="noopener noreferrer"&gt;draft-ietf-httpapi-idempotency-key-header&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deep dive (Japanese):&lt;/strong&gt; &lt;a href="https://www.funailog.com/blog/2026/hono-idempotency-middleware/" rel="noopener noreferrer"&gt;Honoの冪等性ミドルウェアを作った&lt;/a&gt; — design philosophy and architecture details&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Stars, issues, and PRs are welcome. If you're using it in production, I'd love to hear about it.&lt;/p&gt;

</description>
      <category>hono</category>
      <category>typescript</category>
      <category>cloudflare</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
