DEV Community

shweta dinkar
shweta dinkar

Posted on

What WhatsApp, Instagram and Telegram actually do to your photos

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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())
Enter fullscreen mode Exit fullscreen mode

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:

  1. Install it from the link above — Mac, Windows, Linux.
  2. Turn Network on. It ships off, and in local mode nothing is sent anywhere.
  3. Post a note. Anything. This is what ends up inside the photo.
  4. 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.
  5. Send the image to someone through that platform — as a photo, not as a file, or you have skipped the compression entirely.
  6. 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
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.