My main goal was to NOT have two applications (one in Elm, one in JS) that need to synchronize state, but instead keep all state inside the Elm app, use ports to mutate the opaque JSON values, and display video streams using custom elements.
When comparing it to a solution without custom elements, it is obvious that I was able to make do with fewer ports.
I definitely liked that it allowed me to react to DOM events emitted by the custom elements instead of needing to use ports. Which would either listen to many more events at a time than actually needed, or where I had to create many different subscriptions depending on the app state.
Instead I could write it like this inside the view functions to e.g. wait until the custom elment creates a new RTCPeerConnection object.
-- in ./src/Active/View.elm viewPending : Model.PendingUser -> Html Msg viewPending user = H.node "webrtc-media" [ ... , onCustomEvent "new-peer-connection" (Msg.UserUpdated user.id) Msg.peerConnectionDecoder ] 
And then use a port to add the local media stream to it, and create an SDP offer.
-- in ./src/Active/Update.elm updatePendingUser : Msg.UserId -> Model.Stream -> Msg.Updated -> Model.PendingUser -> ( User, Cmd msg ) updatePendingUser ownId localStream msg user = case msg of Msg.NewPeerConnection pc -> ( Model.User ... , if ownId < user.id then -- executes `initiateSdpOffer/3 in ./src/index.js Ports.Out.createSdpOfferFor user.id pc localStream else .. -- in this case we would expect to receive an SDP offer from the other peer ) ...
This offer will then be sent over the signaling server to the other browser and start the negotation process.
Elm code and a simple signaling server are available on github.
I would also love to hear feedback, especially if it makes sense in your point of view to rely on custom element communication or not, or if someone noticed problems and would think ports would offer a cleaner approach.