Direct messages contain our most intimate conversations, jokes, memories, and photos. Yet, when searching for tools to visualize chat history or generate Spotify Wrapped-style stats for group chats, virtually every existing tool asks for your Instagram login or forces you to upload your sensitive ZIP archives to their cloud servers.
I wanted to change that. I built Stats Insta — a completely free, 100% client-side Instagram Chat Analyzer where your data never leaves your device.
In this article, I will share the engineering challenges behind parsing multi-megabyte JSON exports in the browser, decoding escaped Unicode emojis, computing deep conversational metrics, and generating 9:16 Instagram Story cards directly from the DOM.
🔒 The Architecture: 100% Client-Side Privacy
The core philosophy behind Stats Insta is Local-First Processing:
- Zero Server Uploads: The application does not have a backend parsing server or database for chat messages.
-
Native HTML5 FileReader: When a user drops a
message_1.jsonfile, the browser reads the file directly into client-side RAM usingFileReader.readAsText(). - Session-Only Memory: Once you refresh or close the browser tab, the data is instantly garbage-collected by the browser engine.
You can verify this in Chrome DevTools (F12 > Network Tab): dropping a 50MB conversation file triggers 0 network POST requests.
⚙️ Technical Challenge 1: Decoding Instagram's Unicode Escapes
When Meta exports your message history in JSON format, emojis and special characters are not stored as standard UTF-8 strings. Instead, they are encoded as escaped byte sequences (e.g., \u00e2\u009d\u00a4 for a red heart).
If rendered directly in React, those characters appear garbled (mojibake). The subtle part is the input shape: by the time the file has gone through JSON.parse, the strings are no longer literal \uXXXX escape text. Each UTF-8 byte has become its own code point, so the red heart arrives as the three characters U+00E2, U+009D and U+00A4. The fix is to reinterpret those code points as UTF-8 bytes, which is exactly what the utf8 package does. The parser applies it to message content, sender names and reactions as each file is parsed:
import utf8 from "utf8";
const decodedContent = utf8.decode(msg.content ?? "");
const decodedSenderName = utf8.decode(msg.sender_name ?? "");
const decodedReactions = msg.reactions?.map((r) => ({
...r,
reaction: r.reaction ? utf8.decode(r.reaction) : r.reaction,
actor: r.actor ? utf8.decode(r.actor) : r.actor,
}));
That turns the byte-sequence characters back into native emojis at parse time, without lagging the main thread.
Correction (October 2026): the first version of this section showed a different helper that rewrote \uXXXX sequences to %uXXXX and passed them to decodeURIComponent. As a reader pointed out, %u is not valid input for decodeURIComponent, so that helper threw and returned its input unchanged - it never actually decoded anything. The utf8.decode step above is what the shipped code has always used, and the dead helper has since been removed from the codebase.
📊 Technical Challenge 2: Calculating Deep Conversational Metrics
Simple message counts are boring. People care about the hidden dynamics of their friendships. We compute several unique behavioral metrics in browser memory:
1. The "Night Owls" Tracker (12:00 AM – 4:59 AM)
We parse the Unix millisecond timestamps (timestamp_ms) on each message and isolate activity occurring in the late-night window:
const hour = new Date(msg.timestamp_ms).getHours();
if (hour >= 0 && hour <= 4) {
nightOwlCounts[msg.sender_name] = (nightOwlCounts[msg.sender_name] || 0) + 1;
}
This immediately reveals who stays awake sending reels and memes while everyone else is asleep.
2. The Conversation Starter Algorithm
Who puts in the effort to keep the conversation alive? We sort the timeline chronologically and scan for silence gaps exceeding 12 hours:
const TWELVE_HOURS_MS = 12 * 60 * 60 * 1000;
if (lastTimestamp === 0 || msg.timestamp_ms - lastTimestamp > TWELVE_HOURS_MS) {
starterCounts[msg.sender_name] = (starterCounts[msg.sender_name] || 0) + 1;
}
lastTimestamp = msg.timestamp_ms;
3. The Swear Jar
We scan message strings against a local tokenized dictionary of 100+ global curse words and slang to generate a friendly group chat leaderboard of who has the dirtiest mouth.
📱 Technical Challenge 3: Generating 9:16 Instagram Story Wrapped Cards
People love sharing aesthetic visual summaries with their friends. To make the tool virally shareable, I built an in-browser Story Card generator that renders a 1080x1920 (9:16 Full HD) graphic using HTML5 Canvas:
- Glassmorphism gradient backgrounds with radial glowing orbs
- Rounded progress bars showing conversational dominance percentages
- Dynamic badges and highlights
-
1-Click Native Share: On mobile devices, we invoke the Web Share API (
navigator.share({ files: [pngFile] })), which opens the native iOS/Android share sheet directly into Instagram Stories!
🚀 Try It Live & Explore the Code
The project is completely free to use, and I have published detailed guides on understanding Meta's export architecture:
- 🌐 Live Application: https://stats-insta.vercel.app/
- 📘 Guide: How to Open & Read Instagram message_1.json Files
- 📙 Guide: Activity Heatmaps & Peak Texting Hours
I would love to hear feedback from the DEV community! What other client-side analytics or visual metrics would you like to see added?
Update - October 2026
Stats Insta now works with WhatsApp chats as well as Instagram exports. The same in-browser analyzer can read a WhatsApp .txt or ZIP export and show message counts, activity patterns, media and deleted-message stats, link sharing, fastest responders, and Wrapped-style cards.
New pages since this post:
- WhatsApp chat analyzer: https://stats-insta.vercel.app/whatsapp-chat-analyzer
- Instagram Wrapped: https://stats-insta.vercel.app/instagram-wrapped
- How to export a WhatsApp chat: https://stats-insta.vercel.app/guides/export-whatsapp-chat
- WhatsApp chat statistics guide: https://stats-insta.vercel.app/guides/whatsapp-chat-statistics
Everything still runs locally in your browser. Your chat files are not uploaded to a server.
Top comments (2)
You're right — good catch. That snippet is a leftover helper and it's misleading as written: %uXXXX isn't something decodeURIComponent understands, so any literal \uXXXX input throws and the catch returns the text unchanged.
The parser that actually ships doesn't depend on it. By the time Meta's file has gone through JSON.parse, the strings aren't literal backslash-u text anymore — they're mojibake-style code points (the heart reaction arrives as U+00E2 U+009D U+00A4), and the parse step runs content, sender names and reactions through utf8.decode, which turns that byte sequence back into ❤.
So yes: different input shape. The working code gets post-JSON.parse mojibake, not raw \uXXXX strings. I'm removing the dead helper and correcting the snippet in the post to show the utf8.decode step instead. Thanks for reading it that closely.
I'm impressed by your creativity in building a client-side Instagram chat analyzer. What motivated you to focus on in-browser processing, and how do you handle data privacy and security concerns without relying on cloud uploads?