I spent a month measuring it, one round trip at a time, on a real phone. Here is what came back. Everyone knows chat apps "compress" photos. Almost nobody knows how, because the answer is not in any documentation — you have to send an image, download what returns, and read the JPEG header. So that is what I did, several hundred times, and the results were more specific and more useful than I expected. Every platform has a fingerprint A JPEG carries the quantization table it was encoded with. Most software uses a scaled version of the reference table in the JPEG standard's Annex K — pick a quality number, the table scales, done. So if you know what Annex K looks like, you can usually tell what encoded a file. Two of these platforms do not use Annex K at all. WhatsApp, standard send. Luma table, min 6, max 167, mean 35.6: 6 6 6 7 10 15 22 34 6 7 8 11 14 16 21 30 6 8 10 12 17 25 36 54 7 11 12 16 21 30 42 62 10 14 17 21 28 38 52 76 15 16 25 30 38 50 68 95 22 21 36 42 52 68 90 124 34 30 54 62 76 95 124 167 This is not Annex K scaled. The best-fit scale is 0.650 — nominally around quality 68 — but the per-coefficient ratio ranges from 0.28 to 1.69, with a 37% mean residual. The average step is close to quality 65; the shape is completely different, with a much steeper roll-off. Low frequencies are barely touched at 6 to 12, and the far corner is crushed at 167. It is also content-independent. I saw the identical table across 82 returned files spanning five different output geometries. Not similar — byte-identical. Instagram. Min 5, max 25, mean 15.8, and progressive: 5 6 11 11 13 14 15 16 6 8 11 11 14 13 16 16 11 11 11 13 14 16 15 17 11 11 13 14 17 19 19 19 13 14 14 17 18 20 22 22 14 13 16 19 20 22 21 25 15 16 15 19 22 21 22 22 16 16 17 19 22 25 22 18 Very flat, very gentle. And Facebook returns a byte-identical table — same Meta pipeline, so you cannot tell an Instagram file from a Facebook one by its quantization table alone. Only the geometry separates them. Telegram is the ordinary case: a scaled Annex K near quality 87. Nothing custom. WhatsApp HD is a different table again, min 4, max 19, mean 12.2 — much finer than the standard send, which is what you would hope from a mode called HD. Geometry is the part that actually matters The tables are interesting. The geometry decides whether anything works. Platform What it does to the frame WhatsApp, standard Caps the long edge at 1600. At or below, dimensions pass through untouched WhatsApp, HD on Keeps 4096 — a 4096×3072 photo returns 4096×3072 Telegram, as photo Re-encodes everything to 1280×960 Telegram, as file Untouched. Byte-identical, in my tests Instagram Does not cap — normalises onto a 1440×1440 square canvas Facebook 2048 long edge X/Twitter 4096 long edge Two of these surprised me. Instagram does not have a maximum size, it has a shape. Upload something 1080 wide and it comes back upscaled to 1440. Upload 4:3 and it comes back padded to square. There is no size small enough to be safe, because the normalisation is not a cap. WhatsApp caps the long edge, not the width. A portrait photo comes back 1200×1600, not 1600×1200. Obvious in hindsight; not obvious when your code says width. The bit that bites: EXIF orientation Send a photo your phone took in portrait, and there is a good chance it is stored as landscape pixels with an EXIF flag saying "rotate this 90°". WhatsApp applies that flag physically and strips it. I sent a 3840×2160 file with orientation=6 and got back 2160×3840 with no orientation tag. The pixels were rotated, not relabelled. For ordinary photos this is invisible and correct. For anything that depends on where pixels sit — watermarking, hidden data, forensic comparison — a 90° rotation is total loss, and it happens silently to a photo the user shot normally. Why any of this matters I was measuring all this because I was building steganography: hiding data inside a photo so it survives being sent through a chat app. That turns out to be almost entirely a geometry problem. Hidden data in JPEG lives in the 8×8 DCT blocks the format is built on. If a platform resamples the image, the spacing of that grid changes, the decoder starts reading across block boundaries, and the payload is not degraded — it is destroyed. Every catastrophic failure I measured came out at roughly 50% bit error rate, which is the same as guessing. So the whole game is not stronger error correction. It is handing the platform an image it has no reason to touch. Match its output geometry and the grid survives intact. Then there is a second, subtler win. If you encode your image using the platform's own quantization table, its re-encode has nothing left to change. I extracted Meta's table from images Instagram returned and encoded on it directly. The share of the embedding band that survived went from 85.9% to 100%, and — the part I did not expect — the required embedding strength halved, so the image came out visibly cleaner as well as more robust. Things I got wrong Worth recording, since the wrong turns took longer than the right ones. PSNR is misleading for this. It ranked a visibly dotted image above a clean one, because it averages squared error across the whole frame and cannot see that the error is concentrated in the flat areas where the eye looks. If you are comparing image quality on PSNR alone, be suspicious — including of results that flatter you. A texture-adaptive step size made things worse, not better. The idea was sound: perturb more where texture hides it, less on flat walls and sky. What actually happened is that after hard recompression, blocks shift between texture bands, so the decoder reads whole blocks at the wrong strength. That is whole-block error, and both Reed–Solomon and bit repetition assume errors are sparse. Three separate parameters all plateaued at exactly the same point, which is what finally gave it away. Turning the feature off fixed it — at a smaller step, so the images got quieter too. Simulated channels flatter you. A resize-plus-re-encode simulator made Instagram look like the easy case. On a real account it was the hardest, because its damage is sharpening, not compression, and no quality setting reproduces that. Reproduce it yourself None of this needs special equipment: a phone, a few photos, and Pillow. from PIL import Image import numpy as np im = Image.open("returned_from_whatsapp.jpg") q = np.array(im.quantization[0]).reshape(8, 8) print(im.size, q.min(), q.max(), q.mean()) Send an image, download what comes back — the actual file, never a screenshot — and read the table. If it matches one of the fingerprints above, you have an authentic platform return. If it fits a scaled Annex K almost perfectly, something re-encoded it on the way and whatever you measure from it is not the platform's behaviour. That last point cost other people real results in a contest I was part of: calibration samples were shared through a channel that quietly downscaled them to 1024px, and the measurements had to be withdrawn. The irony was not lost on anyone. Try the round trip, not the demo All of this went into Stegstr, an open-source app that hides a message — or a whole feed — inside a photo, in a way that survives WhatsApp, Telegram, Instagram, Facebook and X. It is local-first: embedding and detecting happen on your machine, and the platform carrying the image never knows it carried anything. https://stegstr.com/r/UE8635 One thing worth knowing before you start: hiding a message in a photo is the boring half. Anyone can do that. The interesting half is whether it comes back out after a platform has had it — and you cannot see that by embedding an image and looking at it. There is a browser version that embeds and detects with nothing leaving the page, which is the fastest way to see the mechanism. But it does not do the part worth seeing. For the full loop you want the desktop app: Install it from the link above — Mac, Windows, Linux. Turn Network on. It ships off, and in local mode nothing is sent anywhere. Post a note. Anything. This is what ends up inside the photo. Embed your feed into a photo, choosing the platform you are about to send it through. That choice sets the geometry, and it is the thing this whole article is about. Send the image to someone through that platform — as a photo, not as a file, or you have skipped the compression entirely. Have them open it at the other end. If it works, they see your notes, recovered from a photo that went through WhatsApp's re-encoder. If it fails, you have learned something specific about a platform, and I would like to hear about it — the measurements above are one device and one session each, and the honest limit of this work is that nobody has repeated it. Two people sending images back and forth is the actual use case, and also the only way to find out whether any of this generalises beyond my phone. If you would rather not install a GUI, there is a CLI and an MCP server exposing the same encoder: git clone https://github.com/brunkstr/Stegstr.git && cd Stegstr/src-tauri cargo build --release --bin stegstr-cli ./target/release/stegstr-cli post "hello from a script" --ref UE8635 --json ./target/release/stegstr-cli embed cover.jpg -o out.jpg --payload "hidden" --json There is also a dependency-free Node CLI (node dist-cli/stegstr.mjs) that needs no compiler, and an MCP server (stegstr-cli mcp) if you want an agent to do the embedding. The measurements above, and the returned files behind them, are committed in the repository. You do not have to take my word for any of it.