DEV Community

Fuad Laguda
Fuad Laguda

Posted on

The iOS camera lies for about a tenth of a second, and that is why nobody noticed it

Now, two weeks ago I wrote that the iOS camera track reports 1920 by 1080 while the browser draws portrait, and that you should measure the picture instead of trusting the track. Now, altough that is true, it turns out to be half the story, and the missing half is this.

The track does not lie all the time, so maybe I have to change the title of my first post.

But it does lie for about a tenth of a second, and that is exactly why it was not noticed.

The first piece is here: Why your web teleprompter records sideways on iPhone, and the fix.

I was trying to give the fix away

I had the fix working in my own product and I had put it on GitHub under MIT and I wanted a library to take it, to save the next person three nights.

So I checked four libraries

Project Merged pull requests What that told me
react-webcam 0 merged since January 2025 will not merge my work
videojs-record 6 merged since January 2025, all dependabot no human contributions
react-media-recorder 2 merged, then silent for 13 months effectively dead
livekit/client-sdk-js 125 merged against 10 closed unmerged since 1 May 2026 alive

The test said there was no bug four times.

I built a small page against livekit-client 2.22.3 and ran it on my iPhone 15, iOS 26.6.

LiveKit's default 720p preset: the track said portrait, the picture was portrait. The 1080p preset: same. Microphone in the same call: same. My own exact constraints with no LiveKit in the path at all: same.

Four runs, no mismatch anywhere, then I sat with that for a while. I had a published article saying the track lies, and my own test was telling me it does not.

The fact that would not fit

Same phone, same ten minutes, I opened the demo with the fix switched off and recorded a take, and there it was the bug.

Those two things cannot both be true, if the track reports portrait, the fix has nothing to trigger on, and fix on and fix off would produce the same file, but they did not, so the phone was not wrong it is my test.

The window

I stopped waiting for the preview and read the track the moment it existed, then kept reading. Again it was there I noticed something, it was quick, but i saw a flip because my android lags a lot. so I decided to check the numbers.

track created                     +1059 ms
track.dimensions immediately       1920 x 1080   landscape
waitForDimensions() returned       1920 x 1080   at +1071 ms
track.dimensions after 1st frame   1080 x 1920   portrait, at +1473 ms
Enter fullscreen mode Exit fullscreen mode

A second, separate run: 1920 by 1080 at +1377 ms, then 1080 by 1920 at +1516 ms. A gap of 139 ms against 112 ms in the first run, same behaviour, different number.

Keep that in mind, it matters later.

So the camera reports the raw sensor frame until it has actually produced a picture, then it corrects itself. Read inside that window and you get landscape, then read outside it and you get portrait, so both of my earlier claims were true, they were just true at different milliseconds.

Why the bug hides

Every one of my four clean tests waited for the preview to appear before reading the numbers. That is the most natural thing in the world to write. It is also the one thing that puts you on the wrong side of the window every single time. If not for the fact that my Android is slow and lags, I would not have noticed that.

A test written without knowing the window exists cannot see the bug, no matter how carefully it is written.

That is not an iOS or Android lesson, any time a value is eventually right, a test that waits for things to settle will pass, and the code that did not wait will fail in production.

The year-old issue this explains

LiveKit had issue 1377, "Incorrect orientation of a remote video track's dimensions", opened in January 2025. Three separate people reported it over thirteen months. A core engineer replied, reopened it once when the bot closed it, and then it was closed again with nothing changed. Their comment on it was honest and nearly right: "browsers report wrong initial resolution on iOS."

Here is the mechanism. waitForDimensions sleeps 10 ms on iOS and then polls until dimensions exist. They exist immediately, and they are wrong, so it returns at about +12 ms with the landscape pair. That pair goes out to the room as req.width and req.height. A portrait phone announces itself as landscape, and everyone on the call lays it out sideways.

Nobody had the window and without it, the bug looks like a flaky report that never reproduces.

Why a longer sleep is not the fix

112 and 139. Those are two runs on the same phone, minutes apart. The window changes with the device, the camera, the requested resolution and how busy the phone is. Any fixed delay is a race you will eventually lose, and it taxes every user who did not need the wait. The signal is the first frame, not the clock.

What to do instead

Two shapes, and I have used both.

Wait for a frame, not a duration. A detached video element with requestVideoFrameCallback fires when the first frame has actually gone through the pipeline. Safari 15.4 and up, Chrome too, nothing needs to be in the DOM.

const v = document.createElement('video')
v.muted = true
v.playsInline = true
v.srcObject = new MediaStream([track])
await v.play()
await new Promise<void>((done) => v.requestVideoFrameCallback(() => done()))
const { width, height } = track.getSettings() // now correct
Enter fullscreen mode Exit fullscreen mode

Or stop trusting the report at all, draw one frame onto a 16 by 16 canvas and look at which corner got paint. One drawImage, one getImageData, deterministic. That is the probe in camera.ts in the repo, and it is what my product ships.

What happened when the window was written down

I filed the measurement as issue 2099 on 14 September with the timing table above and the first shape as a suggestion. A LiveKit engineer replied the same day and opened PR 2100, which waits for the first frame on iOS. I ran their branch on the phone, normal power and Low Power Mode, and posted the readings as a review. One thing did not sit right: the gate was getBrowser().os === 'iOS', and an iPad sends a Mac user agent, so iPads would skip the wait. PR 2100 merged on 17 September.

I filed the iPad gap as issue 2107 with two possible shapes. They measured the first, found the first frame can take 500 to 1000 ms on macOS, and took the second, which is treat Safari with a Mac user agent and more than one touch point as iPadOS. PR 2110 went up on 18 September and I ran that build on the iPhone and on an Android and posted the numbers.

So, the first report to first fix was six hours. First report to second fix, four days.
Thirteen months before that, with three reporters and nothing changed. The difference was not effort or skill on anyone's part, it was one measurement that nobody had taken, and who would care about what happened in the tenth of a second? Silly enough my app did.

Top comments (0)