Dustin Hopkins kicked four field goals for the Browns (3-2) — the last with 1:40 left, capping a drive helped by a disputed unnecessary roughness call against San Francisco (5-1).
Cleveland pulled off the upset without quarterback Deshaun Watson, who missed his second straight game with a bruised right shoulder.
The 49ers came in looking nearly unbeatable after destroying Dallas 42-10 at home last week. But Cleveland’s defense was up to the task, and the 49ers were short-handed without playmakers Christian McCaffrey and Deebo Samuel, who both went out with injuries.
P.J. Walker came off Cleveland’s practice squad and outplayed Purdy, who threw his first interception this season and finished with just 125 yards.
The 23-year-old Purdy had won his first 10 starts and nearly pulled out a stirring comeback. He drove the 49ers into field-goal range in the final seconds before Moody pushed his attempt to the right.
When the ball sailed into the net, Cleveland’s sideline erupted in celebration and Browns fans danced in the aisles.
Purdy was just 12 of 27 for 125 yards.
The 49ers were heavy favorites at the start of the week, and their odds only grew when the Browns ruled out Watson on Friday.
They turned to Walker, who was only signed in August, and the 28-year-old showed poise on Cleveland’s final drive — and got away with a questionable throw into the end zone — to set up Hopkins for the winner.
The Browns were also helped with the roughness call on 49ers safety Tashaun Gipson Sr., who spent four seasons with Cleveland. On 3rd-and-10, Walker’s throw for Elijah Moore was high but Gipson was called for the personal foul for leading with his helmet.
Walker completed 18 of 34 passes for 192 yard with two interceptions.
Purdy was out of sync most of the afternoon against Cleveland’s top-rated defense, and things got even tougher for the second-year QB when Samuel went out with a shoulder injury and McCaffrey couldn’t push through an oblique issue.
Still, Purdy got an interference call on San Francisco’s final possession and made a couple nice throws to Jauan Jennings before the 49ers brought out Moody. He came in 9 of 9 on field-goal tries before missing a 54-yarder in the first half.
On the road for the first time in four weeks, the 49ers began mixing it up with the Browns about 30 minutes before the opening kick.
Tempers flared during warmups with a brief skirmish on the 49ers sideline. There was a lot of trash talk and San Francisco left tackle Trent Williams shoved Moore from the side and knocked his helmet off.
Helped by two personal fouls against the Browns, the 49ers scored on their first possession with Purdy shoveling a short pass to McCaffrey, who made two quick cuts got to the outside and went in untouched.
McCaffrey’s touchdown extended his streak of scoring to 15 straight games, two shy of the NFL record held by Hall of Famer Lenny Moore.
McCaffrey is tied with John Riggins and O.J. Simpson for second place.
Dozens of players were involved in the pregame skirmish, which began with Browns safety Juan Thornhill exchanging words with 49ers wide receiver Brandon Aiyuk.
After order was restored by the officials, several of the Browns stared down the 49ers as they left the field.
There were no penalties or ejections, but fines seem likely.
IRON MAN OUT
Browns All-Pro left guard Joel Bitonio had his streak of consecutive regular-season starts end at 102 games. He had also played 6,481 straight snaps.
Bitonio underwent an arthroscopic procedure on his knee during the bye week. Although he was close to returning, he opted to rest another week. He’s expected to play next week at Indianapolis.
49ers: Samuel spent an extended period in the medical tent before going to the locker room. He spent the second half on the sideline in street clothes. ... McCaffrey tried twice to come back in before being ruled out.
Browns: LB Anthony Walker sustained a concussion and didn’t play in the second half.
Browns at Colts, 1 p.m., CBS, 1290, 95.7