Engineering a Zero-Dependency Text Repeater: Handling 10,000 Iterations in Pure Client-Side JavaScript
I didn’t set out to build a “happy birthday 100 times” generator because I thought it would be a fascinating engineering problem. I built it because I got annoyed. Twice, actually — once as a user, and once as a developer who opened dev tools out of curiosity and immediately regretted it.
Let me explain both.
The Problem: Why I Built This
A friend asked me to help her put together a birthday message for a group chat — one of those novelty walls of text where “Happy Birthday” repeats a hundred times in a fun shape, the kind people paste into WhatsApp or Telegram to make someone’s notification feed explode. Simple enough. I searched for a tool to do it.
What I got instead was a masterclass in bad web engineering. Site after site loaded four or five tracking scripts before rendering a single character. One had a sticky ad that covered the generate button on mobile. Another rendered the output through a server round-trip, so every time I changed the separator style, the page reloaded. And when I tried bumping the count up toward a few thousand repetitions, one tab actually locked up my phone’s browser for a good ten seconds.
That’s the part that got me. This is a plain text generation problem. There’s no reason a browser should choke on it. So I decided to build my own version and treat it like an actual performance exercise instead of a throwaway toy — a zero-dependency, 100% client-side utility with no backend, no tracking, and no excuse for jank even at 10,000 repetitions.
Engineering the Core Engine: Memory and Performance
The naive approach to “repeat this string N times” is the one everyone reaches for first:
let output = '';
for (let i = 0; i < count; i++) {
output += phrase + separator;
}This works fine at 100 iterations. It starts to hurt at 5,000, and it’s genuinely bad at 10,000. The reason is that JavaScript strings are immutable. Every += doesn't append to the existing string — it allocates a brand new string in memory and copies the old contents into it, character by character. At small scale you don't notice. At scale, you're doing quadratic work: the engine is re-copying an ever-growing string thousands of times over, and the garbage collector is left cleaning up thousands of orphaned intermediate strings. On a mid-range Android phone, that's exactly the kind of thing that turns a tap into a multi-second freeze.
The fix is to stop building the string incrementally and instead build an array, then join it once:
function generateRepeatedText(phrase, count, separator = '\n') {
const isNumbered = separator === 'numbered';
const lines = Array.from({ length: count }, (_, i) => {
if (isNumbered) return `${i + 1}. ${phrase}`;
return phrase;
});
const joinChar = isNumbered ? '\n'
: separator === 'comma' ? ', '
: separator === 'fullstop' ? '. '
: '\n';
return lines.join(joinChar);
}Array.from with a mapping function generates the whole array in one pass, and .join() is implemented natively in the engine — it knows the final string length ahead of time and allocates memory once, rather than reallocating on every iteration. That single change took the 10,000-repetition case from a noticeable stutter to something that resolves in a handful of milliseconds. It's a good reminder that "just use a for loop" isn't always the boring, safe choice — sometimes the boring choice is the slow one.
Layout Rendering: Center vs. Grid Without Frameworks
Generating the string was the easy half. The harder design question was how to display a few thousand lines of repeated text without the page itself becoming the bottleneck.
I wanted three display modes: a centered “poetic” flow for short shareable messages, a continuous left-aligned block for pasting straight into chat apps, and a multi-column grid for scanning large counts visually. I was tempted to reach for a layout library, but honestly, CSS Grid and Flexbox already do this job without any JavaScript involvement:
.output--center {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.output--grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 4px 12px;
}Switching modes is just a class toggle on a container that already holds the generated text node — no re-render, no recalculating layout in JS, no reflow triggered by anything other than the browser’s own layout engine, which is what it’s optimized for anyway. The one thing I had to watch was content-visibility: auto on the output container, which keeps the browser from doing full paint work on rows that are scrolled out of view. At 10,000 lines in grid mode, that one property is the difference between a smooth scroll and a laggy one.
Exporting Without a Server: Copy and TXT Download
The whole point of a tool like this is getting the text out of the browser and into whatever app the person actually wants to paste it into. No backend meant no server-side file generation, so both export paths had to live entirely in the browser.
Clipboard copy uses the modern navigator.clipboard API, with a fallback for older or stricter mobile browsers (Safari on iOS has historically been picky about clipboard permissions outside of direct user gestures):
async function copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
return true;
}
}The key detail is that the copy call has to happen synchronously inside the click handler — if you await something else first and then call writeText, some browsers will silently reject it because it's no longer tied to a direct user gesture.
The .txt export is even simpler, and it’s genuinely one of my favorite small pieces of browser API design:
function downloadAsTxt(text, filename = 'repeated-text.txt') {
const blob = new Blob([text], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}No fetch call, no server endpoint, no temporary file storage anywhere — the Blob lives in memory, createObjectURL gives it a reference the browser can treat like a file, and the anchor tag's download attribute does the rest. Calling revokeObjectURL afterward matters too; skipping it leaks memory if someone generates and downloads repeatedly in the same session, which for a tool that invites exactly that kind of repeated tinkering, adds up.
Performance Metrics and What I’d Tell Other Devs
The finished build has no external JS libraries, no analytics beacon, no web font requests, and no backend to fail or bill me for. Lighthouse scores it 100 across the board, and I’ve stress-tested it at the full 10,000-repetition ceiling on a five-year-old Android phone without a hint of jank. I run the layout switching and export logic on the live implementation at the happy birthday 100 times — worth poking at if you want to see the grid mode handle a five-figure line count without a stutter.
None of the individual techniques here are exotic. Array-based string building over concatenation, native CSS layout instead of JS-driven positioning, Blob URLs instead of a server round-trip — these are old, boring, well-documented browser features. What surprised me is how rare it is to see them actually used together on tools this simple. Most of the bloat I ran into wasn’t from a hard technical problem; it was from nobody bothering to profile a tool that “only” repeats a string.
If you’ve built something similar, or you can see a hole in how I’m handling the 10,000-line edge case, I’d genuinely like to hear about it. Text generation feels like a solved problem right up until you watch it choke a phone in your hand.
