I analyzed 14,905 love letters without reading them

작성자

카테고리:

← 피드로
DEV Community · Enzo · 2026-09-06 개발(SW)

I run LovePaper, a site where you write a letter to someone and it becomes a page with a permanent link. No account, nothing to install.

Last week I wanted to know what people actually write on it. I ran the numbers on September 1st, across 14,905 letters. By the time I published this the database was at 17,780, it adds around 700 a day. Everything below is from that September 1st snapshot.

This post is what I found, how I measured it without opening a letter, the one time I deliberately broke that rule, and the bug that almost made me publish a wrong number.

The constraint came first

A love letter is the most private thing someone will type into a stranger’s website. So the rule was: nothing leaves the database except a count.

No text printed, logged, or written to a file. Every query returns a number, a percentage, or a frequency table. If a question can only be answered by reading, it doesn’t get answered.

let withEmoji = 0, emojiTotal = 0;
const wordCounts = [];
const EMOJI = /\p{Extended_Pictographic}/gu;

for (const row of rows) {
  const t = row.content;
  if (!t?.trim()) continue;
  const ems = t.match(EMOJI) || [];
  if (ems.length) { withEmoji++; emojiTotal += ems.length; }
  wordCounts.push(t.trim().split(/\s+/).length);
}
// `t` goes out of scope here. Only counters survive.

Enter fullscreen mode Exit fullscreen mode

Boring code. That’s the point.

What 14,905 letters look like

The median letter is 156 words. A quarter are under 70. A fifth run past 300. The longest is 689.

51% contain at least one emoji, averaging 6.4 when they do. The eight most used:

❤️ · 😘 · 🤍 · 🥹 · 💗 · 😭 · 💋 · 🥺

Three of those eight are crying faces. I expected hearts.

Over 1,091,079 words with stopwords removed: “sorry” is the 25th most written word. “forever” is the 74th. People apologize about three times more often than they promise anything.

Only 3.3% ever open the edit screen. Whatever comes out the first time is what gets sent.

And 72.1% pick the dark theme. I built the light one first, assuming a love letter wanted to look like paper. Wrong.

The one time I broke my own rule

Before writing, people pick an occasion: birthday, anniversary, proposal, apology, long distance, friendship, or “just because.”

40.5% pick “just because” with every real occasion sitting on the same screen. And apology beats marriage proposal four to one, 420 against 106.

That second number bothered me. 1.5% proposals felt wrong for a love letter site. Either the number was broken or the feature was.

So I opened those letters. All 109 of them. It’s my database and I decided a mislabeled feature was worth the exception, but I want to be straight that it was an exception, not the method.

About eight were actually proposing marriage. The rest were first confessions. People asking someone out. Several written to someone who had no idea yet.

The label said “proposal.” The users meant “I’m about to tell you.” That’s a product bug, and no aggregate would have shown it to me.

The bug that almost made me publish a wrong number

I wanted the language breakdown. First version:

if (hasTagalogMarkers(text)) return "tl";
if (hasPortugueseMarkers(text)) return "pt";
if (hasSpanishMarkers(text)) return "es";
if (hasEnglishMarkers(text)) return "en";

Enter fullscreen mode Exit fullscreen mode

It reported 15% Tagalog. That felt high, so I looked closer.

The function is order-dependent. Any English letter containing one Tagalog word, and love letters borrow words constantly, matched the first branch and never reached the others. I wasn’t detecting language. I was detecting “does this contain any Tagalog word at all.”

The fix was to stop deciding on first match and start scoring:

const score = { en: 0, pt: 0, es: 0, tl: 0 };
for (const word of words)
  for (const lang of LANGS)
    if (MARKERS[lang].has(word)) score[lang]++;

const [top, topScore] = ranked[0];
const [, runnerUp] = ranked[1];

if (topScore < 3) return "undetermined";
if (topScore < runnerUp * 1.3) return "undetermined";  // too close to call
return top;

Enter fullscreen mode Exit fullscreen mode

Two guards matter more than the scoring. A minimum score, so three stray words can’t decide. And a margin over the runner-up, so a near-tie returns undetermined instead of a confident wrong answer.

Real numbers: 53.5% English, 9.3% Portuguese, 6.9% Tagalog, 5.6% Spanish, and 24.7% honestly undetermined, which is the part I’d have buried if I were trying to look clever.

The first version wasn’t broken. It ran fine and returned a plausible number. That’s the dangerous kind.

There is no love song

You can attach a song. 664 letters have one, across 423 distinct tracks, and 79% of those appear exactly once.

No shared soundtrack. Everyone brings their own.

The day I found out about by accident

On September 1st the numbers jumped. 463 letters three days earlier, then 619, then 843, then 1,070 in a single day, the biggest day the site has ever had.

I went looking for a bug. Instead I found that 148 of those letters had the words “letter day” written inside them, in English and in Bengali, people wishing each other a happy one.

September 1st is World Letter Writing Day. I had never heard of it. My users had.

What I’d do differently

Aggregate-only is more work than dumping rows into a notebook, and it rules out whole questions. I only found the mislabeled feature by breaking it on purpose, once, with a reason I could defend out loud.

I still think it’s the right default. The dataset is only interesting because people trusted it with something real.

If you want a number I didn’t cover, ask in the comments and I’ll run it.

Always tell people you love them!! 💌

원문에서 계속 ↗