<?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: Maksym Shykov</title>
    <description>The latest articles on DEV Community by Maksym Shykov (@mshykov).</description>
    <link>https://dev.to/mshykov</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%2F604689%2Fa320310b-9299-4388-854c-2e44ea5c4019.jpeg</url>
      <title>DEV Community: Maksym Shykov</title>
      <link>https://dev.to/mshykov</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mshykov"/>
    <language>en</language>
    <item>
      <title>A Free Donut Alternative for Slack (and When to Pick Donut Instead)</title>
      <dc:creator>Maksym Shykov</dc:creator>
      <pubDate>Tue, 20 Oct 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/mshykov/a-free-donut-alternative-for-slack-and-when-to-pick-donut-instead-2hc2</link>
      <guid>https://dev.to/mshykov/a-free-donut-alternative-for-slack-and-when-to-pick-donut-instead-2hc2</guid>
      <description>&lt;p&gt;We make &lt;a href="https://coffeeslack.com/" rel="noopener noreferrer"&gt;CoffeeSlack&lt;/a&gt;, a Slack app that pairs teammates for a coffee chat. Donut is the established product in this space, so this is a comparison written by the challenger. We are not a neutral source, which is why it starts with the cases where you should pick Donut.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Donut is the better choice
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You want calendar scheduling.&lt;/strong&gt; Donut integrates with Google Calendar. Our introduction carries a link that opens a prefilled 30-minute event (Google Calendar, or a download for Outlook and Apple), and whoever clicks it gets the event on their own calendar. We do not read anyone's availability: the suggested slot is the next working day at 14:00 in the recipient's time zone, and both people are expected to move it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want more than coffee.&lt;/strong&gt; Donut also runs Watercooler prompts, Celebrations, a recognition and rewards store, onboarding Journeys and HRIS sync. We do one thing. If you are buying a culture platform, we are the wrong shape.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You are on Microsoft Teams.&lt;/strong&gt; Donut supports Teams. We are Slack only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Procurement needs SOC 2, SSO or SCIM.&lt;/strong&gt; We have none of them and do not claim otherwise. If your security team gates the purchase, this is not a close call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want a vendor with a track record.&lt;/strong&gt; Donut has been doing this since 2016. CoffeeSlack is new and small, and you would be early.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What both do
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Donut&lt;/th&gt;
&lt;th&gt;CoffeeSlack&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Random 1:1 pairings in Slack&lt;/td&gt;
&lt;td&gt;Intros, matched within a channel&lt;/td&gt;
&lt;td&gt;Pairs everyone who opted in, within the channels you pick&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rhythm&lt;/td&gt;
&lt;td&gt;Configurable per channel&lt;/td&gt;
&lt;td&gt;Weekly, every two weeks, monthly, or a custom nth-weekday window&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Conversation starters&lt;/td&gt;
&lt;td&gt;Built in&lt;/td&gt;
&lt;td&gt;Built in; an admin can replace them with their own list&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Odd number of people&lt;/td&gt;
&lt;td&gt;Handled&lt;/td&gt;
&lt;td&gt;The last three become a trio, so nobody sits out&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Two real differences
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;We ask people to join, rather than enrolling the channel.&lt;/strong&gt; Each person types &lt;code&gt;/coffee&lt;/code&gt; to take part, and joining covers a calendar month. Nobody is paired without joining, and anyone can leave a round from the dashboard without asking an admin. Your first month will be smaller than your channel. That is the trade, and it buys you people who chose to be there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;We report whether the coffee happened.&lt;/strong&gt; Every introduction DM carries three buttons (&lt;em&gt;we met&lt;/em&gt;, &lt;em&gt;not yet&lt;/em&gt;, &lt;em&gt;we didn't meet&lt;/em&gt;) and the dashboard turns those into a response rate per round. Most tools in this category report how many introductions went out. That is a different number, and an easier one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we do not do
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;No availability lookup; nothing reads either calendar to find a time that works for both people.&lt;/li&gt;
&lt;li&gt;No group size setting: two people, plus one trio when the count is odd.&lt;/li&gt;
&lt;li&gt;No interest or profile matching: we match on channel membership and on not repeating a recent pair.&lt;/li&gt;
&lt;li&gt;No introduction emails yet: pairs are introduced by Slack DM only.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Cost
&lt;/h2&gt;

&lt;p&gt;CoffeeSlack is free for every team right now, with no card and no cap on participants. We intend to charge later: one flat price per company rather than per person, with a free band that fits a 50-person company. For Donut's current plans and any non-profit or community pricing, read &lt;a href="https://www.donut.com/pricing/" rel="noopener noreferrer"&gt;their pricing page&lt;/a&gt; yourself rather than trusting a competitor's summary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try both
&lt;/h2&gt;

&lt;p&gt;Put CoffeeSlack on a different channel from your Donut one (two coffee bots on the same channel would just mean two DMs for the same people), run both for a month, and keep the one that sticks.&lt;/p&gt;

&lt;p&gt;Full version with the details: &lt;a href="https://coffeeslack.com/alternatives/donut" rel="noopener noreferrer"&gt;coffeeslack.com/alternatives/donut&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://coffeeslack.com/alternatives/donut" rel="noopener noreferrer"&gt;coffeeslack.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>slack</category>
      <category>productivity</category>
      <category>remote</category>
      <category>startup</category>
    </item>
    <item>
      <title>Calendar Invites Without OAuth: Five Things That Bit Us</title>
      <dc:creator>Maksym Shykov</dc:creator>
      <pubDate>Sat, 17 Oct 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/mshykov/calendar-invites-without-oauth-five-things-that-bit-us-35hc</link>
      <guid>https://dev.to/mshykov/calendar-invites-without-oauth-five-things-that-bit-us-35hc</guid>
      <description>&lt;p&gt;Our Slack bot introduces two people and asks them to have a coffee. The obvious next step is putting that meeting in their calendars, and the obvious way is the Google Calendar API: per-user OAuth, a consent screen, refresh tokens to store and revoke, and the same work again for Microsoft.&lt;/p&gt;

&lt;p&gt;We shipped it without any of that: a Google template URL and an &lt;code&gt;.ics&lt;/code&gt; download, both built on the server and carried in the message. The person clicking creates the event in their own calendar, so we hold no tokens and ask for no access. Here is what that costs, and the five things that bit us.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not just call the API
&lt;/h2&gt;

&lt;p&gt;Reading free and busy times is a second identity system. Our only sign-in is Slack. Calendar access would mean per-user consent for two providers, stored third-party refresh tokens (a materially larger thing to protect than anything else we hold) and a new paragraph in the privacy policy. It also only pays off once both people in a pair have connected. Below that it degrades to a link with a suggested time, which is exactly what we built. So we built that first and kept the option.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Leaving the time out does not leave the time blank
&lt;/h2&gt;

&lt;p&gt;Google's template URL takes the event as query parameters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://calendar.google.com/calendar/render
  ?action=TEMPLATE
  &amp;amp;text=Coffee chat with Dana
  &amp;amp;dates=20260922T130000Z/20260922T133000Z
  &amp;amp;details=Paired by CoffeeSlack
  &amp;amp;add=dana@example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We assumed omitting &lt;code&gt;dates&lt;/code&gt; would open the editor with an empty time. Tested against a real calendar, it does not: Google pre-fills the next slot from "now", one hour long. A click at around 21:00 on a Friday was offered a Friday coffee at 21:30. A default you choose beats the one Google chooses, so we always send a slot, which is what dragged time zones into a feature that looked like it could avoid them.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. A Z timestamp renders in each reader's own zone
&lt;/h2&gt;

&lt;p&gt;We sent &lt;code&gt;dates=20260922T130000Z/20260922T133000Z&lt;/code&gt; and opened it in a browser two hours ahead of UTC. It rendered as 15:00 to 15:30, with the 30-minute length intact. That is one URL that is correct for both people in a pair even in different countries. The time zone we look up is only ever used to pick a reasonable &lt;em&gt;hour&lt;/em&gt;: an unknown zone degrades the suggestion, it never makes the link wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Turning 14:00 local into an instant takes two passes
&lt;/h2&gt;

&lt;p&gt;To offer "2pm in their own time" you need the moment at which the clock in some zone reads 14:00. The tempting version reads the offset once:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;guess&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;UTC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;year&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;month&lt;/span&gt; &lt;span class="o"&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;day&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;guess&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;offsetAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;guess&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;zone&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// wrong near a DST change&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Treating the wall time as UTC can land the guess on the far side of a daylight-saving change from the real moment, so the offset you read is the other one. Resolving twice fixes it:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;first&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;guess&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;offsetAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;guess&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;zone&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;guess&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;offsetAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;zone&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Searching all 418 zones for a disagreement between the two versions found exactly three: Pacific/Auckland, Pacific/Chatham and Antarctica/McMurdo (which runs on New Zealand time, and which we missed the first time). All at 14:00, and only on the two Saturdays in 2026 when their clocks change. Our slot rule never returns a Saturday, so the bug could not be reached. We kept the second pass anyway, because the defect belongs to the helper, not to the rule that happens to dodge it.&lt;/p&gt;

&lt;p&gt;Two smaller things in the same corner. Asking for an unknown zone throws, and the zone name comes from someone's chat profile, so it needs a try and a fallback. And some builds render midnight as hour 24, which quietly shifts a date by a day unless you take it modulo 24.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The .ics line limit is 75 octets, not 75 characters
&lt;/h2&gt;

&lt;p&gt;RFC 5545 says a line should not exceed 75 octets, and a continuation begins with a single space or tab. Counting characters instead of bytes works on every name you will test with and breaks on the first one written in Cyrillic or containing an emoji, which arrives cut in half. Walk back off the UTF-8 continuation bytes before you split:&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="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;
       &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0xc0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mh"&gt;0x80&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The leading space on a continuation costs an octet too, so every chunk after the first gets 74.&lt;/p&gt;

&lt;p&gt;Escaping has an order: the backslash goes first, or you escape the backslashes you just introduced. Then semicolon, comma, and newline to a literal backslash-n. And the trap we actually shipped into a branch: in JavaScript &lt;code&gt;'\;'&lt;/code&gt; is not an escaped semicolon, it is a semicolon. The line that looked like it escaped them compiled, ran and did nothing, so a name like &lt;em&gt;Smith; Jane&lt;/em&gt; would have broken the property in two. A linter caught it. The test we had written did not, because it asserted against a string built with the same mistake. Two wrongs agreeing is the normal way this survives review. The fix was to assert by character code instead of writing the expected value as a literal.&lt;/p&gt;

&lt;p&gt;Finish with CRLF between every line and one at the end. Outlook is the client that minds.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Where you put the token decides who logs it
&lt;/h2&gt;

&lt;p&gt;A calendar client fetches the file with no headers we control, so the URL itself has to be the credential. The first version put the token in the path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/calendar/coffee.ics/&amp;lt;token&amp;gt;     first version
/calendar/coffee.ics?token=...   second version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We moved it because two pieces of our own code already had an opinion. Our request log records the path and drops the query, under a comment saying the query string can carry secrets. Our error reporter redacts query parameters named &lt;code&gt;token&lt;/code&gt;, and only query parameters. A token in the path would have been written to our log on every fetch and shipped to the error reporter on every 5xx. A reviewer found it; the author did not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And that was still not enough.&lt;/strong&gt; Our platform writes its own request log. The application never sees it and cannot configure it, and on Cloud Run the field it records is the full URL including the query string. So the token we had just moved into the query was sitting in that log on every fetch: 104 of them, in clear text, behind a fix that had been reviewed and merged as complete. Nothing leaked. Every one of those fetches was our own probing, all in the hour after the deploy and before the first real link went out. We know that from the timestamps rather than the status codes, because the rate limiter answers before the token is read, so a 429 never tells you whether the token was valid.&lt;/p&gt;

&lt;p&gt;The repair is a logging exclusion, because the platform can drop a matching entry but cannot redact a field inside one. Two things worth carrying:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Anchor the match.&lt;/strong&gt; Our first filter matched the path as a substring, so anyone could suppress their own log entry by hanging that string off the end of any other URL: a tool for hiding from the logs, shipped as a security fix. Anchor on host and path, and test that a crafted URL fails to match.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Know what the exclusion costs.&lt;/strong&gt; The platform log was the only copy of the client address, user agent and response size. On a public route those are exactly the fields you want when something goes wrong. If it starts to matter, log them yourself from inside the route, where you can leave the secret out on purpose.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The general shape is the part worth keeping: your logging and error reporting encode a belief about which half of a URL is secret, and it is usually right. Your platform holds its own belief, it is usually simpler, and it has not read your comments.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we deliberately left out
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No ATTENDEE, ORGANIZER or METHOD in the .ics.&lt;/strong&gt; Depending on the client, ATTENDEE reads as an invitation or as an ordinary appointment, and it puts a colleague's address in a file in a Downloads folder. Inviting the other person is the Google link's job, where the person clicking is the one inviting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No signature on the token.&lt;/strong&gt; The identifier carries 48 bits from a random-bytes call, so forging one means guessing those bits. An HMAC adds a secret to rotate and forget (ours sat unset for 26 days once) while protecting nothing: the only person who benefits from forging a pair member's copy is the pair's other member, who was sent the same details anyway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No availability lookup.&lt;/strong&gt; That needs the API, which needs the OAuth, which is the project we did not do. The slot we suggest is a guess both people are expected to move.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When this is the wrong approach
&lt;/h2&gt;

&lt;p&gt;Everything above depends on a person being there to click. If you need to know when someone is free, or to put an event in their calendar while they are asleep, none of this helps and you are back to the API and the consent screen. What it buys is the 80 percent case at none of the cost, and the option to do the rest later.&lt;/p&gt;

&lt;p&gt;The Google behaviour described here was checked against a live calendar on 21 September 2026. It is not documented anywhere we could find, so it can change without warning. Check it yourself before relying on it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This is the bot it went into: &lt;a href="https://coffeeslack.com/" rel="noopener noreferrer"&gt;CoffeeSlack&lt;/a&gt; pairs teammates for a coffee chat in Slack. Free for every team, no card.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://coffeeslack.com/calendar-links-without-oauth" rel="noopener noreferrer"&gt;coffeeslack.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>slack</category>
      <category>webdev</category>
      <category>security</category>
      <category>javascript</category>
    </item>
    <item>
      <title>How ATS Resume Parsing Actually Works</title>
      <dc:creator>Maksym Shykov</dc:creator>
      <pubDate>Thu, 15 Oct 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/mshykov/how-ats-resume-parsing-actually-works-2c2p</link>
      <guid>https://dev.to/mshykov/how-ats-resume-parsing-actually-works-2c2p</guid>
      <description>&lt;p&gt;What happens between uploading a CV and a recruiter seeing it: text extraction, section segmentation, entity extraction and search indexing — and where each stage breaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Short answer
&lt;/h2&gt;

&lt;p&gt;An ATS parses a CV in four mechanical stages: it extracts the text, splits it into sections by their headings, pulls out entities such as email, dates and job titles, and indexes the result for recruiters to search. Nearly every piece of CV formatting advice exists because one of those stages breaks.&lt;/p&gt;

&lt;p&gt;Most CV advice treats the applicant tracking system as a black box with opinions. It is simpler than that. Parsing runs in stages, each one mechanical, and almost every piece of real formatting advice is downstream of a specific stage failing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 1 — Text extraction
&lt;/h2&gt;

&lt;p&gt;The file is opened and characters are pulled out of it. For a DOCX this is mostly reading XML. For a PDF it is harder: a PDF does not store lines or paragraphs, it stores fragments of text with coordinates. Reconstructing “this fragment and that fragment are on the same line” is geometry, and it is where multi-column layouts fall apart — two columns can be stitched into one nonsensical line.&lt;/p&gt;

&lt;p&gt;Here is what that looks like. A two-column CV, as a reader sees it, and the text a line-by-line extractor — my checker included — actually gets back.&lt;/p&gt;

&lt;p&gt;What the reader sees:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;EXPERIENCE                        SKILLS
Engineering Manager, Acme         Kubernetes, Go
Jan 2020 – now                    Terraform, AWS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What the parser receives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;EXPERIENCE SKILLS
Engineering Manager, Acme Kubernetes, Go
Jan 2020 – now Terraform, AWS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every line is now a hybrid. The Experience heading shares a line with Skills, your job title has two skills glued to it, and the date range is followed by cloud-related skills. Nothing was lost — it was just put back together in the wrong order, which for a parser is the same thing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fails when:&lt;/strong&gt; the page is an image, the text is outlined, or the layout is multi-column.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 2 — Section segmentation
&lt;/h2&gt;

&lt;p&gt;The text is cut into regions by looking for headings. This is why the boring heading beats the clever one: a parser matching against a list of known words finds &lt;em&gt;Experience&lt;/em&gt;, &lt;em&gt;Work Experience&lt;/em&gt; and &lt;em&gt;Employment History&lt;/em&gt;. It does not find &lt;em&gt;Where I've Made a Dent&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;To make this concrete: these are the exact English headings my checker looks for. It accepts a short line — 45 characters or fewer, in any capitalisation — that contains one of them, so &lt;em&gt;Relevant Work Experience&lt;/em&gt; counts as Experience.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Section&lt;/th&gt;
&lt;th&gt;Headings it accepts&lt;/th&gt;
&lt;th&gt;Points&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Experience&lt;/td&gt;
&lt;td&gt;“experience”, “employment history”, “work experience”, “work history”, “professional experience”&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Education&lt;/td&gt;
&lt;td&gt;“education”, “academic background”&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Skills&lt;/td&gt;
&lt;td&gt;“skills”, “core competencies”, “technical skills”, “expertise”&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Summary&lt;/td&gt;
&lt;td&gt;“summary”, “profile”, “objective”, “about me”, “about”&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Achievements&lt;/td&gt;
&lt;td&gt;“achievements”, “key achievements”, “accomplishments”, “highlights”&lt;/td&gt;
&lt;td&gt;4 (bonus)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Projects&lt;/td&gt;
&lt;td&gt;“projects”, “selected projects”, “side projects”&lt;/td&gt;
&lt;td&gt;3 (bonus)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Certifications&lt;/td&gt;
&lt;td&gt;“certifications”, “certificates”, “courses”, “licenses”, “certifications &amp;amp; courses”&lt;/td&gt;
&lt;td&gt;3 (bonus)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Commercial parsers use longer lists, but the principle is identical — and none of them has your invented heading on it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fails when:&lt;/strong&gt; headings are creative, styled only by colour, or absent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 3 — Entity extraction
&lt;/h2&gt;

&lt;p&gt;Within each section the parser looks for specific things: an email is a pattern, a phone number is a pattern, a date range marks the start of a role. Job title and employer are inferred from position relative to the date.&lt;/p&gt;

&lt;p&gt;This is why &lt;strong&gt;dates matter more than people expect&lt;/strong&gt;. A dated line is the anchor that tells the parser “a new job starts here”. Undated roles collapse into whatever came before them, and your three years somewhere land inside the previous employer's entry.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fails when:&lt;/strong&gt; dates are missing, written only as “2 yrs”, or drawn as a graphic timeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 4 — Indexing and search
&lt;/h2&gt;

&lt;p&gt;The extracted fields go into a database. A recruiter then searches it — by title, by skill, by location. Your CV is not being judged at this point; it is being &lt;em&gt;queried&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Which reframes the keyword question. The goal is not to stuff terms in to please an algorithm. It is to make sure the words a recruiter would plausibly type appear somewhere you have honestly earned them. If you led Kubernetes migrations and never wrote the word “Kubernetes”, you will not be in that result set.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fails when:&lt;/strong&gt; the vocabulary of your CV and the vocabulary of the job ad have no overlap.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this implies
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Formatting advice is not superstition — each rule maps to a stage above.&lt;/li&gt;
&lt;li&gt;Keyword advice is about vocabulary overlap, not density. Never claim a skill you lack.&lt;/li&gt;
&lt;li&gt;Nothing in this pipeline evaluates you. It moves you into a searchable shape, or fails to.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;See it from the parser's side.&lt;/strong&gt; The &lt;a href="https://cv.shykov.dev/" rel="noopener noreferrer"&gt;free CV score checker&lt;/a&gt; has an &lt;em&gt;Extracted data&lt;/em&gt; tab that shows exactly what came out of your file — the name, contact details, links, and each role it managed to identify. If something is missing there, it will be missing for the employer too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://cv.shykov.dev/what-is-an-ats-score" rel="noopener noreferrer"&gt;What an ATS score actually measures&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://cv.shykov.dev/ats-checker-without-upload" rel="noopener noreferrer"&gt;Checkers that do not upload your CV&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can an ATS read a two-column resume?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Often not in the right order. Text extraction rebuilds lines from positions on the page, so two columns can be stitched together line by line, mixing a job title with a skills list. A single-column layout avoids the problem entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do applicant tracking systems read headers and footers?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Some parsers skip those regions or treat them unreliably. Keep your name, email and phone number in the main body of the page so they are extracted whichever parser reads the file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does hiding keywords in white text help?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. White text is still text: it is extracted into the parsed profile a recruiter reads, where it looks like an attempt to game the search. Use the job ad’s vocabulary only where you have genuinely earned it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://cv.shykov.dev/how-ats-parsing-works" rel="noopener noreferrer"&gt;cv.shykov.dev&lt;/a&gt;. The checker mentioned here is free, open source and runs entirely in your browser: &lt;a href="https://cv.shykov.dev/" rel="noopener noreferrer"&gt;cv.shykov.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>career</category>
      <category>resume</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>PDF or DOCX for an ATS: Which Should You Send?</title>
      <dc:creator>Maksym Shykov</dc:creator>
      <pubDate>Mon, 12 Oct 2026 16:47:05 +0000</pubDate>
      <link>https://dev.to/mshykov/pdf-or-docx-for-an-ats-which-should-you-send-2n9n</link>
      <guid>https://dev.to/mshykov/pdf-or-docx-for-an-ats-which-should-you-send-2n9n</guid>
      <description>&lt;p&gt;Send a PDF, unless the application form asks for something else. The reasoning, the one PDF that will fail every time, and what to do when the employer names a format.&lt;/p&gt;

&lt;h2&gt;
  
  
  Short answer
&lt;/h2&gt;

&lt;p&gt;Send a text-based PDF unless the application asks for another format — then send exactly what it asks for. The only PDF that reliably fails is one with no real text in it, such as a scan or an export with outlined fonts, and you can test for that in seconds.&lt;/p&gt;

&lt;p&gt;The longer answer is worth two minutes, because the usual advice (“always use Word, ATS cannot read PDFs”) is a decade out of date.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why PDF is the default now
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It looks the same everywhere.&lt;/strong&gt; A DOCX re-flows depending on the fonts and Word version at the other end. Your careful two-page layout can arrive as three ragged pages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Modern parsers handle it.&lt;/strong&gt; PDF text extraction is a solved problem; the major systems have supported it for years.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is harder to mangle.&lt;/strong&gt; Nobody accidentally edits your PDF between upload and review.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The one PDF that fails every time
&lt;/h2&gt;

&lt;p&gt;A PDF is a container. It can hold real text, or it can hold a picture of text. If you exported from a design tool with the text outlined, or scanned a printout, or screenshotted your CV and saved it as a PDF, then the file contains &lt;em&gt;no text at all&lt;/em&gt;. A parser reads it as a blank document.&lt;/p&gt;

&lt;p&gt;The test takes three seconds: open the PDF and try to select a line of text with your cursor. If you cannot highlight it, neither can the ATS.&lt;/p&gt;

&lt;h2&gt;
  
  
  A 30-second test for any PDF
&lt;/h2&gt;

&lt;p&gt;Selecting text proves the text exists. Two more steps show whether it comes out usable:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Select a line.&lt;/strong&gt; If nothing highlights, the page is an image. Re-export from the original document.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search for your email&lt;/strong&gt; with &lt;code&gt;Ctrl F&lt;/code&gt; / &lt;code&gt;⌘ F&lt;/code&gt;. If the viewer cannot find it, a parser will not either — often because it lives in a header, an image, or a text box.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Select all, copy, and paste into a plain-text editor&lt;/strong&gt; (Notepad, or TextEdit in plain-text mode). What you see is close to what a parser receives. If two columns come out interleaved line by line, or your job titles appear far from their dates, fix the layout before you worry about anything else.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Exporting from Word, Google Docs or Pages with their normal &lt;em&gt;Download as PDF&lt;/em&gt; or &lt;em&gt;Export to PDF&lt;/em&gt; produces text-based PDFs. The risky routes are “print to image”, scanning, and design tools with an option to convert text to outlines.&lt;/p&gt;

&lt;h2&gt;
  
  
  When DOCX is the right answer
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The form says so.&lt;/strong&gt; If the upload field lists &lt;code&gt;.doc/.docx&lt;/code&gt; only, that is the answer. Do not out-clever the form.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A recruiter asked for it.&lt;/strong&gt; Agencies often re-brand your CV onto their template, which needs an editable file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The employer runs an older system.&lt;/strong&gt; Rare now, but harmless to comply with.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Things that matter more than the extension
&lt;/h2&gt;

&lt;p&gt;Format is the easiest decision you will make about your CV. These are the ones that actually move the needle:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One column.&lt;/strong&gt; Multi-column layouts can be read in the wrong order, interleaving two unrelated lines.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No text inside images.&lt;/strong&gt; A logo is fine; your job title rendered as a graphic is not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No critical detail in the header or footer.&lt;/strong&gt; Some parsers skip those regions. Put your email in the body.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real bullet characters,&lt;/strong&gt; not manually drawn dashes inside a table cell.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A sensible filename&lt;/strong&gt; — &lt;code&gt;Firstname_Lastname_CV.pdf&lt;/code&gt;. A human sees it in a folder of hundreds.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Not sure which category your file falls into?&lt;/strong&gt; Drop it on the &lt;a href="https://cv.shykov.dev/" rel="noopener noreferrer"&gt;free CV score checker&lt;/a&gt; — it reads both PDF and DOCX in your browser and tells you straight away whether there is extractable text, how many pages a parser sees, and which sections it managed to identify.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://cv.shykov.dev/how-ats-parsing-works" rel="noopener noreferrer"&gt;How ATS resume parsing actually works&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://cv.shykov.dev/ats-resume-checklist" rel="noopener noreferrer"&gt;The ATS resume checklist&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can an ATS read a PDF resume?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes, as long as the PDF contains real text. Modern applicant tracking systems extract text from PDFs routinely. A scanned or image-only PDF has no text to extract and reads as blank.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I send .doc or .docx?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Prefer .docx, the current Word format, unless the employer specifically asks for .doc. Better still, send a text-based PDF unless the form limits you to Word files.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I know if my PDF is text-based?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Open it and try to highlight a line with your cursor, then search for your email address. If you can select text and the search finds your email, the PDF is text-based.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://cv.shykov.dev/pdf-or-docx-for-ats" rel="noopener noreferrer"&gt;cv.shykov.dev&lt;/a&gt;. The checker mentioned here is free, open source and runs entirely in your browser: &lt;a href="https://cv.shykov.dev/" rel="noopener noreferrer"&gt;cv.shykov.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>career</category>
      <category>resume</category>
      <category>webdev</category>
      <category>productivity</category>
    </item>
    <item>
      <title>ATS Resume Checkers That Do Not Upload Your CV</title>
      <dc:creator>Maksym Shykov</dc:creator>
      <pubDate>Mon, 12 Oct 2026 16:17:26 +0000</pubDate>
      <link>https://dev.to/mshykov/ats-resume-checkers-that-do-not-upload-your-cv-218</link>
      <guid>https://dev.to/mshykov/ats-resume-checkers-that-do-not-upload-your-cv-218</guid>
      <description>&lt;p&gt;Most resume checkers require you to upload your CV and hand over an email address. Here is what happens to the file, why it matters while you are still employed, and how a local-only check differs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Short answer
&lt;/h2&gt;

&lt;p&gt;A browser can read and score a PDF or DOCX without sending it anywhere, so an upload is a design choice, not a technical necessity. You can verify a checker yourself in under a minute: open the browser’s Network panel, drop your CV in, and look for a request that carries the file.&lt;/p&gt;

&lt;p&gt;A CV is one of the most sensitive documents most people own. It carries your full name, your personal email, your phone number, your home city, and a complete timeline of who has employed you. Handing it to a website is a bigger decision than it feels like.&lt;/p&gt;

&lt;p&gt;It matters most in the situation people are usually in when they reach for a resume checker: &lt;strong&gt;job hunting while still employed.&lt;/strong&gt; At that point the document is not just personal data, it is evidence of intent.&lt;/p&gt;

&lt;h2&gt;
  
  
  What “upload your resume” usually means
&lt;/h2&gt;

&lt;p&gt;When a tool asks you to upload, the file leaves your device and lands on a server. From there, some combination of the following is normal and often disclosed in the terms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The file is stored, sometimes indefinitely, and tied to the account you created.&lt;/li&gt;
&lt;li&gt;The text is sent to a third-party language model for scoring or rewriting.&lt;/li&gt;
&lt;li&gt;The email you registered with enters a marketing sequence.&lt;/li&gt;
&lt;li&gt;Aggregate data feeds a recruiting product sold to employers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that is necessarily malicious. It is simply the business model: the check is free because you and your document are the product. But it is worth knowing before you click, rather than after.&lt;/p&gt;

&lt;h2&gt;
  
  
  The questions worth asking of any checker
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Does the file leave the device?&lt;/strong&gt; If there is no upload, most other questions stop mattering.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is an account required?&lt;/strong&gt; An email wall exists to capture the email, not to improve the score.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is scoring deterministic or generated?&lt;/strong&gt; If a model writes the feedback, your CV was sent to that model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Can you read the rules?&lt;/strong&gt; A published rubric can be argued with. A hidden one cannot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What is the deletion path?&lt;/strong&gt; If the answer is a support email, assume the file stays.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How a local-only check works
&lt;/h2&gt;

&lt;p&gt;Modern browsers can read a PDF or DOCX without any server involved. The file is opened into memory, the text is extracted by a JavaScript library, the checks run over that text, and the result is rendered — all inside the tab you already have open. Close the tab and the document is gone.&lt;/p&gt;

&lt;p&gt;The trade-off is real and worth stating: a local checker cannot compare you against a database of other candidates, and it cannot rewrite your bullets for you. What it can do is tell you whether a parser will read the document correctly, and which specific lines are weak — which is the part that actually affects whether your application arrives intact.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to check that a checker really does not upload
&lt;/h2&gt;

&lt;p&gt;“We never store your file” is a promise. Whether the file leaves your machine at all is something you can watch happen — or not happen — in any desktop browser:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open the checker, then open the browser’s developer tools: &lt;code&gt;F12&lt;/code&gt; on Windows and Linux, &lt;code&gt;⌥ ⌘ I&lt;/code&gt; on a Mac.&lt;/li&gt;
&lt;li&gt;Switch to the &lt;strong&gt;Network&lt;/strong&gt; tab and clear it, so only new requests show.&lt;/li&gt;
&lt;li&gt;Drop your CV into the checker and wait for the result.&lt;/li&gt;
&lt;li&gt;Look through the new requests. An upload is a &lt;code&gt;POST&lt;/code&gt; or &lt;code&gt;PUT&lt;/code&gt; whose request size is roughly the size of your file. Scripts and fonts the page loads for itself are not uploads.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If no request carries the file, the file did not leave. This works on any site, including the one I built — which is the point: the claim should be checkable by you, not taken on trust.&lt;/p&gt;

&lt;p&gt;My tool adds a second, browser-enforced guarantee. Its Content-Security-Policy header sets &lt;code&gt;connect-src 'self' data: blob:&lt;/code&gt;, which means the browser itself refuses any network connection to another domain, and the one domain it may talk to serves static files only — there is no endpoint to receive a CV.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;That is the local kind of checker.&lt;/strong&gt; The &lt;a href="https://cv.shykov.dev/" rel="noopener noreferrer"&gt;free CV score checker&lt;/a&gt; parses your PDF or DOCX in the browser with &lt;code&gt;pdf.js&lt;/code&gt; and &lt;code&gt;mammoth&lt;/code&gt;, scores it with fixed rules, and has no backend that could receive a file. It is MIT licensed, so the claim is checkable rather than promised.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://cv.shykov.dev/what-is-an-ats-score" rel="noopener noreferrer"&gt;What an ATS score actually measures&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://cv.shykov.dev/pdf-or-docx-for-ats" rel="noopener noreferrer"&gt;PDF or DOCX: which should you send?&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is it safe to upload my resume to an online resume checker?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It depends on what happens to the file, which is usually described only in the terms. Uploaded CVs are commonly stored, tied to an account, and sometimes sent to third-party language models. If you are job hunting while employed, prefer a checker that never receives the file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How can a website read my CV without uploading it?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Browsers can open a file you select into the page’s own memory. JavaScript libraries such as pdf.js and mammoth then extract the text, and the checks run on your device. Nothing needs a server.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I know a resume checker is not uploading my file?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Open the browser’s developer tools, go to the Network tab, clear it, then drop your CV in. An upload typically shows up as a POST or PUT request roughly the size of your file. If there is none, the file stayed on your device.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://cv.shykov.dev/ats-checker-without-upload" rel="noopener noreferrer"&gt;cv.shykov.dev&lt;/a&gt;. The checker mentioned here is free, open source and runs entirely in your browser: &lt;a href="https://cv.shykov.dev/" rel="noopener noreferrer"&gt;cv.shykov.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>privacy</category>
      <category>resume</category>
      <category>career</category>
      <category>webdev</category>
    </item>
    <item>
      <title>What Is an ATS Score, and What Does It Actually Measure?</title>
      <dc:creator>Maksym Shykov</dc:creator>
      <pubDate>Mon, 12 Oct 2026 16:16:16 +0000</pubDate>
      <link>https://dev.to/mshykov/what-is-an-ats-score-and-what-does-it-actually-measure-dhi</link>
      <guid>https://dev.to/mshykov/what-is-an-ats-score-and-what-does-it-actually-measure-dhi</guid>
      <description>&lt;p&gt;An ATS score measures how cleanly a machine can read your CV — not how good a candidate you are. Here is what the number covers, what it cannot see, and the myth to ignore.&lt;/p&gt;

&lt;h2&gt;
  
  
  Short answer
&lt;/h2&gt;

&lt;p&gt;An ATS score estimates how cleanly applicant tracking software can pull your text, contact details, sections and dates out of your CV. It says nothing about whether you fit the job. Above roughly 85, nothing important is getting lost — spend the time on the content instead.&lt;/p&gt;

&lt;p&gt;An applicant tracking system is the software an employer uses to receive, store and search applications. Before a recruiter reads your CV, the system parses it: it pulls out your name, your contact details, your job titles, your dates and your skills, and writes them into database fields. An &lt;strong&gt;ATS score&lt;/strong&gt; is an estimate of how well that step will go.&lt;/p&gt;

&lt;p&gt;That is a narrower claim than it sounds, and the narrowness is the point. The score does not know whether you are right for the job. It measures one thing: whether the document survives being read by a machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The myth worth dropping first
&lt;/h2&gt;

&lt;p&gt;You have probably read that &lt;em&gt;“75% of resumes are rejected by an ATS before a human sees them.”&lt;/em&gt; That figure has been repeated for over a decade and has no credible source behind it. Applicant tracking systems are search and storage tools. They rank and filter on criteria a recruiter sets; they do not autonomously bin three quarters of applicants.&lt;/p&gt;

&lt;p&gt;The real risk is duller and more fixable. If a parser cannot find your email, your application arrives with an empty contact field. If it cannot read your job titles, you do not appear in the recruiter's search for “engineering manager”. Nobody rejected you — you were never in the result set.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a score can reasonably measure
&lt;/h2&gt;

&lt;p&gt;Every honest ATS score is a heuristic built from a handful of checks. On this site the rubric is fixed at 100 points and published, so you can see exactly where each point comes from. In five groups:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Parseability (25)&lt;/strong&gt; — is there real, selectable text, or is the page an image?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contact details (15)&lt;/strong&gt; — email, phone, and a link written out as visible text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sections (35)&lt;/strong&gt; — headings a parser recognises: Experience, Education, Skills, Summary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Format (15)&lt;/strong&gt; — page count, dated roles, real bullet structure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Content (10)&lt;/strong&gt; — quantified impact and verb-led bullets.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And check by check — this table is the one the scorer runs, not a summary of it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Group&lt;/th&gt;
&lt;th&gt;Points&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Machine-readable text&lt;/td&gt;
&lt;td&gt;Parseability&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clean text encoding&lt;/td&gt;
&lt;td&gt;Parseability&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Email address&lt;/td&gt;
&lt;td&gt;Contact&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Phone number&lt;/td&gt;
&lt;td&gt;Contact&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LinkedIn / website link&lt;/td&gt;
&lt;td&gt;Contact&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Experience&lt;/td&gt;
&lt;td&gt;Sections&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Education&lt;/td&gt;
&lt;td&gt;Sections&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Skills&lt;/td&gt;
&lt;td&gt;Sections&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Summary&lt;/td&gt;
&lt;td&gt;Sections&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Achievements / Projects / Certifications&lt;/td&gt;
&lt;td&gt;Sections&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Page count&lt;/td&gt;
&lt;td&gt;Format&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dated history&lt;/td&gt;
&lt;td&gt;Format&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bulleted structure&lt;/td&gt;
&lt;td&gt;Format&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Quantified impact&lt;/td&gt;
&lt;td&gt;Content&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Strong action verbs&lt;/td&gt;
&lt;td&gt;Content&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two details are easy to miss. A missing phone number or summary only costs its own points — they are warnings, not failures — while a missing Experience, Education or Skills heading fails outright. And the last Sections row is a bonus: an Achievements heading is worth 4, Projects 3, Certifications 3.&lt;/p&gt;

&lt;p&gt;Sections carry the most weight because they are what turns a wall of text into structured data. A parser that finds a heading called “Experience” knows the entries beneath it are jobs. Without it, it is guessing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What no score can measure
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Whether your experience matches the role. That is the recruiter's judgement.&lt;/li&gt;
&lt;li&gt;Which specific ATS the employer runs. Vendors parse differently, and none publish their rules.&lt;/li&gt;
&lt;li&gt;Whether your writing is persuasive. A perfectly parseable CV can still be dull.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Treat a score above roughly 85 as “nothing here will get lost in transit” and move on to the content. Chasing the last few points is almost always wasted effort.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why two checkers give you two different scores
&lt;/h2&gt;

&lt;p&gt;There is no standard ATS score. Each checker invents its own rubric, weights it however it likes, and — usually — does not tell you what it is. A 62 on one site and an 81 on another are not a contradiction; they are two different tests. The useful question is never “which number is right” but “which specific check failed, and do I agree it matters?” That is only answerable when the rules are published.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check yours in a few seconds.&lt;/strong&gt; The &lt;a href="https://cv.shykov.dev/" rel="noopener noreferrer"&gt;free CV score checker&lt;/a&gt; scores a PDF or DOCX entirely inside your browser — the file is never uploaded, there is no account, and no language model is involved. You can read the exact scoring rules in the public source.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://cv.shykov.dev/how-ats-parsing-works" rel="noopener noreferrer"&gt;How ATS resume parsing actually works&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://cv.shykov.dev/ats-resume-checklist" rel="noopener noreferrer"&gt;The ATS resume checklist&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What is a good ATS score?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;On this checker, 85 or above means a parser will read the document cleanly and nothing important is lost. 70 to 84 is good with a few specific fixes left. 50 to 69 needs work, usually a missing section heading or contact detail. Below 50 something structural is wrong, most often text the parser cannot extract.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do employers see my ATS score?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not a score from a checker like this one. It is computed on your device and sent nowhere. The employer’s own system may rank applications against the job, but that ranking uses its rules and the recruiter’s search terms, not any third-party score.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does my CV get a different score on every checker?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Because there is no standard. Each checker defines its own checks and weights. Compare the individual checks that failed rather than the headline numbers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is it true that 75% of resumes are rejected by an ATS?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That figure has circulated for over a decade without a credible source. Applicant tracking systems store and search applications; recruiters set the filters. The real risk is a CV the parser misreads, which then fails to appear in a recruiter’s search.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://cv.shykov.dev/what-is-an-ats-score" rel="noopener noreferrer"&gt;cv.shykov.dev&lt;/a&gt;. The checker mentioned here is free, open source and runs entirely in your browser: &lt;a href="https://cv.shykov.dev/" rel="noopener noreferrer"&gt;cv.shykov.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>career</category>
      <category>resume</category>
      <category>privacy</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
