<?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: Slice Cursor</title>
    <description>The latest articles on DEV Community by Slice Cursor (@slicecursor).</description>
    <link>https://dev.to/slicecursor</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%2F4124812%2F40b1e36d-9bca-4fec-b94b-468f1f6d4331.jpg</url>
      <title>DEV Community: Slice Cursor</title>
      <link>https://dev.to/slicecursor</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/slicecursor"/>
    <language>en</language>
    <item>
      <title>Who unfollowed you? My cron finds out</title>
      <dc:creator>Slice Cursor</dc:creator>
      <pubDate>Mon, 14 Sep 2026 15:59:10 +0000</pubDate>
      <link>https://dev.to/slicecursor/who-unfollowed-you-my-cron-finds-out-5947</link>
      <guid>https://dev.to/slicecursor/who-unfollowed-you-my-cron-finds-out-5947</guid>
      <description>&lt;p&gt;I have a small side project that shows people who unfollowed them on Instagram. Most of it runs in the user's browser. But I wanted one paid feature: if your follower count goes down while your computer is off, you get an email with the names of who left.&lt;/p&gt;

&lt;p&gt;Sounds easy. It wasn't. The server has to get follower lists by itself, from Vercel functions, and I really did not want to keep anyone's Instagram password. I'm not an expert on this, so this is just what I learned building it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not do it myself
&lt;/h2&gt;

&lt;p&gt;The official Graph API only covers Business/Creator accounts, and only your own followers. Doing it myself would mean running my own logged-in Instagram accounts, rotating them, and dealing with checkpoints and bans. I didn't want a second project just to keep the first one alive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Only crawl when something happened
&lt;/h2&gt;

&lt;p&gt;For follower lists I used &lt;a href="https://hikerapi.com/?utm_source=user_post&amp;amp;utm_medium=referral&amp;amp;utm_campaign=rewards_v1&amp;amp;utm_content=search&amp;amp;utm_term=63611" rel="noopener noreferrer"&gt;HikerAPI&lt;/a&gt;: plain REST, one &lt;code&gt;x-access-key&lt;/code&gt; header, and you pay per request.&lt;/p&gt;

&lt;p&gt;Paying per request changed how I built everything. The normal cron never fetches names. It only checks if the count changed. When the count goes down, a second job pulls the list and compares it with the last snapshot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Only 25 per page
&lt;/h2&gt;

&lt;p&gt;I assumed about 100 users per page. &lt;code&gt;/v1/user/followers/chunk&lt;/code&gt; gives you &lt;strong&gt;25&lt;/strong&gt;, and each request takes about 2 seconds. A 2,000-follower account is 80 requests, around 160 seconds. Vercel functions time out way before that.&lt;/p&gt;

&lt;p&gt;So I crawl in slices. Each cron run fetches a few pages, saves the &lt;code&gt;max_id&lt;/code&gt; cursor, and the next run continues from there&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fetchFollowerSlice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;maxRequests&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&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;users&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Follower&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;maxRequests&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&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;qs&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;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;qs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;max_id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;at&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;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;hiker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/v1/user/followers/chunk?&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;qs&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
      &lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cursor&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cursor&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;next&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt; &lt;span class="c1"&gt;// done&lt;/span&gt;
      &lt;span class="nx"&gt;at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;next&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;at&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt; &lt;span class="c1"&gt;// resume here next tick&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// keep the old cursor, retry later&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;Other things I ran into:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The response shape is not always the same.&lt;/strong&gt; v1 chunk returns a &lt;code&gt;[users, max_id]&lt;/code&gt; tuple, other versions return an object, and v2 &lt;code&gt;/user/followers&lt;/code&gt; now answers 410. My &lt;code&gt;readPage&lt;/code&gt; accepts both. If parsing still fails, the user gets a plain "your count dropped" email instead of nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Private accounts answer 403.&lt;/strong&gt; Nothing to crawl there, so those users only get the count alert.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't lose the cursor.&lt;/strong&gt; If a request times out in the middle, the function returns null and the saved cursor stays where it was. The next run tries the same page again instead of skipping 25 people.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The cost math
&lt;/h2&gt;

&lt;p&gt;At $0.001 per request, a full 2,000-follower crawl is about &lt;strong&gt;$0.08&lt;/strong&gt;. And it only runs when someone actually lost followers, so most days it costs nothing.&lt;/p&gt;

&lt;p&gt;One thing to know: that price needs a minimum prepaid balance. Plan for it before you launch the feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  If I started again
&lt;/h2&gt;

&lt;p&gt;I would add two boring things on the first day.&lt;/p&gt;

&lt;p&gt;First, an alert for when the API says no. I found this one by accident. I was testing the crawl and every request came back &lt;code&gt;402 InsufficientFunds&lt;/code&gt;, but my account still had $1.02 in it. My free credit was gone and the cheap rate needs a minimum balance, so $1 was not enough to do anything. My code caught the error and kept the cursor like it should, so nothing looked broken. It was just silently not working. Now the cron sends me one email a day when it sees a 402.&lt;/p&gt;

&lt;p&gt;Second, an off switch. The names feature is behind a flag now, so if the API stops working I turn it off and people still get the normal count email. Nothing else in the app breaks.&lt;/p&gt;

&lt;p&gt;The alert is about 30 lines and the flag is one line. I wish I wrote them before, not after.&lt;/p&gt;




&lt;p&gt;I'm part of HikerAPI's user-rewards program,they credit my account for posts like this.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>api</category>
      <category>nextjs</category>
      <category>typescript</category>
    </item>
  </channel>
</rss>
