<?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: Vitaly Pavlenko</title>
    <description>The latest articles on DEV Community by Vitaly Pavlenko (@vitaly_pavlenko_71b14d120).</description>
    <link>https://dev.to/vitaly_pavlenko_71b14d120</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2450335%2Fafdeb635-9cb6-4519-9163-4e89a322f434.jpg</url>
      <title>DEV Community: Vitaly Pavlenko</title>
      <link>https://dev.to/vitaly_pavlenko_71b14d120</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/vitaly_pavlenko_71b14d120"/>
    <language>en</language>
    <item>
      <title>Spaced Repetition Under the Hood: How We Decide When to Show a Word Again</title>
      <dc:creator>Vitaly Pavlenko</dc:creator>
      <pubDate>Sat, 01 Aug 2026 23:46:56 +0000</pubDate>
      <link>https://dev.to/vitaly_pavlenko_71b14d120/spaced-repetition-under-the-hood-how-we-decide-when-to-show-a-word-again-2g5b</link>
      <guid>https://dev.to/vitaly_pavlenko_71b14d120/spaced-repetition-under-the-hood-how-we-decide-when-to-show-a-word-again-2g5b</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I build &lt;a href="https://vibeling.app/" rel="noopener noreferrer"&gt;VibeLing&lt;/a&gt;, a vocabulary app. This post is about the scheduler inside it — what it does, what we deliberately didn't build, and the thing that broke once real people started using it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every spaced repetition app has one function at its core: given a word and everything that has happened to it so far, return a date. Show it again on that date. Everything else — the card UI, the audio, the streak counter — sits on top of that one decision.&lt;/p&gt;

&lt;p&gt;We got that function wrong in an interesting way, so here is how it works now and what it cost to get there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The state we keep per word
&lt;/h2&gt;

&lt;p&gt;Two numbers and a state machine. That's the whole model.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;Word&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="nx"&gt;LEARNING&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nx"&gt;REVIEW&lt;/span&gt;
  &lt;span class="nx"&gt;correct&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="nx"&gt;int&lt;/span&gt;        &lt;span class="c1"&gt;// successful answers in LEARNING&lt;/span&gt;
  &lt;span class="nx"&gt;interval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="nx"&gt;int&lt;/span&gt;        &lt;span class="c1"&gt;// days, only meaningful in REVIEW&lt;/span&gt;
  &lt;span class="nx"&gt;difficulty&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;float&lt;/span&gt;      &lt;span class="c1"&gt;// starts at 1.0&lt;/span&gt;
  &lt;span class="nx"&gt;due&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="nx"&gt;date&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A new word starts in &lt;code&gt;LEARNING&lt;/code&gt;. It leaves &lt;code&gt;LEARNING&lt;/code&gt; only after &lt;strong&gt;nine correct answers&lt;/strong&gt;, and we cap it at three per day, spread across three different exercise types — pick the translation, fill the gap in a sentence, say it out loud. So a word takes a minimum of three days to graduate, and it has to survive three different ways of being asked before we believe it.&lt;/p&gt;

&lt;p&gt;Once it graduates, it moves onto a doubling ladder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1 → 2 → 4 → 8 → 16 → 32 → 64 → 128 → 256 → 512 days
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Answer correctly, step up. Answer wrong, drop back — badly enough, and the word returns to &lt;code&gt;LEARNING&lt;/code&gt; and has to earn its nine answers again.&lt;/p&gt;

&lt;p&gt;That's it. No half-life model, no per-user parameter fitting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why we didn't use FSRS
&lt;/h2&gt;

&lt;p&gt;The obvious question for anyone who has read about this: why not &lt;a href="https://github.com/open-spaced-repetition/fsrs4anki" rel="noopener noreferrer"&gt;FSRS&lt;/a&gt;, the algorithm Anki moved to? It models memory properly — every card carries &lt;em&gt;difficulty&lt;/em&gt;, &lt;em&gt;stability&lt;/em&gt; and &lt;em&gt;retrievability&lt;/em&gt;, and the scheduler predicts the probability you'd recall the card today and picks the day that probability crosses your target retention.&lt;/p&gt;

&lt;p&gt;It's better. We didn't use it, for two reasons that had nothing to do with the algorithm being wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It needs review logs we didn't have.&lt;/strong&gt; FSRS earns its accuracy by fitting parameters to a real review history. On day one we had no history, so we'd have shipped default parameters and a much more complicated system, in exchange for approximately nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A doubling ladder is debuggable.&lt;/strong&gt; When a user writes in saying a word keeps coming back too often, I can read their word's history in about ten seconds and say exactly why. With a fitted model I'd be squinting at three floats. At the stage where you're still discovering what your product does wrong, being able to explain the system out loud to a confused user is worth more than a few percentage points of retention.&lt;/p&gt;

&lt;p&gt;I'd revisit this now that there are logs. That's an honest "we'd probably do it differently today", not a claim that the simple thing is better.&lt;/p&gt;

&lt;h2&gt;
  
  
  The signals: a binary grade throws away too much
&lt;/h2&gt;

&lt;p&gt;The one place we didn't go simple is grading. Most flashcard UIs collapse an answer into pass/fail, which discards nearly everything the session just told you. Two users both answered correctly; one of them took nine seconds and used a hint.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;difficulty&lt;/code&gt; starts at 1.0 and moves on more than correctness:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;updateDifficulty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;word&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nx"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="nx"&gt;difficulty&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;   &lt;span class="c1"&gt;// got it, but not first try&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nx"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usedHint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;           &lt;span class="nx"&gt;difficulty&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nx"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timeMs&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;slowFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;word&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exerciseType&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                                  &lt;span class="nx"&gt;difficulty&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nx"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nx"&gt;SPEECH&lt;/span&gt; &lt;span class="nx"&gt;and&lt;/span&gt; &lt;span class="nx"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recognitionScore&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                                  &lt;span class="nx"&gt;difficulty&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;
    &lt;span class="nx"&gt;recentErrorRate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;word&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lastSessions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;word&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lastSessions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;difficulty&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;blend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;difficulty&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;recentErrorRate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;difficulty&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;MIN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;MAX&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;nextInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;word&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;ladderStep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;word&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;interval&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;word&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;difficulty&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I'm writing the constants as &lt;code&gt;a&lt;/code&gt;, &lt;code&gt;b&lt;/code&gt;, &lt;code&gt;c&lt;/code&gt;, &lt;code&gt;d&lt;/code&gt; on purpose. They were tuned by hand and by feel, and dressing them up as derived values would be a lie. What matters is the shape: a slow, hinted, second-attempt "correct" is not the same event as an instant one, and the interval it earns should be shorter.&lt;/p&gt;

&lt;p&gt;The speech signal is the one I'd defend hardest. Pronunciation exercises run through speech recognition, and a low recognition score on a word the user "knows" is a good early warning that they know it by sight only. Sight-only knowledge is exactly the kind that collapses in conversation.&lt;/p&gt;

&lt;p&gt;The rolling error rate over the last 10 sessions is the counterweight. Any single answer is noisy — people tap the wrong button on the bus. A word has to be consistently hard before the coefficient moves much.&lt;/p&gt;

&lt;h2&gt;
  
  
  What broke: the snowball
&lt;/h2&gt;

&lt;p&gt;Here's the part I'd want to read in someone else's post.&lt;/p&gt;

&lt;p&gt;The scheduler was fine. The &lt;em&gt;queue&lt;/em&gt; was not.&lt;/p&gt;

&lt;p&gt;Nothing in the model above limits how many words come due on the same day. Users add words from shows and articles, and they add them in bursts — forty words on a Sunday evening, because they finally sat down with a series. Three days later, all forty graduate LEARNING together. Sixteen days later they come due together again. The ladder is deterministic, so bursts stay in formation forever and reinforce each other every time they land.&lt;/p&gt;

&lt;p&gt;Individually every one of those due dates is correct. Collectively, the app opens on a Tuesday and says &lt;strong&gt;you have 380 words to review&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;What people do with that number is not "review 380 words". They close the app. And a spaced repetition system that doesn't get opened is worse than no system, because all those intervals keep expiring in the background and the number keeps climbing. The user comes back on Friday to 520.&lt;/p&gt;

&lt;p&gt;We could see it in the data: sessions weren't getting longer before people churned. They were getting shorter, then stopping. The backlog wasn't overworking anyone. It was just demoralising.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: cap the session, not the intake
&lt;/h2&gt;

&lt;p&gt;The obvious fix is to limit new words per day, and it's the one most apps reach for. We didn't, for a product reason: the moment someone hears a word in a show and wants to keep it is the moment they're most engaged. Telling them "you've hit today's limit" punishes the exact behaviour the app exists to encourage.&lt;/p&gt;

&lt;p&gt;So we capped the other end. &lt;strong&gt;A session is always the same size — ten words — and the full queue is never displayed.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;buildSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nx"&gt;due&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wordsDueFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;              &lt;span class="c1"&gt;// may be 500 long&lt;/span&gt;
    &lt;span class="nx"&gt;ranked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sortBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;due&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="nx"&gt;overdueDays&lt;/span&gt; &lt;span class="nx"&gt;desc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;difficulty&lt;/span&gt; &lt;span class="nx"&gt;desc&lt;/span&gt; &lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;take&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ranked&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;SESSION_SIZE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;// always 10&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things changed as a result.&lt;/p&gt;

&lt;p&gt;The visible number stopped being a threat. There is no 380 anywhere in the UI, only a session of ten and a plan of three sessions for today. The work is the same work; it just no longer arrives as a wall.&lt;/p&gt;

&lt;p&gt;And &lt;strong&gt;queue ordering turned into the real problem&lt;/strong&gt; — which is the interesting consequence. When you show everything, ordering barely matters, because the user gets through it all or doesn't. When you show ten out of five hundred, those ten slots are the entire product. Pick badly and a word can sit overdue for weeks while easier words cycle in front of it. That's why the sort is by overdue days first, difficulty second: the most rotten thing in the queue goes first, and the ones the user is actually shaky on get priority over the ones they'd have breezed through.&lt;/p&gt;

&lt;p&gt;The backlog still exists. It drains slowly instead of being paid off in one heroic sitting, and that turns out to be fine — it's the same tradeoff the ladder makes, applied to the queue instead of a single word.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's still open
&lt;/h2&gt;

&lt;p&gt;Two things I don't have a good answer for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Long absences.&lt;/strong&gt; Someone disappears for two months and comes back to a queue where every interval has blown past. Resetting everything to &lt;code&gt;LEARNING&lt;/code&gt; is too punishing; ignoring the gap means their first session is full of words they've genuinely lost. Right now the overdue-first sort papers over it. It isn't a real answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bursts still travel together.&lt;/strong&gt; Capping the session hid the symptom but didn't break up the convoy — words added on the same evening still march up the ladder in formation. Adding a small jitter to each interval would spread them out, and the reason we haven't is unsatisfying: nobody has complained about it since the sessions got bounded, and there's always something more visible to fix.&lt;/p&gt;

&lt;p&gt;If you want the non-technical version of the same ideas — what spaced repetition is doing to memory and why the interval should land right when a word is about to fade — I wrote &lt;a href="https://vibeling.app/en/blog/spaced-repetition-science-of-never-forgetting" rel="noopener noreferrer"&gt;a plain-language walkthrough of spaced repetition&lt;/a&gt; for the blog.&lt;/p&gt;

&lt;p&gt;And if you'd rather poke at the scheduler from the outside than read pseudocode about it, you can &lt;a href="https://vibeling.app/en/learn/spanish" rel="noopener noreferrer"&gt;add ten Spanish words and watch what it does with them&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you've built one of these: how do you handle a user who vanishes for two months? That's the case I keep going back and forth on.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>showdev</category>
      <category>algorithms</category>
      <category>learning</category>
    </item>
    <item>
      <title>I redesigned the onboarding and paywall in my app — and in a single week I earned more than in the previous six months combined</title>
      <dc:creator>Vitaly Pavlenko</dc:creator>
      <pubDate>Mon, 08 Jun 2026 18:07:31 +0000</pubDate>
      <link>https://dev.to/vitaly_pavlenko_71b14d120/i-redesigned-the-onboarding-and-paywall-in-my-app-and-in-a-single-week-i-earned-more-than-in-the-3fop</link>
      <guid>https://dev.to/vitaly_pavlenko_71b14d120/i-redesigned-the-onboarding-and-paywall-in-my-app-and-in-a-single-week-i-earned-more-than-in-the-3fop</guid>
      <description>&lt;p&gt;Six months ago I started building an app for learning foreign words. Let me say upfront: before this, I had only ever worked for a salary. Which means I always thought like a developer and an executor.&lt;/p&gt;

&lt;p&gt;The more code I write, the more features I ship, the fewer bugs there are — the more I earn.&lt;/p&gt;

&lt;p&gt;But this approach doesn't work at all when you want to make money on your own product. And it took me six months to understand that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The MVP already had a paid subscription, but there was a catch
&lt;/h2&gt;

&lt;p&gt;When I launched the MVP, I decided to bolt on a paid subscription right away, so that literally from the very start I could see not just interest in the product, but people's willingness to pay their own money for it.&lt;/p&gt;

&lt;p&gt;I did, of course, add the option to subscribe. But since the product was still very bare-bones, I hid it away on the settings page. In other words, most of the users who downloaded the app didn't even suspect it had a paid version.&lt;/p&gt;

&lt;p&gt;Naturally, there were almost no purchases. Except for one "pity" subscription — to support a beginner founder.&lt;/p&gt;

&lt;p&gt;I can guess why I did it that way back then. I was a little ashamed of the app. In my head I already saw the future result I wanted to reach. But it was still a long way off. And users only saw what was there in the moment: bugs, a plain interface, a bare minimum of features.&lt;/p&gt;

&lt;p&gt;So, to smooth over the impression somehow, I decided to bury the paid subscription as deep as possible. So that as few people as possible would see it. And only the most motivated and loyal users would manage to dig it up.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you understand how your app works and why it's needed, that doesn't mean users understand it too
&lt;/h2&gt;

&lt;p&gt;The first versions had, naturally, no onboarding at all. I didn't even know what that was. Back then, in my understanding, onboarding meant tooltips shown one after another: click here, then click here, and so on.&lt;/p&gt;

&lt;p&gt;When I started attracting my first users, similar comments began showing up in the feedback:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;It's completely unclear where to start when you land on the main screen.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So I finally decided to make some kind of explanation. But not to increase sales — just to explain in a couple of words what the app is for and what it does.&lt;/p&gt;

&lt;p&gt;It was a few static slides where the user just had to press the "Next" button.&lt;/p&gt;

&lt;p&gt;And I dared to add a paywall on the last step after the onboarding. But, naturally, nobody paid for anything from that paywall. Because the paywall itself had no trial, and there was a "Continue for free" button.&lt;/p&gt;

&lt;p&gt;Then it became obvious to me why there were no payments. The user had simply read about the features but hadn't actually used the app in any way. He didn't understand whether he needed it or not. Who's going to pay for that? And on top of it, anyone will click the "Continue for free" button rather than the "Pay money" button.&lt;/p&gt;

&lt;h2&gt;
  
  
  A mobile app lives or dies by its onboarding and paywall
&lt;/h2&gt;

&lt;p&gt;After that, all this time I kept trying to improve the app: I added new features, fixed bugs, improved how the neural networks worked. I barely thought about sales, onboarding, or the paywall screen.&lt;/p&gt;

&lt;p&gt;At some point I happened to meet, at the gym, the owner of a large mobile app that makes $400,000 a month. An app for counting calories and building personalized workouts.&lt;/p&gt;

&lt;p&gt;I downloaded that app and was surprised: it had around 20 onboarding steps, after which came a payment screen offering a 3-day trial.&lt;/p&gt;

&lt;p&gt;At first this felt counterintuitive to me. I had literally spent up to 10 minutes of my time on the onboarding. You'd think that this would, on the contrary, lose a lot of customers who simply never make it to the end.&lt;/p&gt;

&lt;p&gt;But then everything fell into place.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A long, personalized onboarding is literally the foundation for mobile apps. It's simple: a person who has already spent their time is more likely to start a trial. Otherwise they'd have to admit they wasted those 10–15 minutes.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;But, of course, what matters isn't just the number of screens, but also what they consist of.&lt;/p&gt;

&lt;h2&gt;
  
  
  Now for the meat. How I reworked the onboarding in my product
&lt;/h2&gt;

&lt;p&gt;Once the product became more or less stable, I spent a couple of weeks digging deep into the onboarding and paywall. I literally did only that, and during this time I didn't build any new features into the product.&lt;/p&gt;

&lt;p&gt;In the end I got a set of screens like this — 19 steps in total.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcnrmgj78kwis6oxj2t4l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fcnrmgj78kwis6oxj2t4l.png" alt=" " width="800" height="642"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;All onboarding steps and the paywall in VibeLing&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Now in order: what was done and why. Every screen matters and affects the conversion to subscription at the very end.&lt;/p&gt;

&lt;h3&gt;
  
  
  Briefly explaining the app
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F07yvdo7rva7vj9eusc4e.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F07yvdo7rva7vj9eusc4e.jpg" alt=" " width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Information about the VibeLing app&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The very first slides are very light. Here we just tell the user in a couple of words what the app is and what it's for.&lt;/p&gt;

&lt;p&gt;Because far from all users download the app deliberately. Someone might have just tapped "Download" without looking, and only after the first launch started figuring out what they'd actually downloaded.&lt;/p&gt;

&lt;h3&gt;
  
  
  Finding out the most important information
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpkq4bpr74no68b64ljlq.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fpkq4bpr74no68b64ljlq.jpg" alt=" " width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The language to learn and the user's native language&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Since my app lets you learn many languages, it's important for me to find out which language the user needs and what their native language is, so I can show the translation in exactly that language.&lt;/p&gt;

&lt;p&gt;This is literally the most important information. Because if the language they want isn't available, there's no point in continuing.&lt;/p&gt;

&lt;p&gt;On top of that, we find out the goal of learning the language. Already here we show the user that we're giving them not just a bare tool, but a personalized app tailored to their personal goals.&lt;/p&gt;

&lt;p&gt;Plus these goals are used later inside the app itself — for recommended word presets.&lt;/p&gt;

&lt;h3&gt;
  
  
  Not the most pleasant part of the survey, but a very important one
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3ulf8t5o6c3t9kakifxl.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3ulf8t5o6c3t9kakifxl.jpg" alt=" " width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Missed opportunities&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The goal of the next steps is to collect information about how long the person has been learning words and what results they've already achieved. And after that, to highlight their missed opportunities: what results they could have achieved if, for example, they had used our app earlier.&lt;/p&gt;

&lt;p&gt;I'd call this part the depressing one. It's important that the user reflects and even gets a little upset.&lt;/p&gt;

&lt;p&gt;But don't worry — next we'll inspire them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Creating the effect of personalization while running heavy operations under the hood
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxayq2ak8z3g0d2mj447j.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxayq2ak8z3g0d2mj447j.jpg" alt=" " width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Tailoring the VibeLing app to the user&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For the most part these slides are a fiction. Technically they're needed to perform heavy operations on the backend and prepare the upcoming examples with sentences.&lt;/p&gt;

&lt;p&gt;At the same time, we use this moment to show great reviews and the rating. This gives the user a sense of a serious approach and real personalization for them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Letting them use the app
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdlpfu1mlu5fqqhtxwa9d.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdlpfu1mlu5fqqhtxwa9d.jpg" alt=" " width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;First contact with the app&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This is a very important part. Before showing the payment screen, the user needs to at least minimally understand how they'll be interacting with the app most of the time.&lt;/p&gt;

&lt;p&gt;To do this, we let the user actually add a word they don't know — in the most natural mode possible: by selecting it from a sentence.&lt;/p&gt;

&lt;p&gt;After that we show an example of one of the training exercises: a question with four translation options, from which you have to pick the one correct answer.&lt;/p&gt;

&lt;p&gt;The person picks the correct answer, and we show them a success screen. On it we explain what will happen next.&lt;/p&gt;

&lt;p&gt;And next, this word will appear in training sessions using a spaced-repetition system — less and less often, until it settles into long-term memory.&lt;/p&gt;

&lt;h3&gt;
  
  
  Collecting the last data for personalization
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fppp4q7xm8bg3gwyvgp1m.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fppp4q7xm8bg3gwyvgp1m.jpg" alt=" " width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Frequency and length of training sessions&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;All that's left is to collect information about how often the person wants to learn words. This is needed both to actually configure the number of training sessions in the app itself, and for the final onboarding screen.&lt;/p&gt;

&lt;h3&gt;
  
  
  Presenting the personal plan and showing the paywall
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fh8nko5qazxvnzed2ixp9.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fh8nko5qazxvnzed2ixp9.jpg" alt=" " width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Learning plan and payment screen&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Here we've gathered all the information: the learning goal, the languages, the training frequency. We told the user about their missed opportunities and showed how many words they could already know if they'd started earlier.&lt;/p&gt;

&lt;p&gt;We also gave them a demo of the product and helped them start learning their first word.&lt;/p&gt;

&lt;p&gt;Finally, we can show the final onboarding screen with a personal 90-day plan. Here we show how many words the user will know if they start using the app today and keep at it without a break.&lt;/p&gt;

&lt;p&gt;And at the very end we wrap it all up with the paywall.&lt;/p&gt;

&lt;p&gt;And here it's important to understand that the user is already maximally motivated to try the trial version. They understand the value of the app, they've used it, and they know why they personally need it.&lt;/p&gt;

&lt;p&gt;The paywall has two options: a monthly and a yearly subscription. It's important to give a discount on the yearly plan to motivate the user to choose exactly that option.&lt;/p&gt;

&lt;p&gt;Initially I thought no one would buy the yearly subscription. But I was very surprised when almost 50% of the payments over the last week turned out to be yearly ones.&lt;/p&gt;

&lt;p&gt;You also absolutely need to add a 3-day trial. This is literally the gold standard. It greatly increases the final conversion to payment, because the user feels safer. And of course you need to state this explicitly — this is called "handling objections."&lt;/p&gt;

&lt;p&gt;The user isn't forced to pay for a black box. They understand they can cancel the trial that same day if the app turns out to be garbage or just doesn't suit them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Another interesting paywall hack
&lt;/h3&gt;

&lt;p&gt;In fact, the app can be used for free. But we don't provide an explicit "Continue for free" button. And that's what most large mobile apps in the stores do.&lt;/p&gt;

&lt;p&gt;In the beginning I had exactly that button — "Continue for free." 100% of users clicked it.&lt;/p&gt;

&lt;p&gt;Instead of such a button, we make a not-very-noticeable little "X" at the top of the screen. Many people won't even realize they can tap it and keep using the app for free. But if they do realize — they can tap it and land on the main screen. And we can motivate them to subscribe further down the line, inside the app. But that's a whole separate big story.&lt;/p&gt;

&lt;h3&gt;
  
  
  Results
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fh4ebifx3faroce9e6yko.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fh4ebifx3faroce9e6yko.png" alt=" " width="800" height="428"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Subscriptions over the last week after releasing the new onboarding&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Before releasing this onboarding, at best I was earning $55 a month. And those were only monthly subscriptions — roughly 10 active subscriptions.&lt;/p&gt;

&lt;p&gt;After the release, in a single week I immediately got 4 yearly subscriptions and several monthly ones. Even though the flow of new users stayed roughly the same the whole time.&lt;/p&gt;

&lt;p&gt;The conversion to starting a trial among new users grew from 2.5% to 5%.&lt;/p&gt;

&lt;p&gt;And on top of that, I haven't even used additional ways to nudge users — for example, an extra 50% discount limited in time.&lt;/p&gt;

&lt;p&gt;I'm sure it's possible to reach 10%.&lt;/p&gt;




&lt;p&gt;This whole time I've been talking about the app &lt;a href="https://vibeling.app/" rel="noopener noreferrer"&gt;VibeLing&lt;/a&gt; — you can take a look yourself at how everything is built there, if you're interested. And I'll be glad to get any feedback.&lt;/p&gt;

</description>
      <category>mobile</category>
      <category>startup</category>
      <category>marketing</category>
      <category>buildinpublic</category>
    </item>
  </channel>
</rss>
