<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>maruf.ing</title><description>builds backends. breaks them. writes it down.</description><link>https://maruf.ing/</link><language>en</language><item><title>create the account last</title><link>https://maruf.ing/writing/create-the-account-last/</link><guid isPermaLink="true">https://maruf.ing/writing/create-the-account-last/</guid><description>Half-created users are a bug you designed. Rebuilding a signup flow as a server-driven state machine.</description><pubDate>Wed, 07 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Most signup flows I&amp;#39;ve worked on create the user row on the first screen. You type an email, a row appears, and every screen after that patches it. It feels efficient. It is also how you end up with thousands of accounts that have an email, no phone, no store, and no way to finish.&lt;/p&gt;&lt;p&gt;I used to think the fix was cleanup jobs. I now think the fix is to &lt;del&gt;clean up half-created accounts&lt;/del&gt; &lt;ins&gt;never create them&lt;/ins&gt;.&lt;/p&gt;&lt;h2&gt;the flow, as a state machine&lt;/h2&gt;
&lt;p&gt;The client doesn&amp;#39;t decide what comes next. It asks the server, and the server answers with the next step.&lt;/p&gt;&lt;figure class=&quot;diagram&quot;&gt;&lt;pre class=&quot;mermaid&quot;&gt;stateDiagram-v2
  direction TB
  [*] --&amp;gt; identify
  identify --&amp;gt; verify_email: email or oauth
  verify_email --&amp;gt; details: otp ok
  verify_email --&amp;gt; verify_email: wrong code
  details --&amp;gt; verify_phone
  verify_phone --&amp;gt; create: otp ok
  create --&amp;gt; [*]: account + store&lt;/pre&gt;&lt;/figure&gt;&lt;p&gt;Nothing is written to the users table until the last arrow. Everything before it lives in a short-lived session.&lt;/p&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;step&lt;/th&gt;
&lt;th&gt;stored where&lt;/th&gt;
&lt;th&gt;survives a refresh&lt;/th&gt;
&lt;th&gt;creates rows&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;identify&lt;/td&gt;
&lt;td&gt;session (TTL)&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;verify email&lt;/td&gt;
&lt;td&gt;session (TTL)&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;details&lt;/td&gt;
&lt;td&gt;session (TTL)&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;verify phone&lt;/td&gt;
&lt;td&gt;session (TTL)&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;create&lt;/td&gt;
&lt;td&gt;database&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes, in one transaction&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2&gt;the server owns the next step&lt;/h2&gt;
&lt;figure class=&quot;code&quot;&gt;&lt;figcaption&gt;&lt;span&gt;signup/step.go&lt;/span&gt;&lt;button type=&quot;button&quot; class=&quot;copy&quot;&gt;copy&lt;/button&gt;&lt;/figcaption&gt;&lt;pre class=&quot;shiki ink&quot; style=&quot;background-color:var(--code-background);color:var(--code-foreground)&quot; tabindex=&quot;0&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-token-keyword)&quot;&gt;type&lt;/span&gt;&lt;span style=&quot;color:var(--code-token-function)&quot;&gt; Step&lt;/span&gt;&lt;span style=&quot;color:var(--code-token-keyword)&quot;&gt; string&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-token-keyword)&quot;&gt;const&lt;/span&gt;&lt;span style=&quot;color:var(--code-foreground)&quot;&gt; (&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-token-constant)&quot;&gt;	Identify&lt;/span&gt;&lt;span style=&quot;color:var(--code-token-function)&quot;&gt;    Step&lt;/span&gt;&lt;span style=&quot;color:var(--code-token-keyword)&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:var(--code-token-string-expression)&quot;&gt; &quot;identify&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-token-constant)&quot;&gt;	VerifyEmail&lt;/span&gt;&lt;span style=&quot;color:var(--code-token-function)&quot;&gt; Step&lt;/span&gt;&lt;span style=&quot;color:var(--code-token-keyword)&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:var(--code-token-string-expression)&quot;&gt; &quot;verify_email&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-token-constant)&quot;&gt;	Details&lt;/span&gt;&lt;span style=&quot;color:var(--code-token-function)&quot;&gt;     Step&lt;/span&gt;&lt;span style=&quot;color:var(--code-token-keyword)&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:var(--code-token-string-expression)&quot;&gt; &quot;details&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-token-constant)&quot;&gt;	VerifyPhone&lt;/span&gt;&lt;span style=&quot;color:var(--code-token-function)&quot;&gt; Step&lt;/span&gt;&lt;span style=&quot;color:var(--code-token-keyword)&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:var(--code-token-string-expression)&quot;&gt; &quot;verify_phone&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-token-constant)&quot;&gt;	Done&lt;/span&gt;&lt;span style=&quot;color:var(--code-token-function)&quot;&gt;        Step&lt;/span&gt;&lt;span style=&quot;color:var(--code-token-keyword)&quot;&gt; =&lt;/span&gt;&lt;span style=&quot;color:var(--code-token-string-expression)&quot;&gt; &quot;done&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-foreground)&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-token-comment)&quot;&gt;// Next is the only place that knows the order.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-token-comment)&quot;&gt;// The client renders whatever it is told to render.&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-token-keyword)&quot;&gt;func&lt;/span&gt;&lt;span style=&quot;color:var(--code-token-function)&quot;&gt; Next&lt;/span&gt;&lt;span style=&quot;color:var(--code-foreground)&quot;&gt;(s &lt;/span&gt;&lt;span style=&quot;color:var(--code-token-keyword)&quot;&gt;*&lt;/span&gt;&lt;span style=&quot;color:var(--code-token-function)&quot;&gt;Session&lt;/span&gt;&lt;span style=&quot;color:var(--code-foreground)&quot;&gt;) &lt;/span&gt;&lt;span style=&quot;color:var(--code-token-function)&quot;&gt;Step&lt;/span&gt;&lt;span style=&quot;color:var(--code-foreground)&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-token-keyword)&quot;&gt;	switch&lt;/span&gt;&lt;span style=&quot;color:var(--code-foreground)&quot;&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-token-keyword)&quot;&gt;	case&lt;/span&gt;&lt;span style=&quot;color:var(--code-foreground)&quot;&gt; s.Email &lt;/span&gt;&lt;span style=&quot;color:var(--code-token-keyword)&quot;&gt;==&lt;/span&gt;&lt;span style=&quot;color:var(--code-token-string-expression)&quot;&gt; &quot;&quot;&lt;/span&gt;&lt;span style=&quot;color:var(--code-foreground)&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-token-keyword)&quot;&gt;		return&lt;/span&gt;&lt;span style=&quot;color:var(--code-foreground)&quot;&gt; Identify&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-token-keyword)&quot;&gt;	case&lt;/span&gt;&lt;span style=&quot;color:var(--code-token-keyword)&quot;&gt; !&lt;/span&gt;&lt;span style=&quot;color:var(--code-foreground)&quot;&gt;s.EmailVerified:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-token-keyword)&quot;&gt;		return&lt;/span&gt;&lt;span style=&quot;color:var(--code-foreground)&quot;&gt; VerifyEmail&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-token-keyword)&quot;&gt;	case&lt;/span&gt;&lt;span style=&quot;color:var(--code-foreground)&quot;&gt; s.Name &lt;/span&gt;&lt;span style=&quot;color:var(--code-token-keyword)&quot;&gt;==&lt;/span&gt;&lt;span style=&quot;color:var(--code-token-string-expression)&quot;&gt; &quot;&quot;&lt;/span&gt;&lt;span style=&quot;color:var(--code-token-keyword)&quot;&gt; ||&lt;/span&gt;&lt;span style=&quot;color:var(--code-foreground)&quot;&gt; s.StoreName &lt;/span&gt;&lt;span style=&quot;color:var(--code-token-keyword)&quot;&gt;==&lt;/span&gt;&lt;span style=&quot;color:var(--code-token-string-expression)&quot;&gt; &quot;&quot;&lt;/span&gt;&lt;span style=&quot;color:var(--code-foreground)&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-token-keyword)&quot;&gt;		return&lt;/span&gt;&lt;span style=&quot;color:var(--code-foreground)&quot;&gt; Details&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-token-keyword)&quot;&gt;	case&lt;/span&gt;&lt;span style=&quot;color:var(--code-token-keyword)&quot;&gt; !&lt;/span&gt;&lt;span style=&quot;color:var(--code-foreground)&quot;&gt;s.PhoneVerified:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-token-keyword)&quot;&gt;		return&lt;/span&gt;&lt;span style=&quot;color:var(--code-foreground)&quot;&gt; VerifyPhone&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-token-keyword)&quot;&gt;	default&lt;/span&gt;&lt;span style=&quot;color:var(--code-foreground)&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-token-keyword)&quot;&gt;		return&lt;/span&gt;&lt;span style=&quot;color:var(--code-foreground)&quot;&gt; Done&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-foreground)&quot;&gt;	}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:var(--code-foreground)&quot;&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/figure&gt;&lt;p&gt;Because the order lives in one function, adding a step is one case, not a change in three apps.&lt;/p&gt;&lt;aside class=&quot;callout careful&quot;&gt;&lt;span class=&quot;callout-kind&quot;&gt;careful&lt;/span&gt;&lt;div&gt;&lt;p&gt;Don&amp;#39;t put the password in client state between steps. Keep it in the server session, which expires, and hash it at the create step.&lt;/p&gt;&lt;/div&gt;&lt;/aside&gt;&lt;h2&gt;what it bought us&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;No half-created accounts. There is nothing to clean up because nothing was written.&lt;/li&gt;
&lt;li&gt;The client got simpler. It stopped tracking progress and started rendering steps.&lt;/li&gt;
&lt;li&gt;Retrying is safe. Refreshing the page asks the server where you are, and the answer doesn&amp;#39;t change.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The trade-off is that the session store becomes important. If it goes down, signups pause. I&amp;#39;m fine with that. A paused signup is better than a broken account.&lt;/p&gt;</content:encoded><category>writing</category><category>backend</category><category>state-machines</category><category>go</category></item></channel></rss>